diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioLifecycleController.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioLifecycleController.kt index 2dd91d4..8e63ad3 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioLifecycleController.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioLifecycleController.kt @@ -50,7 +50,7 @@ internal class AndroidAudioLifecycleController( private val mainHandler = Handler(Looper.getMainLooper()) private var callbackRegistered = false - private var currentRouteType: String? = null + private var currentRouteFingerprint: String? = null private val audioDeviceCallback = object : AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array) { @@ -121,17 +121,38 @@ internal class AndroidAudioLifecycleController( device: AudioDeviceInfo?, ) { val routeType = classifyCurrentRoute(device) - if (routeType == currentRouteType) { + val routeFingerprint = buildRouteFingerprint(routeType) + if (routeFingerprint == currentRouteFingerprint) { return } - currentRouteType = routeType - Log.i(TAG, "route changed to: $routeType") + currentRouteFingerprint = routeFingerprint + Log.i(TAG, "route changed to: $routeType fingerprint=$routeFingerprint") channel?.invokeMethod( "handleRouteChange", mapOf("routeType" to routeType), ) } + private fun buildRouteFingerprint(routeType: String): String { + val selectedCommunicationId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.communicationDevice?.id?.toString().orEmpty() + } else { + "" + } + val inputs = audioManager + .getDevices(AudioManager.GET_DEVICES_INPUTS) + .map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" } + .sorted() + .joinToString("|") + val outputs = audioManager + .getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .map { "${it.id}:${it.type}:${it.productName?.toString()?.trim().orEmpty()}" } + .sorted() + .joinToString("|") + return "$routeType#$selectedCommunicationId#$inputs#$outputs" + } + /** * Classify the current audio output route into a stable string * matching the iOS route-classification schema so the Dart-side diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt index b979f29..8a604fe 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt @@ -1,6 +1,8 @@ package app.chanora.chanora_flutter import android.content.Context +import android.Manifest +import android.content.pm.PackageManager import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager @@ -8,6 +10,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log +import androidx.core.content.ContextCompat import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -121,6 +124,18 @@ internal class AndroidAudioOutputController(context: Context) : ) return false } + if (target.requiresBluetoothConnectPermission() && + ContextCompat.checkSelfPermission( + appContext, + Manifest.permission.BLUETOOTH_CONNECT, + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.w( + TAG, + "setCommunicationDevice blocked for bluetooth target=${target.logLabel()}: BLUETOOTH_CONNECT not granted", + ) + return false + } val changed = audioManager.setCommunicationDevice(target) Log.i(TAG, "setCommunicationDevice device=${target.logLabel()} changed=$changed selected=${audioManager.communicationDevice?.logLabel()}") return changed @@ -230,6 +245,11 @@ internal class AndroidAudioOutputController(context: Context) : type == AudioDeviceInfo.TYPE_BLE_BROADCAST) } + private fun AudioDeviceInfo.requiresBluetoothConnectPermission(): Boolean = + normalizedType() == "bluetoothA2dp" || + normalizedType() == "bluetoothSco" || + normalizedType() == "bluetoothLe" + private fun AudioDeviceInfo.logLabel(): String = "id=$id type=${normalizedType()} product=${productName?.toString()?.trim().orEmpty()}" diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidBluetoothScoController.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidBluetoothScoController.kt index 9da82e0..4eb88db 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidBluetoothScoController.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidBluetoothScoController.kt @@ -2,44 +2,50 @@ package app.chanora.chanora_flutter import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothProfile +import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager +import android.media.AudioDeviceInfo import android.media.AudioManager +import android.os.Build import android.util.Log +import androidx.core.content.ContextCompat /** - * Manages Android Bluetooth SCO (Synchronous Connection-Oriented) audio - * for the Chanora voice session. + * Manages Android Bluetooth voice routing for the Chanora voice session. * * Trace: SDD-110 (Android Bluetooth SCO) * - * Bluetooth SCO is the low-latency, monaural audio path used by Bluetooth - * headsets for phone calls. Without SCO, voice audio may route through - * A2DP which is stereo, high-latency, and lacks the codec support for - * two-way communication. On Android we must explicitly start/stop SCO - * when a Bluetooth headset is present; the platform does not auto-manage - * this for VoIP apps. + * On Android 13 / API 33 and newer, VoIP apps are expected to select a + * Bluetooth communication route with `AudioManager.setCommunicationDevice()` + * so BLE audio headsets are supported. On older Android releases we fall + * back to legacy SCO start / stop management. * * ## Lifecycle * * 1. [start] — called by the Rust audio engine (via JNI) after the Oboe - * voice streams are opened. Calls `AudioManager.startBluetoothSco()` + * voice streams are opened. On API 33+ this prefers + * `AudioManager.setCommunicationDevice()` for Bluetooth communication + * devices. On older releases it calls `AudioManager.startBluetoothSco()` * if a Bluetooth SCO-capable device is connected and registers a * `BroadcastReceiver` for `ACTION_SCO_AUDIO_STATE_UPDATED`. - * 2. SCO state changes are forwarded to the Rust engine via + * 2. Legacy SCO state changes, or synthetic connected/disconnected state + * changes for the API 33+ path, are forwarded to the Rust engine via * `publishScoStateChange(int)`, a JNI function declared in * `crates/chanora_audio/src/android_voice_unit.rs`. - * 3. [stop] — called by the Rust engine on voice stop. Calls - * `AudioManager.stopBluetoothSco()` and unregisters the receiver. + * 3. [stop] — called by the Rust engine on voice stop. Clears the selected + * communication device on API 33+ when it is one we selected, otherwise + * falls back to `AudioManager.stopBluetoothSco()` and receiver teardown. * * ## Thread model * * `start` / `stop` are called from a tokio worker thread (via JNI). - * `AudioManager.startBluetoothSco` is asynchronous — the platform - * responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives on the - * main thread via the `BroadcastReceiver`. + * `AudioManager.startBluetoothSco` is asynchronous on legacy devices — + * the platform responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives + * on the main thread via the `BroadcastReceiver`. */ internal class AndroidBluetoothScoController { @@ -72,6 +78,16 @@ internal class AndroidBluetoothScoController { return } scoStarted = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (trySelectBluetoothCommunicationDevice(appContext)) { + return + } + Log.i( + TAG, + "No selectable Bluetooth communication device on API 33+; legacy SCO fallback is skipped", + ) + return + } registerScoReceiver(appContext) tryStartSco(appContext) } @@ -86,12 +102,17 @@ internal class AndroidBluetoothScoController { fun stop(context: Context) { val appContext = context.applicationContext scoStarted = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clearSelectedBluetoothCommunicationDevice(appContext) + return + } unregisterScoReceiver(appContext) tryStopSco(appContext) } private var scoStarted: Boolean = false private var receiverRegistered: Boolean = false + private var selectedCommunicationDeviceId: Int? = null private val scoReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -173,6 +194,15 @@ internal class AndroidBluetoothScoController { Log.e(TAG, "AudioManager unavailable; cannot start SCO") return } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT, + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping SCO start") + return + } if (isBluetoothScoOn(am)) { Log.i(TAG, "SCO already on; no-op") @@ -212,6 +242,69 @@ internal class AndroidBluetoothScoController { } } + private fun trySelectBluetoothCommunicationDevice(context: Context): Boolean { + val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (am == null) { + Log.e(TAG, "AudioManager unavailable; cannot select communication device") + return false + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return false + } + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT, + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.i(TAG, "BLUETOOTH_CONNECT not granted; skipping bluetooth communication device selection") + return false + } + val target = am.availableCommunicationDevices.firstOrNull { it.isBluetoothCommunicationDevice() } + if (target == null) { + Log.i(TAG, "No Bluetooth communication device available on API 33+") + return false + } + val current = am.communicationDevice + if (current?.id == target.id) { + selectedCommunicationDeviceId = target.id + publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED) + Log.i(TAG, "Bluetooth communication device already selected: ${target.logLabel()}") + return true + } + val changed = am.setCommunicationDevice(target) + Log.i( + TAG, + "setCommunicationDevice bluetooth target=${target.logLabel()} changed=$changed selected=${am.communicationDevice?.logLabel()}", + ) + if (changed) { + selectedCommunicationDeviceId = target.id + publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_CONNECTED) + } + return changed + } + + private fun clearSelectedBluetoothCommunicationDevice(context: Context) { + val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (am == null) { + Log.e(TAG, "AudioManager unavailable; cannot clear communication device") + return + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return + } + val selectedId = selectedCommunicationDeviceId + val current = am.communicationDevice + if (selectedId == null || current?.id != selectedId) { + selectedCommunicationDeviceId = null + Log.d(TAG, "No app-selected Bluetooth communication device to clear") + return + } + am.clearCommunicationDevice() + selectedCommunicationDeviceId = null + publishSyntheticScoState(AudioManager.SCO_AUDIO_STATE_DISCONNECTED) + Log.i(TAG, "clearCommunicationDevice() dispatched for bluetooth route") + } + private fun tryStopSco(context: Context) { val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager if (am == null) { @@ -230,6 +323,25 @@ internal class AndroidBluetoothScoController { } } + private fun publishSyntheticScoState(state: Int) { + try { + publishScoStateChange(state) + } catch (t: Throwable) { + Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t) + } + } + + private fun AudioDeviceInfo.isBluetoothCommunicationDevice(): Boolean = + type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + (type == AudioDeviceInfo.TYPE_BLE_HEADSET || + type == AudioDeviceInfo.TYPE_BLE_SPEAKER || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + type == AudioDeviceInfo.TYPE_BLE_BROADCAST))) + + private fun AudioDeviceInfo.logLabel(): String = + "id=$id type=$type product=${productName?.toString()?.trim().orEmpty()}" + private fun scoStateName(state: Int): String = when (state) { AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED" AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED" diff --git a/apps/chanora_flutter/assets/models/ten_vad.onnx b/apps/chanora_flutter/assets/models/ten_vad.onnx deleted file mode 100644 index 07b2816..0000000 Binary files a/apps/chanora_flutter/assets/models/ten_vad.onnx and /dev/null differ diff --git a/apps/chanora_flutter/lib/design/platform_capabilities.dart b/apps/chanora_flutter/lib/design/platform_capabilities.dart index e5cbcbb..5182578 100644 --- a/apps/chanora_flutter/lib/design/platform_capabilities.dart +++ b/apps/chanora_flutter/lib/design/platform_capabilities.dart @@ -51,146 +51,147 @@ List currentPlatformCapabilities() { } List _androidCapabilities() => [ - const PlatformCapability( - feature: 'Voice capture', - description: 'Oboe AAudio backend; Bluetooth SCO supported.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Voice processing', - description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Foreground service', - description: 'Persistent notification during active connection.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Global PTT hotkey', - description: 'Not available on Android.', - tier: CapabilityTier.unavailable, - ), - const PlatformCapability( - feature: 'Audio device selection', - description: 'System-managed; wired/BT/SCO automatic routing.', - tier: CapabilityTier.degraded, - ), - const PlatformCapability( - feature: 'Push-to-talk button', - description: 'Media button / volume-key binding supported.', - tier: CapabilityTier.supported, - ), - ]; + const PlatformCapability( + feature: 'Voice capture', + description: 'Oboe AAudio backend; Bluetooth SCO supported.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Foreground service', + description: 'Persistent notification during active connection.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Not available on Android.', + tier: CapabilityTier.unavailable, + ), + const PlatformCapability( + feature: 'Audio device selection', + description: 'System-managed; wired/BT/SCO automatic routing.', + tier: CapabilityTier.degraded, + ), + const PlatformCapability( + feature: 'Push-to-talk button', + description: 'Media button / volume-key binding supported.', + tier: CapabilityTier.supported, + ), +]; List _iosCapabilities() => [ - const PlatformCapability( - feature: 'Voice capture', - description: 'VoiceProcessingIO AudioUnit (default); Sonora raw mode optional.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Voice processing', - description: 'Platform AEC/NS/AGC via VPIO; optional Rust AEC3/NS/AGC2 via Sonora.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Audio route switching', - description: 'Handles speaker/earpiece/Bluetooth route changes and interruptions.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Global PTT hotkey', - description: 'Not available on iOS.', - tier: CapabilityTier.unavailable, - ), - const PlatformCapability( - feature: 'Background audio', - description: 'Supported via AVAudioSession background mode.', - tier: CapabilityTier.supported, - ), - ]; + const PlatformCapability( + feature: 'Voice capture', + description: 'VoiceProcessingIO AudioUnit (default).', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'Platform AEC/NS/AGC via VPIO.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Audio route switching', + description: + 'Handles speaker/earpiece/Bluetooth route changes and interruptions.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Not available on iOS.', + tier: CapabilityTier.unavailable, + ), + const PlatformCapability( + feature: 'Background audio', + description: 'Supported via AVAudioSession background mode.', + tier: CapabilityTier.supported, + ), +]; List _macosCapabilities() => [ - const PlatformCapability( - feature: 'Voice capture', - description: 'cpal device enumeration; VoiceProcessingIO optional.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Voice processing', - description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Global PTT hotkey', - description: 'Supported via platform-global key-binding API.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Secure storage', - description: 'macOS Keychain.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Audio device selection', - description: 'System audio output route picker.', - tier: CapabilityTier.supported, - ), - ]; + const PlatformCapability( + feature: 'Voice capture', + description: 'cpal device enumeration; VoiceProcessingIO optional.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Supported via platform-global key-binding API.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Secure storage', + description: 'macOS Keychain.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Audio device selection', + description: 'System audio output route picker.', + tier: CapabilityTier.supported, + ), +]; List _windowsCapabilities() => [ - const PlatformCapability( - feature: 'Voice capture', - description: 'WASAPI via cpal.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Voice processing', - description: 'WebRTC AEC3/NS/AGC2.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Global PTT hotkey', - description: 'Supported via platform-global hotkey binding.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Secure storage', - description: 'Windows Credential Manager / DPAPI.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Installer', - description: 'MSIX packaging not yet available in beta.', - tier: CapabilityTier.unavailable, - ), - ]; + const PlatformCapability( + feature: 'Voice capture', + description: 'WASAPI via cpal.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2 with Silero VAD.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Supported via platform-global hotkey binding.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Secure storage', + description: 'Windows Credential Manager / DPAPI.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Installer', + description: 'MSIX packaging not yet available in beta.', + tier: CapabilityTier.unavailable, + ), +]; List _linuxCapabilities() => [ - const PlatformCapability( - feature: 'Voice capture', - description: 'PulseAudio/ALSA via cpal.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Voice processing', - description: 'WebRTC AEC3/NS/AGC2; no platform VPIO.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Global PTT hotkey', - description: 'Supported via X11/Wayland global key-binding. ', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Secure storage', - description: 'Secret Service / libsecret.', - tier: CapabilityTier.supported, - ), - const PlatformCapability( - feature: 'Desktop environment', - description: 'DE-specific behaviour: screen locker may suspend audio.', - tier: CapabilityTier.degraded, - ), - ]; + const PlatformCapability( + feature: 'Voice capture', + description: 'PulseAudio/ALSA via cpal.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Voice processing', + description: 'WebRTC AEC3/NS/AGC2 with Silero VAD; no platform VPIO.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Global PTT hotkey', + description: 'Supported via X11/Wayland global key-binding. ', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Secure storage', + description: 'Secret Service / libsecret.', + tier: CapabilityTier.supported, + ), + const PlatformCapability( + feature: 'Desktop environment', + description: 'DE-specific behaviour: screen locker may suspend audio.', + tier: CapabilityTier.degraded, + ), +]; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 7d5f1c3..81ecdcf 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -76,12 +76,18 @@ String _kAppVersion = appSemverBaseline; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); - _kAppVersion = await resolveAppVersion(); unawaited(wireStorage()); unawaited(wireConnectivity()); wireAudioLifecycle(); await configureBundledVadModels(); runApp(const ChanoraApp()); + unawaited(_finishDeferredStartup()); +} + +Future _finishDeferredStartup() async { + try { + _kAppVersion = await resolveAppVersion(); + } catch (_) {} } class ChanoraApp extends StatelessWidget { @@ -149,7 +155,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { rust.BridgeAudioStats? _audioStats; Timer? _statsTimer; int _statsTick = 0; - bool _voiceStatusRefreshInFlight = false; + bool _snapshotRefreshInFlight = false; + bool _snapshotRefreshQueued = false; + bool _snapshotRefreshQueuedRecordActivity = false; + bool _snapshotRefreshQueuedReportErrors = false; StreamSubscription? _eventsSub; // v1 voice subsystem state (SDD-094/095/096/097). Driven by @@ -164,6 +173,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { BigInt? _currentVoiceChannelId; BigInt? _pendingVoiceChannelId; bool _canJoinVoiceChannel = true; + bool _voiceStateInitialized = false; String? _lostReason; int? _reconnectAttempt; @@ -193,13 +203,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { List _bookmarks = const []; final List _chatMessages = []; + final ValueNotifier _chatFeedRevision = ValueNotifier(0); int _chatUnread = 0; bool _chatOpen = false; final ValueNotifier> _pokeSnackBarPokes = ValueNotifier( const [], ); bool _pokeSnackBarVisible = false; - IconData? _audioRoute; // SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service. // Constructed at startup so cold-launch state is captured before the @@ -421,6 +431,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Future _reloadBookmarks() async { try { + await wireStorage(); final list = await rust.listBookmarks(); if (!mounted) return; setState(() => _bookmarks = list); @@ -467,7 +478,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _phase = ConnectionPhase.connected; } }); - unawaited(_onRefresh()); + unawaited(_refreshSnapshot(recordActivity: true, reportErrors: true)); case rust.BridgeEvent_PttCapability( :final level, :final backendId, @@ -491,6 +502,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { :final canJoin, ): setState(() { + _voiceStateInitialized = true; _inChannel = inChannel; _transmitMode = transmitMode; _hardMute = mute; @@ -565,7 +577,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final isPoke = target is rust.BridgeMessageTarget_Poke; final receivedAt = DateTime.now(); setState(() { - _chatMessages.add( + _appendChatEntryUnlocked( ChatEntry( senderId: senderId, senderName: senderName, @@ -575,12 +587,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { timestamp: receivedAt, ), ); - if (_chatMessages.length > 200) { - _chatMessages.removeRange(0, _chatMessages.length - 200); - } - if (!_chatOpen && !isPoke) { - _chatUnread++; - } }); if (isPoke) { _showPokeSnackBar( @@ -601,8 +607,21 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); }()); } - case rust.BridgeEvent_AudioRouteChanged(:final route): - setState(() => _audioRoute = _routeIcon(route)); + case rust.BridgeEvent_ServerActivity(:final message): + setState(() { + _appendChatEntryUnlocked( + ChatEntry( + senderId: BigInt.zero, + senderName: 'Server', + message: message, + target: const rust.BridgeMessageTarget.server(), + timestamp: DateTime.now(), + countsTowardUnread: false, + ), + ); + }); + case rust.BridgeEvent_AudioRouteChanged(): + break; } } @@ -617,13 +636,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { void _ensureStatsTimer() { if (_statsTimer != null) return; - _statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async { + _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; setState(() => _audioStats = s); _statsTick += 1; - if (_statsTick % 5 == 0) { + if (_statsTick % 4 == 0) { unawaited(_refreshSnapshotForVoiceStatus()); } } catch (_) {} @@ -631,18 +650,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } Future _refreshSnapshotForVoiceStatus() async { - if (_voiceStatusRefreshInFlight) return; - _voiceStatusRefreshInFlight = true; - try { - final snap = await rust.snapshot(); - if (!mounted) return; - setState(() => _applySnapshot(snap)); - } catch (_) { - // Best-effort visual refresh only. Connection/loss paths still - // surface via the normal bridge events and explicit refreshes. - } finally { - _voiceStatusRefreshInFlight = false; - } + await _refreshSnapshot(recordActivity: true, reportErrors: false); } @override @@ -669,10 +677,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); _eventsSub?.cancel(); _statsTimer?.cancel(); - _voiceStatusRefreshInFlight = false; + _snapshotRefreshInFlight = false; + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; _hostCtl.dispose(); _nickCtl.dispose(); _passwordCtl.dispose(); + _chatFeedRevision.dispose(); _pokeSnackBarPokes.dispose(); _androidPermissions.recordAudioState.removeListener( _onRecordAudioPermissionChanged, @@ -880,9 +892,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } } - await configureBundledVadModels(); await rust.voiceJoin(channelId: ch.id, password: password ?? ''); if (!mounted) return; + setState(() { + _currentVoiceChannelId = ch.id; + _pendingVoiceChannelId = null; + _inChannel = true; + _canJoinVoiceChannel = true; + _voiceStateInitialized = true; + }); unawaited(_onRefresh()); } catch (e) { if (!mounted) return; @@ -959,10 +977,25 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { try { return await rust.getAudioProcessingConfig(); } catch (_) { - return defaultAudioProcessingConfig; + return defaultAudioProcessingConfig(); } } + void _appendChatEntryUnlocked(ChatEntry entry) { + _chatMessages.add(entry); + if (_chatMessages.length > 200) { + _chatMessages.removeRange(0, _chatMessages.length - 200); + } + _notifyChatFeedChanged(); + if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) { + _chatUnread++; + } + } + + void _notifyChatFeedChanged() { + _chatFeedRevision.value++; + } + /// Narrow-mode voice controls modal sheet (Plan E status chip /// trigger). On mobile this is the **single** voice-controls /// surface: route picker + inline mode radio + inline release-tail @@ -1061,13 +1094,65 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } Future _onRefresh() async { + await _refreshSnapshot(recordActivity: true, reportErrors: true); + } + + Future _refreshSnapshot({ + required bool recordActivity, + required bool reportErrors, + }) async { + if (_snapshotRefreshInFlight) { + _snapshotRefreshQueued = true; + _snapshotRefreshQueuedRecordActivity = + _snapshotRefreshQueuedRecordActivity || recordActivity; + _snapshotRefreshQueuedReportErrors = + _snapshotRefreshQueuedReportErrors || reportErrors; + return; + } + + _snapshotRefreshInFlight = true; + var nextRecordActivity = recordActivity; + var nextReportErrors = reportErrors; + try { - final snap = await rust.snapshot(); - if (!mounted) return; - setState(() => _applySnapshot(snap)); - } catch (e) { - if (!mounted) return; - _showUiError('refresh snapshot', e); + while (mounted) { + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; + + final previousSnapshot = _snapshot; + try { + final snap = await rust.snapshot(); + if (!mounted) return; + final activityEntries = nextRecordActivity && previousSnapshot != null + ? buildServerActivityEntries( + previous: previousSnapshot, + current: snap, + ) + : const []; + setState(() { + _applySnapshot(snap); + for (final entry in activityEntries) { + _appendChatEntryUnlocked(entry); + } + }); + } catch (e) { + if (nextReportErrors && mounted) { + _showUiError('refresh snapshot', e); + } + } + + if (!_snapshotRefreshQueued) { + break; + } + nextRecordActivity = _snapshotRefreshQueuedRecordActivity; + nextReportErrors = _snapshotRefreshQueuedReportErrors; + } + } finally { + _snapshotRefreshInFlight = false; + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; } } @@ -1109,7 +1194,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _statsTimer?.cancel(); _statsTimer = null; _statsTick = 0; - _voiceStatusRefreshInFlight = false; + _snapshotRefreshInFlight = false; + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; try { await rust.disconnect(); } catch (_) {} @@ -1131,34 +1219,21 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _currentVoiceChannelId = null; _pendingVoiceChannelId = null; _canJoinVoiceChannel = true; + _voiceStateInitialized = false; _lostReason = null; _reconnectAttempt = null; _reconnectDelay = null; _chatMessages.clear(); _chatUnread = 0; _chatOpen = false; - } - - IconData _routeIcon(rust.BridgeAudioRoute r) { - switch (r) { - case rust.BridgeAudioRoute.earpiece: - return Icons.phone_android; - case rust.BridgeAudioRoute.speaker: - return Icons.volume_up; - case rust.BridgeAudioRoute.wiredHeadset: - return Icons.headset; - case rust.BridgeAudioRoute.bluetoothHfp: - case rust.BridgeAudioRoute.bluetoothA2Dp: - return Icons.bluetooth; - case rust.BridgeAudioRoute.unknown: - return Icons.help_outline; - } + _notifyChatFeedChanged(); } Future _onOpenChat({ rust.BridgeMessageTarget? target, String clientName = '', }) async { + final initialSnapshot = _snapshot!; setState(() { _chatUnread = 0; _chatOpen = true; @@ -1167,7 +1242,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { MaterialPageRoute( builder: (_) => ChatPage( messages: _chatMessages, - snapshot: _snapshot!, + snapshot: initialSnapshot, + messagesSource: () => _chatMessages, + snapshotSource: () => _snapshot ?? initialSnapshot, + refreshListenable: _chatFeedRevision, initialTarget: target ?? resolveInitialChatTarget( @@ -1312,12 +1390,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _snapshot = snap; final own = ownClientSnapshotState(snap); if (own == null) return; - _currentVoiceChannelId = own.channelId; _inputMuted = own.inputMuted; _outputMuted = own.outputMuted; - _pendingVoiceChannelId = null; - _inChannel = true; - _canJoinVoiceChannel = true; + if (!_voiceStateInitialized || _currentVoiceChannelId == null) { + _currentVoiceChannelId = own.channelId; + _pendingVoiceChannelId = null; + _inChannel = true; + _canJoinVoiceChannel = true; + _voiceStateInitialized = true; + } else if (_pendingVoiceChannelId == null) { + _currentVoiceChannelId = own.channelId; + } + _notifyChatFeedChanged(); if (!own.talkPowerOk && !_hardMuteByTalkPower) { _hardMuteByTalkPower = true; @@ -1525,6 +1609,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { if (name == null || name.trim().isEmpty) return; if (!mounted) return; try { + await wireStorage(); await rust.addBookmark( b: rust.BridgeBookmark( id: 0, @@ -1543,6 +1628,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Future _onDeleteBookmark(rust.BridgeBookmark b) async { try { + await wireStorage(); await rust.deleteBookmark(id: b.id); await _reloadBookmarks(); } catch (e) { @@ -1580,6 +1666,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } try { + await wireStorage(); await rust.addBookmark( b: rust.BridgeBookmark( id: 0, @@ -1652,7 +1739,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final appBarTitle = _serverReachable ? Text( _snapshot?.serverName ?? l10n.appTitle, - style: theme.textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ) : headerTitle; @@ -1701,10 +1792,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ), ], ), - if (_serverReachable && _audioRoute != null) ...[ - const SizedBox(width: 8), - Icon(_audioRoute, size: 16, color: theme.colorScheme.tertiary), - ], if (_lostReason != null || _reconnectAttempt != null) ...[ const SizedBox(height: 8), Container( @@ -1925,6 +2012,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { onPressed: _onConfirmDisconnect, ) : null, + leadingWidth: _phase.canDisconnect ? 44 : null, + titleSpacing: _phase.canDisconnect ? 4 : null, title: appBarTitle, actions: headerActions, ), diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 5de105f..8160f52 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -270,23 +270,19 @@ Future audioProcessingStats() => Future listAudioDevices() => RustLib.instance.api.crateApiListAudioDevices(); -/// Set the preferred input device by name. Takes effect on next +/// Set the preferred input device by id. Takes effect on next /// `start_audio`. -Future setInputDevice({String? name}) => - RustLib.instance.api.crateApiSetInputDevice(name: name); +Future setInputDevice({String? id}) => + RustLib.instance.api.crateApiSetInputDevice(id: id); -/// Set the preferred output device by name. -Future setOutputDevice({String? name}) => - RustLib.instance.api.crateApiSetOutputDevice(name: name); +/// Set the preferred output device by id. +Future setOutputDevice({String? id}) => + RustLib.instance.api.crateApiSetOutputDevice(id: id); /// Configure the VAD model path. Future setVadModelPath({required String path}) => RustLib.instance.api.crateApiSetVadModelPath(path: path); -/// Configure the TEN VAD ONNX model path. -Future setTenVadModelPath({required String path}) => - RustLib.instance.api.crateApiSetTenVadModelPath(path: path); - /// Enable or disable audio debug WAV dumping. Future enableAudioDebugWavDump({required bool enabled}) => RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled); @@ -321,24 +317,47 @@ enum BridgeAudioBackend { /// Audio device info from the platform. class BridgeAudioDevice { + /// Stable platform-reported device identifier. + final String id; + /// Human-readable device name. final String name; + /// Additional device details useful for disambiguation. + final String details; + /// True if the OS reports this as the default device. final bool isDefault; - const BridgeAudioDevice({required this.name, required this.isDefault}); + /// True if Chanora currently has this device pinned. + final bool isSelected; + + const BridgeAudioDevice({ + required this.id, + required this.name, + required this.details, + required this.isDefault, + required this.isSelected, + }); @override - int get hashCode => name.hashCode ^ isDefault.hashCode; + int get hashCode => + id.hashCode ^ + name.hashCode ^ + details.hashCode ^ + isDefault.hashCode ^ + isSelected.hashCode; @override bool operator ==(Object other) => identical(this, other) || other is BridgeAudioDevice && runtimeType == other.runtimeType && + id == other.id && name == other.name && - isDefault == other.isDefault; + details == other.details && + isDefault == other.isDefault && + isSelected == other.isSelected; } /// List of available audio devices. @@ -512,6 +531,21 @@ class BridgeAudioProcessingStats { /// Clipped samples. final BigInt clippedSamples; + /// Number of effectively silent processed capture frames. + final BigInt zeroFrames; + + /// Number of processed capture frames. + final BigInt captureFrames; + + /// Input callbacks carrying 10 ms of audio. + final BigInt callbacks10Ms; + + /// Input callbacks carrying 20 ms of audio. + final BigInt callbacks20Ms; + + /// Input callbacks carrying other sizes. + final BigInt callbacksOther; + /// Sonora enabled. final bool sonoraEnabled; @@ -536,6 +570,11 @@ class BridgeAudioProcessingStats { required this.outputUnderruns, required this.callbackXruns, required this.clippedSamples, + required this.zeroFrames, + required this.captureFrames, + required this.callbacks10Ms, + required this.callbacks20Ms, + required this.callbacksOther, required this.sonoraEnabled, required this.platformVoiceProcessingEnabled, }); @@ -559,6 +598,11 @@ class BridgeAudioProcessingStats { outputUnderruns.hashCode ^ callbackXruns.hashCode ^ clippedSamples.hashCode ^ + zeroFrames.hashCode ^ + captureFrames.hashCode ^ + callbacks10Ms.hashCode ^ + callbacks20Ms.hashCode ^ + callbacksOther.hashCode ^ sonoraEnabled.hashCode ^ platformVoiceProcessingEnabled.hashCode; @@ -584,6 +628,11 @@ class BridgeAudioProcessingStats { outputUnderruns == other.outputUnderruns && callbackXruns == other.callbackXruns && clippedSamples == other.clippedSamples && + zeroFrames == other.zeroFrames && + captureFrames == other.captureFrames && + callbacks10Ms == other.callbacks10Ms && + callbacks20Ms == other.callbacks20Ms && + callbacksOther == other.callbacksOther && sonoraEnabled == other.sonoraEnabled && platformVoiceProcessingEnabled == other.platformVoiceProcessingEnabled; @@ -976,6 +1025,12 @@ sealed class BridgeEvent with _$BridgeEvent { required BridgeMessageTarget target, }) = BridgeEvent_ChatMessage; + /// Human-readable server activity surfaced from protocol bookkeeping events. + const factory BridgeEvent.serverActivity({ + /// TeamSpeak-style activity line. + required String message, + }) = BridgeEvent_ServerActivity; + /// Audio route changed (speaker/earpiece/BT/wired). const factory BridgeEvent.audioRouteChanged({ /// The new audio route. @@ -1169,9 +1224,6 @@ enum BridgeVadBackend { /// Silero ONNX VAD. sileroOnnx, - /// TEN VAD. - tenVad, - /// WebRTC fallback VAD. webrtcVad, diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index bf123b7..0735ae0 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,TResult Function( BridgeEvent_ChatMessage value)? chatMessage,TResult Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,TResult Function( BridgeEvent_ChatMessage value)? chatMessage,TResult Function( BridgeEvent_ServerActivity value)? serverActivity,TResult Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,required TResult orElse(),}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -70,7 +70,8 @@ return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != nul return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null: -return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return chatMessage(_that);case BridgeEvent_ServerActivity() when serverActivity != null: +return serverActivity(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: return audioRouteChanged(_that);case _: return orElse(); @@ -89,7 +90,7 @@ return audioRouteChanged(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,required TResult Function( BridgeEvent_ChatMessage value) chatMessage,required TResult Function( BridgeEvent_AudioRouteChanged value) audioRouteChanged,}){ +@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,required TResult Function( BridgeEvent_ChatMessage value) chatMessage,required TResult Function( BridgeEvent_ServerActivity value) serverActivity,required TResult Function( BridgeEvent_AudioRouteChanged value) audioRouteChanged,}){ final _that = this; switch (_that) { case BridgeEvent_Connected(): @@ -104,7 +105,8 @@ return pttCapability(_that);case BridgeEvent_VoiceState(): return voiceState(_that);case BridgeEvent_InterruptionState(): return interruptionState(_that);case BridgeEvent_PermissionState(): return permissionState(_that);case BridgeEvent_ChatMessage(): -return chatMessage(_that);case BridgeEvent_AudioRouteChanged(): +return chatMessage(_that);case BridgeEvent_ServerActivity(): +return serverActivity(_that);case BridgeEvent_AudioRouteChanged(): return audioRouteChanged(_that);} } /// A variant of `map` that fallback to returning `null`. @@ -119,7 +121,7 @@ return audioRouteChanged(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,TResult? Function( BridgeEvent_ChatMessage value)? chatMessage,TResult? Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,TResult? Function( BridgeEvent_ChatMessage value)? chatMessage,TResult? Function( BridgeEvent_ServerActivity value)? serverActivity,TResult? Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -134,7 +136,8 @@ return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != nul return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null: -return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return chatMessage(_that);case BridgeEvent_ServerActivity() when serverActivity != null: +return serverActivity(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: return audioRouteChanged(_that);case _: return null; @@ -152,7 +155,7 @@ return audioRouteChanged(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( BridgeAudioRoute route)? audioRouteChanged,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,required TResult orElse(),}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -166,7 +169,8 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null: -return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null: +return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: return audioRouteChanged(_that.route);case _: return orElse(); @@ -185,7 +189,7 @@ return audioRouteChanged(_that.route);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( BridgeAudioRoute route) audioRouteChanged,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,}) {final _that = this; switch (_that) { case BridgeEvent_Connected(): return connected(_that.serverName);case BridgeEvent_Lost(): @@ -199,7 +203,8 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState(): return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState(): return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage(): -return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged(): +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity(): +return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged(): return audioRouteChanged(_that.route);} } /// A variant of `when` that fallback to returning `null` @@ -214,7 +219,7 @@ return audioRouteChanged(_that.route);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -228,7 +233,8 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null: -return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null: +return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: return audioRouteChanged(_that.route);case _: return null; @@ -1041,6 +1047,73 @@ $BridgeMessageTargetCopyWith<$Res> get target { /// @nodoc +class BridgeEvent_ServerActivity extends BridgeEvent { + const BridgeEvent_ServerActivity({required this.message}): super._(); + + +/// TeamSpeak-style activity line. + final String message; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeEvent_ServerActivityCopyWith get copyWith => _$BridgeEvent_ServerActivityCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ServerActivity&&(identical(other.message, message) || other.message == message)); +} + + +@override +int get hashCode => Object.hash(runtimeType,message); + +@override +String toString() { + return 'BridgeEvent.serverActivity(message: $message)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeEvent_ServerActivityCopyWith<$Res> implements $BridgeEventCopyWith<$Res> { + factory $BridgeEvent_ServerActivityCopyWith(BridgeEvent_ServerActivity value, $Res Function(BridgeEvent_ServerActivity) _then) = _$BridgeEvent_ServerActivityCopyWithImpl; +@useResult +$Res call({ + String message +}); + + + + +} +/// @nodoc +class _$BridgeEvent_ServerActivityCopyWithImpl<$Res> + implements $BridgeEvent_ServerActivityCopyWith<$Res> { + _$BridgeEvent_ServerActivityCopyWithImpl(this._self, this._then); + + final BridgeEvent_ServerActivity _self; + final $Res Function(BridgeEvent_ServerActivity) _then; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? message = null,}) { + return _then(BridgeEvent_ServerActivity( +message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + class BridgeEvent_AudioRouteChanged extends BridgeEvent { const BridgeEvent_AudioRouteChanged({required this.route}): super._(); diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 130f739..894c8fd 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 1433826599; + int get rustContentHash => -560177922; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -156,7 +156,7 @@ abstract class RustLibApi extends BaseApi { Future crateApiSetHardMute({required bool muted}); - Future crateApiSetInputDevice({String? name}); + Future crateApiSetInputDevice({String? id}); Future crateApiSetInputMuted({required bool muted}); @@ -166,7 +166,7 @@ abstract class RustLibApi extends BaseApi { void crateApiSetNetworkState({required BridgeNetworkState state}); - Future crateApiSetOutputDevice({String? name}); + Future crateApiSetOutputDevice({String? id}); Future crateApiSetOutputGain({required double gain}); @@ -181,8 +181,6 @@ abstract class RustLibApi extends BaseApi { Future crateApiSetReleaseTailMs({required int ms}); - Future crateApiSetTenVadModelPath({required String path}); - Future crateApiSetTransmitMode({required BridgeTransmitMode mode}); Future crateApiSetVadModelPath({required String path}); @@ -772,7 +770,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { }, codec: SseCodec( decodeSuccessData: sse_decode_bridge_audio_device_list, - decodeErrorData: null, + decodeErrorData: sse_decode_bridge_error, ), constMeta: kCrateApiListAudioDevicesConstMeta, argValues: [], @@ -1079,12 +1077,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: "set_hard_mute", argNames: ["muted"]); @override - Future crateApiSetInputDevice({String? name}) { + Future crateApiSetInputDevice({String? id}) { return handler.executeNormal( NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_opt_String(name, serializer); + sse_encode_opt_String(id, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, @@ -1097,14 +1095,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_bridge_error, ), constMeta: kCrateApiSetInputDeviceConstMeta, - argValues: [name], + argValues: [id], apiImpl: this, ), ); } TaskConstMeta get kCrateApiSetInputDeviceConstMeta => - const TaskConstMeta(debugName: "set_input_device", argNames: ["name"]); + const TaskConstMeta(debugName: "set_input_device", argNames: ["id"]); @override Future crateApiSetInputMuted({required bool muted}) { @@ -1191,12 +1189,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: "set_network_state", argNames: ["state"]); @override - Future crateApiSetOutputDevice({String? name}) { + Future crateApiSetOutputDevice({String? id}) { return handler.executeNormal( NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_opt_String(name, serializer); + sse_encode_opt_String(id, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, @@ -1209,14 +1207,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_bridge_error, ), constMeta: kCrateApiSetOutputDeviceConstMeta, - argValues: [name], + argValues: [id], apiImpl: this, ), ); } TaskConstMeta get kCrateApiSetOutputDeviceConstMeta => - const TaskConstMeta(debugName: "set_output_device", argNames: ["name"]); + const TaskConstMeta(debugName: "set_output_device", argNames: ["id"]); @override Future crateApiSetOutputGain({required double gain}) { @@ -1364,36 +1362,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiSetReleaseTailMsConstMeta => const TaskConstMeta(debugName: "set_release_tail_ms", argNames: ["ms"]); - @override - Future crateApiSetTenVadModelPath({required String path}) { - return handler.executeNormal( - NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_String(path, serializer); - pdeCallFfi( - generalizedFrbRustBinding, - serializer, - funcId: 42, - port: port_, - ); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: sse_decode_bridge_error, - ), - constMeta: kCrateApiSetTenVadModelPathConstMeta, - argValues: [path], - apiImpl: this, - ), - ); - } - - TaskConstMeta get kCrateApiSetTenVadModelPathConstMeta => const TaskConstMeta( - debugName: "set_ten_vad_model_path", - argNames: ["path"], - ); - @override Future crateApiSetTransmitMode({required BridgeTransmitMode mode}) { return handler.executeNormal( @@ -1404,7 +1372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 42, port: port_, ); }, @@ -1432,7 +1400,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 43, port: port_, ); }, @@ -1459,7 +1427,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 44, port: port_, ); }, @@ -1487,7 +1455,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 45, port: port_, ); }, @@ -1519,7 +1487,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 46, port: port_, ); }, @@ -1548,7 +1516,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 47, port: port_, ); }, @@ -1643,11 +1611,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 2) - throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + if (arr.length != 5) + throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); return BridgeAudioDevice( - name: dco_decode_String(arr[0]), - isDefault: dco_decode_bool(arr[1]), + id: dco_decode_String(arr[0]), + name: dco_decode_String(arr[1]), + details: dco_decode_String(arr[2]), + isDefault: dco_decode_bool(arr[3]), + isSelected: dco_decode_bool(arr[4]), ); } @@ -1694,8 +1665,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 19) - throw Exception('unexpected arr length: expect 19 but see ${arr.length}'); + if (arr.length != 24) + throw Exception('unexpected arr length: expect 24 but see ${arr.length}'); return BridgeAudioProcessingStats( inputDbfs: dco_decode_f_32(arr[0]), renderDbfs: dco_decode_f_32(arr[1]), @@ -1716,8 +1687,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { outputUnderruns: dco_decode_u_64(arr[14]), callbackXruns: dco_decode_u_64(arr[15]), clippedSamples: dco_decode_u_64(arr[16]), - sonoraEnabled: dco_decode_bool(arr[17]), - platformVoiceProcessingEnabled: dco_decode_bool(arr[18]), + zeroFrames: dco_decode_u_64(arr[17]), + captureFrames: dco_decode_u_64(arr[18]), + callbacks10Ms: dco_decode_u_64(arr[19]), + callbacks20Ms: dco_decode_u_64(arr[20]), + callbacksOther: dco_decode_u_64(arr[21]), + sonoraEnabled: dco_decode_bool(arr[22]), + platformVoiceProcessingEnabled: dco_decode_bool(arr[23]), ); } @@ -1887,6 +1863,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { target: dco_decode_box_autoadd_bridge_message_target(raw[4]), ); case 12: + return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1])); + case 13: return BridgeEvent_AudioRouteChanged( route: dco_decode_bridge_audio_route(raw[1]), ); @@ -2194,9 +2172,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SseDeserializer deserializer, ) { // Codec=Sse (Serialization based), see doc to use other codecs + var var_id = sse_decode_String(deserializer); var var_name = sse_decode_String(deserializer); + var var_details = sse_decode_String(deserializer); var var_isDefault = sse_decode_bool(deserializer); - return BridgeAudioDevice(name: var_name, isDefault: var_isDefault); + var var_isSelected = sse_decode_bool(deserializer); + return BridgeAudioDevice( + id: var_id, + name: var_name, + details: var_details, + isDefault: var_isDefault, + isSelected: var_isSelected, + ); } @protected @@ -2270,6 +2257,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_outputUnderruns = sse_decode_u_64(deserializer); var var_callbackXruns = sse_decode_u_64(deserializer); var var_clippedSamples = sse_decode_u_64(deserializer); + var var_zeroFrames = sse_decode_u_64(deserializer); + var var_captureFrames = sse_decode_u_64(deserializer); + var var_callbacks10Ms = sse_decode_u_64(deserializer); + var var_callbacks20Ms = sse_decode_u_64(deserializer); + var var_callbacksOther = sse_decode_u_64(deserializer); var var_sonoraEnabled = sse_decode_bool(deserializer); var var_platformVoiceProcessingEnabled = sse_decode_bool(deserializer); return BridgeAudioProcessingStats( @@ -2290,6 +2282,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { outputUnderruns: var_outputUnderruns, callbackXruns: var_callbackXruns, clippedSamples: var_clippedSamples, + zeroFrames: var_zeroFrames, + captureFrames: var_captureFrames, + callbacks10Ms: var_callbacks10Ms, + callbacks20Ms: var_callbacks20Ms, + callbacksOther: var_callbacksOther, sonoraEnabled: var_sonoraEnabled, platformVoiceProcessingEnabled: var_platformVoiceProcessingEnabled, ); @@ -2519,6 +2516,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { target: var_target, ); case 12: + var var_message = sse_decode_String(deserializer); + return BridgeEvent_ServerActivity(message: var_message); + case 13: var var_route = sse_decode_bridge_audio_route(deserializer); return BridgeEvent_AudioRouteChanged(route: var_route); default: @@ -2917,8 +2917,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SseSerializer serializer, ) { // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.id, serializer); sse_encode_String(self.name, serializer); + sse_encode_String(self.details, serializer); sse_encode_bool(self.isDefault, serializer); + sse_encode_bool(self.isSelected, serializer); } @protected @@ -2978,6 +2981,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_64(self.outputUnderruns, serializer); sse_encode_u_64(self.callbackXruns, serializer); sse_encode_u_64(self.clippedSamples, serializer); + sse_encode_u_64(self.zeroFrames, serializer); + sse_encode_u_64(self.captureFrames, serializer); + sse_encode_u_64(self.callbacks10Ms, serializer); + sse_encode_u_64(self.callbacks20Ms, serializer); + sse_encode_u_64(self.callbacksOther, serializer); sse_encode_bool(self.sonoraEnabled, serializer); sse_encode_bool(self.platformVoiceProcessingEnabled, serializer); } @@ -3168,8 +3176,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(senderName, serializer); sse_encode_String(message, serializer); sse_encode_box_autoadd_bridge_message_target(target, serializer); - case BridgeEvent_AudioRouteChanged(route: final route): + case BridgeEvent_ServerActivity(message: final message): sse_encode_i_32(12, serializer); + sse_encode_String(message, serializer); + case BridgeEvent_AudioRouteChanged(route: final route): + sse_encode_i_32(13, serializer); sse_encode_bridge_audio_route(route, serializer); } } diff --git a/apps/chanora_flutter/lib/widgets/audio_debug_stats_panel.dart b/apps/chanora_flutter/lib/widgets/audio_debug_stats_panel.dart index a0ebc7b..1a9fd5a 100644 --- a/apps/chanora_flutter/lib/widgets/audio_debug_stats_panel.dart +++ b/apps/chanora_flutter/lib/widgets/audio_debug_stats_panel.dart @@ -108,7 +108,8 @@ class _AudioDebugStatsPanelState extends State { s.platformVoiceProcessingEnabled ? 'on' : 'off', Colors.white, ), - _row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white), + if (s.processingBackend == BridgeAudioBackend.sonora) + _row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white), const SizedBox(height: 4), _row( 'mic in', @@ -184,7 +185,6 @@ class _AudioDebugStatsPanelState extends State { String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) { BridgeVadBackend.webrtcVad => 'webrtc', BridgeVadBackend.sileroOnnx => 'silero', - BridgeVadBackend.tenVad => 'ten', BridgeVadBackend.energyDebug => 'energy', BridgeVadBackend.disabled => 'off', }; diff --git a/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart b/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart index 255d7aa..70497a5 100644 --- a/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart +++ b/apps/chanora_flutter/lib/widgets/audio_device_list_tile.dart @@ -5,8 +5,8 @@ import '../src/rust/api.dart' as rust; /// Loads available input/output audio devices. typedef AudioDeviceListLoader = Future Function(); -/// Persists a selected audio device name. -typedef AudioDeviceSetter = Future Function({String? name}); +/// Persists a selected audio device id. +typedef AudioDeviceSetter = Future Function({String? id}); /// Which desktop audio device group this tile manages. enum AudioDeviceKind { @@ -52,6 +52,7 @@ class AudioDeviceListTile extends StatefulWidget { class _AudioDeviceListTileState extends State { List _devices = []; + String? _selectedDeviceId; bool _loaded = false; @override @@ -68,28 +69,56 @@ class _AudioDeviceListTileState extends State { AudioDeviceKind.input => list.inputDevices, AudioDeviceKind.output => list.outputDevices, }; + _selectedDeviceId = _devices + .where((device) => device.isSelected) + .firstOrNull + ?.id; _loaded = true; }); } - Future _selectDevice(rust.BridgeAudioDevice device) async { + Future _selectDevice(String? deviceId) async { switch (widget.kind) { case AudioDeviceKind.input: - await widget.setInputDevice(name: device.name); + await widget.setInputDevice(id: deviceId); case AudioDeviceKind.output: - await widget.setOutputDevice(name: device.name); + await widget.setOutputDevice(id: deviceId); } if (!mounted) return; + setState(() { + _selectedDeviceId = deviceId; + }); + + final selectedName = _selectedDevice?.name ?? 'System default'; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${widget.label} set to ${device.name}'), + content: Text('${widget.label} set to $selectedName'), duration: const Duration(seconds: 2), ), ); } + rust.BridgeAudioDevice? get _selectedDevice { + if (_selectedDeviceId == null) { + return null; + } + for (final device in _devices) { + if (device.id == _selectedDeviceId) { + return device; + } + } + return null; + } + @override Widget build(BuildContext context) { + final selectedDevice = _selectedDevice; + final subtitleText = selectedDevice?.name ?? 'System default'; + final leadingIcon = switch (widget.kind) { + AudioDeviceKind.input => Icons.mic_none, + AudioDeviceKind.output => Icons.headphones, + }; + if (!_loaded) { return ListTile( title: Text(widget.label), @@ -110,17 +139,31 @@ class _AudioDeviceListTileState extends State { } return ExpansionTile( title: Text(widget.label), - subtitle: Text('${_devices.length} available'), - leading: const Icon(Icons.headphones, size: 18), + subtitle: Text(subtitleText), + leading: Icon(leadingIcon, size: 18), children: [ + ListTile( + dense: true, + title: const Text('System default', style: TextStyle(fontSize: 13)), + subtitle: const Text('Use the OS default device'), + trailing: _selectedDeviceId == null + ? const Icon(Icons.check, size: 16, color: Colors.green) + : null, + onTap: _selectedDeviceId == null ? null : () => _selectDevice(null), + ), for (final device in _devices) ListTile( dense: true, title: Text(device.name, style: const TextStyle(fontSize: 13)), - trailing: device.isDefault + subtitle: device.details.isEmpty ? null : Text(device.details), + trailing: device.id == _selectedDeviceId ? const Icon(Icons.check, size: 16, color: Colors.green) + : device.isDefault + ? const Icon(Icons.radio_button_checked, size: 16) : null, - onTap: device.isDefault ? null : () => _selectDevice(device), + onTap: device.id == _selectedDeviceId + ? null + : () => _selectDevice(device.id), ), ], ); diff --git a/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart index 53eb4b3..1ed4a77 100644 --- a/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart +++ b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart @@ -3,21 +3,32 @@ import 'dart:io' show Platform; import '../src/rust/api.dart' as rust; /// Fallback audio-processing config used before the bridge can report one. -const defaultAudioProcessingConfig = rust.BridgeAudioProcessingConfig( - route: rust.BridgeAudioRoute.unknown, - iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, - processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, - vadBackend: rust.BridgeVadBackend.sileroOnnx, - aec: rust.BridgeEffectOwner.platform, - ns: rust.BridgeEffectOwner.platform, - agc: rust.BridgeEffectOwner.platform, - hpfEnabled: true, - limiterEnabled: true, - vadHangoverMs: 500, - vadPreRollMs: 160, - vadMinTxMs: 200, - debugWavDumpEnabled: false, -); +rust.BridgeAudioProcessingConfig defaultAudioProcessingConfig() { + final desktopWebrtcApm = Platform.isWindows || Platform.isLinux; + return rust.BridgeAudioProcessingConfig( + route: rust.BridgeAudioRoute.unknown, + iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + processingBackend: desktopWebrtcApm + ? rust.BridgeAudioBackend.webrtcApm + : rust.BridgeAudioBackend.platformVoiceProcessing, + vadBackend: rust.BridgeVadBackend.sileroOnnx, + aec: desktopWebrtcApm + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.platform, + ns: desktopWebrtcApm + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.platform, + agc: desktopWebrtcApm + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.platform, + hpfEnabled: true, + limiterEnabled: true, + vadHangoverMs: 500, + vadPreRollMs: 160, + vadMinTxMs: 200, + debugWavDumpEnabled: false, + ); +} /// Mutable UI state for audio-processing controls. class AudioProcessingConfigState { @@ -31,7 +42,7 @@ class AudioProcessingConfigState { debugWavDump = config.debugWavDumpEnabled, preferHardware = _usesPlatformEffects(config), vadBackend = normalizedVadBackend(config.vadBackend), - iosMode = config.iosMode; + iosMode = normalizedIosProcessingMode(config.iosMode); /// Noise suppression toggle. bool nsEnabled; @@ -64,9 +75,23 @@ class AudioProcessingConfigState { rust.BridgeAudioProcessingConfig buildConfig({ required rust.BridgeAudioProcessingConfig base, bool? isAndroid, + bool? isIos, + bool? isMacOS, + bool? isWindows, + bool? isLinux, }) { final android = isAndroid ?? Platform.isAndroid; - final vad = normalizedVadBackend(vadBackend); + final ios = isIos ?? Platform.isIOS; + final macOS = isMacOS ?? Platform.isMacOS; + final windows = isWindows ?? Platform.isWindows; + final linux = isLinux ?? Platform.isLinux; + final appleVoiceProcessing = ios || macOS; + final desktopWebrtcApm = windows || linux; + final vad = normalizedVadBackend( + vadBackend, + isWindows: windows, + isLinux: linux, + ); if (android) { final owner = preferHardware @@ -74,7 +99,7 @@ class AudioProcessingConfigState { : rust.BridgeEffectOwner.webrtcApm; return rust.BridgeAudioProcessingConfig( route: base.route, - iosMode: iosMode, + iosMode: normalizedIosProcessingMode(iosMode), processingBackend: preferHardware ? rust.BridgeAudioBackend.platformVoiceProcessing : rust.BridgeAudioBackend.webrtcApm, @@ -91,38 +116,55 @@ class AudioProcessingConfigState { ); } - final isSonora = - iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental; - final aecOwner = isSonora - ? (aecEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : rust.BridgeEffectOwner.platform; - final nsOwner = isSonora - ? (nsEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : (nsEnabled - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.off); - final agcOwner = isSonora - ? (agcEnabled - ? rust.BridgeEffectOwner.webrtcApm - : rust.BridgeEffectOwner.off) - : (agcEnabled - ? rust.BridgeEffectOwner.platform - : rust.BridgeEffectOwner.off); + if (appleVoiceProcessing) { + return rust.BridgeAudioProcessingConfig( + route: base.route, + iosMode: normalizedIosProcessingMode(iosMode), + processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, + vadBackend: vad, + aec: rust.BridgeEffectOwner.platform, + ns: rust.BridgeEffectOwner.platform, + agc: rust.BridgeEffectOwner.platform, + hpfEnabled: hpfEnabled, + limiterEnabled: limiterEnabled, + vadHangoverMs: base.vadHangoverMs, + vadPreRollMs: base.vadPreRollMs, + vadMinTxMs: base.vadMinTxMs, + debugWavDumpEnabled: debugWavDump, + ); + } + + if (desktopWebrtcApm) { + final owner = rust.BridgeEffectOwner.webrtcApm; + return rust.BridgeAudioProcessingConfig( + route: base.route, + iosMode: normalizedIosProcessingMode(iosMode), + processingBackend: rust.BridgeAudioBackend.webrtcApm, + vadBackend: vad, + aec: aecEnabled ? owner : rust.BridgeEffectOwner.off, + ns: nsEnabled ? owner : rust.BridgeEffectOwner.off, + agc: agcEnabled ? owner : rust.BridgeEffectOwner.off, + hpfEnabled: hpfEnabled, + limiterEnabled: limiterEnabled, + vadHangoverMs: base.vadHangoverMs, + vadPreRollMs: base.vadPreRollMs, + vadMinTxMs: base.vadMinTxMs, + debugWavDumpEnabled: debugWavDump, + ); + } return rust.BridgeAudioProcessingConfig( route: base.route, - iosMode: iosMode, - processingBackend: isSonora - ? rust.BridgeAudioBackend.webrtcApm - : rust.BridgeAudioBackend.platformVoiceProcessing, + iosMode: normalizedIosProcessingMode(iosMode), + processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, vadBackend: vad, - aec: aecOwner, - ns: nsOwner, - agc: agcOwner, + aec: rust.BridgeEffectOwner.platform, + ns: nsEnabled + ? rust.BridgeEffectOwner.platform + : rust.BridgeEffectOwner.off, + agc: agcEnabled + ? rust.BridgeEffectOwner.platform + : rust.BridgeEffectOwner.off, hpfEnabled: hpfEnabled, limiterEnabled: limiterEnabled, vadHangoverMs: base.vadHangoverMs, @@ -133,13 +175,55 @@ class AudioProcessingConfigState { } } +bool androidUsesHardwareProcessing(AudioProcessingConfigState state) { + return state.preferHardware; +} + +bool androidShowsNsControl(AudioProcessingConfigState state) { + return true; +} + +bool androidShowsAecControl(AudioProcessingConfigState state) { + return true; +} + +bool androidShowsAgcControl(AudioProcessingConfigState state) { + return true; +} + +bool androidShowsHpfControl(AudioProcessingConfigState state) { + return true; +} + +bool androidShowsLimiterControl(AudioProcessingConfigState state) { + return false; +} + /// Never leave the UI on the hidden disabled backend. -rust.BridgeVadBackend normalizedVadBackend(rust.BridgeVadBackend backend) { +/// +/// Windows and Linux use Silero as the primary VAD; WebRTC is still +/// available internally as a runtime fallback. +rust.BridgeVadBackend normalizedVadBackend( + rust.BridgeVadBackend backend, { + bool? isWindows, + bool? isLinux, +}) { + final desktop = + (isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux); + if (desktop) return rust.BridgeVadBackend.sileroOnnx; return backend == rust.BridgeVadBackend.disabled - ? rust.BridgeVadBackend.webrtcVad + ? rust.BridgeVadBackend.sileroOnnx : backend; } +rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode( + rust.BridgeIosVoiceProcessingMode mode, +) { + return mode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing + ? mode + : rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing; +} + bool _usesPlatformEffects(rust.BridgeAudioProcessingConfig config) { return config.aec == rust.BridgeEffectOwner.platform || config.ns == rust.BridgeEffectOwner.platform || diff --git a/apps/chanora_flutter/lib/widgets/chat_views.dart b/apps/chanora_flutter/lib/widgets/chat_views.dart index 27f477c..953a205 100644 --- a/apps/chanora_flutter/lib/widgets/chat_views.dart +++ b/apps/chanora_flutter/lib/widgets/chat_views.dart @@ -10,6 +10,9 @@ import '../services/ts3_server_link.dart'; import '../src/rust/api.dart' as rust; import 'bbcode_text.dart'; +const double _chatSidebarTileExtent = 92; +const Color _chatSidebarSelectedTileColor = Color(0xFF415366); + /// One chat/activity message shown in the chat hub. class ChatEntry { /// Construct a chat entry. @@ -20,6 +23,7 @@ class ChatEntry { required this.target, this.isSelf = false, this.timestamp, + this.countsTowardUnread = true, }); /// Sender client id. @@ -40,11 +44,18 @@ class ChatEntry { /// Local time when this message was received or sent. final DateTime? timestamp; + /// Whether this entry should increment the message unread badge. + final bool countsTowardUnread; + /// True for direct-message targets. bool get isPrivate => target is rust.BridgeMessageTarget_Client; /// True for poke targets. bool get isPoke => target is rust.BridgeMessageTarget_Poke; + + /// True for synthetic or protocol-driven server activity rows. + bool get isServerActivity => + target is rust.BridgeMessageTarget_Server && !countsTowardUnread; } String chatTimeLabel(DateTime? timestamp) { @@ -69,6 +80,162 @@ String pokeHistoryLine(AppL10n l10n, ChatEntry entry) { return l10n.pokeHistoryIncomingWithMessage(time, peerName, message); } +enum _ActivityTone { + positive, + transition, + warning, + plain, + username, + channel, + adminActor, + automationActor, + group, +} + +sealed class _ActivitySegment { + const _ActivitySegment(this.text, this.tone, {this.bold = false}); + + final String text; + final _ActivityTone tone; + final bool bold; +} + +class _ActivityTextSegment extends _ActivitySegment { + const _ActivityTextSegment(super.text, super.tone, {super.bold = false}); +} + +List<_ActivitySegment> _activitySegments(String message) { + final quotedMatches = RegExp(r'"[^"]+"').allMatches(message).toList(); + if (quotedMatches.isEmpty) { + return [_ActivityTextSegment(message, _activityActionTone(message))]; + } + + final segments = <_ActivitySegment>[]; + var cursor = 0; + for (var i = 0; i < quotedMatches.length; i++) { + final match = quotedMatches[i]; + if (match.start > cursor) { + final plain = message.substring(cursor, match.start); + if (plain.isNotEmpty) { + segments.add( + _ActivityTextSegment(plain, _activityActionTone(plain.trim())), + ); + } + } + final quoted = match.group(0)!; + segments.add(_activityEntitySegment(quoted, message, i)); + cursor = match.end; + } + if (cursor < message.length) { + final tail = message.substring(cursor); + if (tail.isNotEmpty) { + segments.add( + _ActivityTextSegment(tail, _activityActionTone(tail.trim())), + ); + } + } + return segments; +} + +_ActivitySegment _activityEntitySegment( + String quoted, + String fullMessage, + int quotedIndex, +) { + final lower = fullMessage.toLowerCase(); + final after = fullMessage + .substring(fullMessage.indexOf(quoted) + quoted.length) + .toLowerCase(); + final value = quoted.substring(1, quoted.length - 1); + + if (lower.contains('channel group ')) { + if (quotedIndex == 0) { + return _ActivityTextSegment(quoted, _ActivityTone.group); + } + return _activityActorSegment(value, quoted); + } + if (lower.contains('server group ')) { + if (quotedIndex == 0) { + return _ActivityTextSegment(quoted, _ActivityTone.group); + } + return _activityActorSegment(value, quoted); + } + if (after.startsWith(' connected to channel') || + after.startsWith(' switched from channel') || + after.startsWith(' disconnected') || + after.startsWith(' was moved from channel') || + after.startsWith(' is now ')) { + return _activityActorSegment(value, quoted); + } + if (lower.contains('channel ') && + (after.startsWith('.') || + after.startsWith(' to') || + after.startsWith(' from') || + after.startsWith(' by') || + after.isEmpty)) { + return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true); + } + if (lower.contains('channel ') && + (quotedIndex == 1 || quotedIndex == 2 || quotedIndex == 3)) { + return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true); + } + return _activityActorSegment(value, quoted); +} + +_ActivitySegment _activityActorSegment(String value, String quoted) { + final lower = value.toLowerCase(); + if (lower.contains('auto') || + lower.contains('automation') || + lower.contains('bot')) { + return _ActivityTextSegment( + quoted, + _ActivityTone.automationActor, + bold: true, + ); + } + if (lower.contains('server') || + lower.contains('admin') || + lower.contains('vigorous pro')) { + return _ActivityTextSegment(quoted, _ActivityTone.adminActor, bold: true); + } + return _ActivityTextSegment(quoted, _ActivityTone.username, bold: true); +} + +_ActivityTone _activityActionTone(String text) { + final lower = text.toLowerCase(); + if (lower.contains('connected')) return _ActivityTone.positive; + if (lower.contains('disconnected') || + lower.contains('dropped') || + lower.contains('lost') || + lower.contains('shutdown')) { + return _ActivityTone.warning; + } + if (lower.contains('switched') || + lower.contains('moved') || + lower.contains('created') || + lower.contains('deleted') || + lower.contains('renamed') || + lower.contains('assigned') || + lower.contains('removed')) { + return _ActivityTone.transition; + } + return _ActivityTone.plain; +} + +Color _activityColor(BuildContext context, _ActivityTone tone) { + return switch (tone) { + _ActivityTone.positive => const Color(0xFF2F7D57), + _ActivityTone.transition => const Color(0xFF5A7394), + _ActivityTone.warning => const Color(0xFFC06A2B), + _ActivityTone.plain => const Color(0xFF6C7C8F), + _ActivityTone.username => const Color(0xFFC64A4A), + _ActivityTone.channel => const Color(0xFF3B6EA8), + _ActivityTone.adminActor => const Color(0xFF426B9A), + _ActivityTone.automationActor => const Color(0xFFB64C4C), + _ActivityTone.group => const Color(0xFF8B7A68), + }; +} + /// Resolve the best chat target to show when opening the chat page. /// /// Returns null to show the chat hub. @@ -76,15 +243,126 @@ rust.BridgeMessageTarget? resolveInitialChatTarget({ required List messages, required BigInt? currentVoiceChannelId, }) { + bool hasServerActivity = false; for (final message in messages.reversed) { if (message.isPrivate) return message.target; + if (message.target is rust.BridgeMessageTarget_Server) { + hasServerActivity = true; + } } if (currentVoiceChannelId != null) { return const rust.BridgeMessageTarget.channel(); } + if (hasServerActivity) { + return const rust.BridgeMessageTarget.server(); + } return null; } +/// Build server-activity entries from a fresh snapshot diff. +List buildServerActivityEntries({ + required rust.BridgeSnapshot previous, + required rust.BridgeSnapshot current, + DateTime? timestamp, +}) { + final now = timestamp ?? DateTime.now(); + final previousClients = { + for (final client in previous.clients) client.id: client, + }; + final currentClients = { + for (final client in current.clients) client.id: client, + }; + final previousChannels = { + for (final channel in previous.channels) channel.id: channel, + }; + final currentChannels = { + for (final channel in current.channels) channel.id: channel, + }; + final entries = []; + + String clientLabel(rust.BridgeClient client, rust.BridgeSnapshot snapshot) { + if (client.id == snapshot.ownClientId) return 'You'; + final name = client.name.isNotEmpty ? client.name : 'Unknown'; + return '"$name"'; + } + + String channelLabel(rust.BridgeSnapshot snapshot, BigInt channelId) { + final name = snapshotChannelName(snapshot, channelId); + if (name.isNotEmpty) return 'channel "$name"'; + return 'channel ${channelId.toString()}'; + } + + void addEntry(String message) { + entries.add( + ChatEntry( + senderId: BigInt.zero, + senderName: 'Server', + message: message, + target: const rust.BridgeMessageTarget.server(), + timestamp: now, + countsTowardUnread: false, + ), + ); + } + + for (final client in current.clients) { + if (client.isServerQuery) continue; + final previousClient = previousClients[client.id]; + if (previousClient == null) { + addEntry( + '${clientLabel(client, current)} connected to ${channelLabel(current, client.channel)}', + ); + continue; + } + if (previousClient.channel != client.channel) { + final fromChannel = channelLabel(previous, previousClient.channel); + final toChannel = channelLabel(current, client.channel); + addEntry( + '${clientLabel(client, current)} switched from $fromChannel to $toChannel', + ); + continue; + } + if (previousClient.name != client.name && client.name.isNotEmpty) { + final previousName = previousClient.id == current.ownClientId + ? 'You' + : (previousClient.name.isNotEmpty ? previousClient.name : 'Unknown'); + final currentName = clientLabel(client, current); + addEntry('$previousName is now $currentName'); + } + } + + for (final client in previous.clients) { + if (client.isServerQuery) continue; + if (currentClients.containsKey(client.id)) continue; + addEntry( + '${clientLabel(client, previous)} disconnected from ${channelLabel(previous, client.channel)}', + ); + } + + for (final channel in current.channels) { + final previousChannel = previousChannels[channel.id]; + if (previousChannel == null) { + addEntry('Channel created: ${channelLabel(current, channel.id)}'); + continue; + } + if (previousChannel.name != channel.name) { + addEntry('Channel renamed to ${channelLabel(current, channel.id)}'); + continue; + } + if (previousChannel.parent != channel.parent || + previousChannel.order != channel.order) { + addEntry('Channel moved: ${channelLabel(current, channel.id)}'); + } + } + + for (final channel in previous.channels) { + if (currentChannels.containsKey(channel.id)) continue; + addEntry('Channel deleted: ${channelLabel(previous, channel.id)}'); + } + + return entries; +} + class ChatClientGroups { const ChatClientGroups({ required this.clientsByChannel, @@ -255,6 +533,9 @@ class ChatPage extends StatefulWidget { super.key, required this.messages, required this.snapshot, + this.messagesSource, + this.snapshotSource, + this.refreshListenable, this.initialTarget, this.initialClientName = '', this.onTs3ServerLink, @@ -266,6 +547,15 @@ class ChatPage extends StatefulWidget { /// Latest TeamSpeak snapshot. final rust.BridgeSnapshot snapshot; + /// Optional live source for messages while this route stays open. + final List Function()? messagesSource; + + /// Optional live source for snapshots while this route stays open. + final rust.BridgeSnapshot Function()? snapshotSource; + + /// Triggers a rebuild when the backing chat state changes. + final Listenable? refreshListenable; + /// Non-null to open a specific target directly; null for hub. final rust.BridgeMessageTarget? initialTarget; @@ -284,13 +574,19 @@ class _ChatPageState extends State { String _selectedClientName = ''; final Set _closedPrivateChats = {}; + List get _messages => + widget.messagesSource?.call() ?? widget.messages; + + rust.BridgeSnapshot get _snapshot => + widget.snapshotSource?.call() ?? widget.snapshot; + @override void initState() { super.initState(); _selectedTarget = widget.initialTarget ?? resolveInitialChatTarget( - messages: widget.messages, + messages: _messages, currentVoiceChannelId: _currentChannelId, ) ?? const rust.BridgeMessageTarget.server(); @@ -298,16 +594,28 @@ class _ChatPageState extends State { } BigInt? get _currentChannelId { - return ownClientSnapshotState(widget.snapshot)?.channelId; + return ownClientSnapshotState(_snapshot)?.channelId; } @override Widget build(BuildContext context) { + if (widget.refreshListenable != null) { + return ListenableBuilder( + listenable: widget.refreshListenable!, + builder: (context, _) => _buildScaffold(context), + ); + } + return _buildScaffold(context); + } + + Widget _buildScaffold(BuildContext context) { + final snapshot = _snapshot; + final messages = _messages; final currentChannelId = _currentChannelId; - final channelName = snapshotChannelName(widget.snapshot, currentChannelId); + final channelName = snapshotChannelName(snapshot, currentChannelId); return Scaffold( appBar: AppBar( - title: Text('Chat — ${widget.snapshot.serverName}'), + title: Text('Chat — ${snapshot.serverName}'), actions: [ IconButton( tooltip: 'Close chat', @@ -334,8 +642,8 @@ class _ChatPageState extends State { child: _ChatDetailView( target: _selectedTarget, clientName: _selectedClientName, - snapshot: widget.snapshot, - messages: widget.messages, + snapshot: snapshot, + messages: messages, currentChannelId: currentChannelId, channelName: channelName, onTs3ServerLink: widget.onTs3ServerLink, @@ -353,7 +661,7 @@ class _ChatPageState extends State { List<_PrivateChatItem> get _privateChats { final chats = {}; - for (final message in widget.messages) { + for (final message in _messages) { final target = message.target; if (target is! rust.BridgeMessageTarget_Client) continue; final id = target.field0; @@ -378,7 +686,7 @@ class _ChatPageState extends State { } String _privateChatName(BigInt id, String fallback) { - for (final client in widget.snapshot.clients) { + for (final client in _snapshot.clients) { if (client.id == id && client.name.isNotEmpty) return client.name; } return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct'; @@ -443,7 +751,7 @@ class _ChatSidebar extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 148, + width: _chatSidebarTileExtent, child: Column( children: [ _ChatSidebarItem( @@ -513,30 +821,45 @@ class _ChatSidebarItem extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final bg = selected - ? theme.colorScheme.primaryContainer - : Colors.transparent; - final fg = selected - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurface; + final fg = selected ? Colors.white : theme.colorScheme.onSurface; return Material( - color: bg, + color: Colors.transparent, child: InkWell( onTap: onTap, - child: SizedBox( - height: 44, + borderRadius: BorderRadius.circular(8), + child: Ink( + width: _chatSidebarTileExtent, + height: _chatSidebarTileExtent, + decoration: BoxDecoration( + color: selected + ? _chatSidebarSelectedTileColor + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 18, color: fg), - const SizedBox(width: 8), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith(color: fg), + SizedBox( + width: 24, + height: 24, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [Icon(icon, size: 18, color: fg)], + ), + ), + const SizedBox(height: 8), + Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + color: fg, + height: 1.1, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, ), ), ], @@ -693,6 +1016,8 @@ class _ChatDetailView extends StatefulWidget { class _ChatDetailViewState extends State<_ChatDetailView> { final _textCtl = TextEditingController(); final _scrollCtl = ScrollController(); + int _lastRenderedMessageCount = -1; + rust.BridgeMessageTarget? _lastRenderedTarget; Iterable get _filtered { if (widget.target is rust.BridgeMessageTarget_Channel) { @@ -758,10 +1083,22 @@ class _ChatDetailViewState extends State<_ChatDetailView> { } } + void _scheduleScrollIfNeeded(int messageCount) { + final targetChanged = _lastRenderedTarget != widget.target; + final countChanged = _lastRenderedMessageCount != messageCount; + _lastRenderedTarget = widget.target; + _lastRenderedMessageCount = messageCount; + if (!targetChanged && !countChanged) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scrollToBottom(); + }); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final msgs = _filtered.toList(); + _scheduleScrollIfNeeded(msgs.length); final placeholder = chatInputPlaceholder( widget.target, channelName: widget.channelName, @@ -882,10 +1219,13 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); if (entry.isPoke) { return _PokeHistoryRow(entry: entry); } + if (entry.isServerActivity) { + return _ServerActivityRow(entry: entry); + } + final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( @@ -947,6 +1287,61 @@ class _MessageBubble extends StatelessWidget { } } +class _ServerActivityRow extends StatelessWidget { + const _ServerActivityRow({required this.entry}); + + final ChatEntry entry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final timestampStyle = theme.textTheme.bodySmall?.copyWith( + color: const Color(0xFF8A98A8), + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w400, + letterSpacing: 0.15, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final baseStyle = theme.textTheme.bodySmall?.copyWith( + color: const Color(0xFF6C7C8F), + fontWeight: FontWeight.w400, + fontSize: 13, + height: 1.45, + letterSpacing: 0.05, + ); + final spans = [ + TextSpan( + text: '<${chatTimeLabel(entry.timestamp)}> ', + style: timestampStyle, + ), + ]; + for (final segment in _activitySegments(entry.message)) { + spans.add( + TextSpan( + text: segment.text, + style: baseStyle?.copyWith( + color: _activityColor(context, segment.tone), + fontWeight: segment.bold ? FontWeight.w700 : FontWeight.w400, + ), + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3), + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFF7F9FC), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text.rich(TextSpan(children: spans)), + ), + ), + ); + } +} + class _PokeHistoryRow extends StatelessWidget { const _PokeHistoryRow({required this.entry}); diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index e2797a1..601c46e 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -26,6 +26,21 @@ import 'voice_settings_controls.dart'; import 'voice_status_summary.dart'; import '../src/rust/api.dart' as rust; +bool get _isIos { + if (kIsWeb) return false; + return Platform.isIOS; +} + +bool get _isMacOS { + if (kIsWeb) return false; + return Platform.isMacOS; +} + +bool get _isDesktopSileroVadHost { + if (kIsWeb) return false; + return Platform.isWindows || Platform.isLinux; +} + /// Two-line status chip that summarises the current voice state. /// Tap to open the voice details modal. class VoiceStatusChip extends StatelessWidget { @@ -448,17 +463,17 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { widget.initialAudioConfig, ); - // Poll audio stats at 80 ms so TX/RX counters and the level meter + // Poll audio stats at 250 ms so TX/RX counters and the level meter // update in real time while the sheet is open, independent of the parent. - _statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async { + _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; setState(() { _stats = s; _rateTickCount++; - // Compute rates every ~960 ms (12 × 80 ms). - if (_rateTickCount >= 12) { + // Compute rates every ~1 s (4 × 250 ms). + if (_rateTickCount >= 4) { _txRate = s.framesSent - _prevSent; _rxRate = s.framesReceived - _prevReceived; _prevSent = s.framesSent; @@ -657,103 +672,99 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { _notifyAudioConfig(); }, ), + const SizedBox(height: 4), + Text( + _audioProcessing.preferHardware + ? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.' + : 'Software mode applies the full WebRTC APM stage set.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ], - AudioProcessingToggleRow( - dense: true, - label: 'Noise suppression', - subtitle: 'Wiener filter', - value: _audioProcessing.nsEnabled, - onChanged: (v) { - setState(() => _audioProcessing.nsEnabled = v); - _notifyAudioConfig(); - }, - ), - AudioProcessingToggleRow( - dense: true, - label: 'Echo cancellation', - subtitle: () { - if (Platform.isAndroid) { - return 'WebRTC AEC3 · adaptive filter'; - } - return widget.initialAudioConfig.iosMode == - rust - .BridgeIosVoiceProcessingMode - .platformVoiceProcessing - ? 'Always on · managed by platform VPIO' - : 'AEC3 adaptive filter · 80 ms tail'; - }(), - value: () { - if (Platform.isAndroid) return _audioProcessing.aecEnabled; - return widget.initialAudioConfig.iosMode == - rust - .BridgeIosVoiceProcessingMode - .platformVoiceProcessing - ? true - : _audioProcessing.aecEnabled; - }(), - onChanged: () { - if (Platform.isAndroid) { - return (v) { - setState(() => _audioProcessing.aecEnabled = v); - _notifyAudioConfig(); - }; - } - return widget.initialAudioConfig.iosMode == - rust - .BridgeIosVoiceProcessingMode - .platformVoiceProcessing + if (_isIos) ...[ + Text( + 'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are ' + 'hidden here; only settings that still affect the shipping ' + 'iOS path are shown.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + ], + if (!_isIos && + (!Platform.isAndroid || + androidShowsNsControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Noise suppression', + subtitle: 'Wiener filter', + value: _audioProcessing.nsEnabled, + onChanged: (v) { + setState(() => _audioProcessing.nsEnabled = v); + _notifyAudioConfig(); + }, + ), + if (!_isIos && + (!Platform.isAndroid || + androidShowsAecControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Echo cancellation', + subtitle: Platform.isAndroid + ? (_audioProcessing.preferHardware + ? 'Prefers device/OS effect; falls back to WebRTC AEC3' + : 'WebRTC AEC3 · adaptive filter') + : (_isMacOS + ? 'Managed by platform VPIO' + : 'WebRTC AEC3 · adaptive filter'), + value: _isMacOS ? true : _audioProcessing.aecEnabled, + onChanged: _isMacOS ? null : (v) { setState(() => _audioProcessing.aecEnabled = v); _notifyAudioConfig(); - }; - }(), - ), - AudioProcessingToggleRow( - dense: true, - label: 'Auto gain control', - subtitle: 'AGC2 · −18 dBFS target', - value: _audioProcessing.agcEnabled, - onChanged: (v) { - setState(() => _audioProcessing.agcEnabled = v); - _notifyAudioConfig(); - }, - ), - AudioProcessingToggleRow( - dense: true, - label: 'High-pass filter', - subtitle: '80 Hz · DC removal', - value: _audioProcessing.hpfEnabled, - onChanged: (v) { - setState(() => _audioProcessing.hpfEnabled = v); - _notifyAudioConfig(); - }, - ), - AudioProcessingToggleRow( - dense: true, - label: 'Peak limiter', - subtitle: '−1 dBFS soft-knee · 2 ms look-ahead', - value: _audioProcessing.limiterEnabled, - onChanged: (v) { - setState(() => _audioProcessing.limiterEnabled = v); - _notifyAudioConfig(); - }, - ), - - // iOS mode selector. - if (Platform.isIOS) ...[ - const VoiceSubHeader('Processing backend'), - SegmentedButton( - style: voiceSegmentedButtonStyle(theme), - segments: iosProcessingSegments, - selected: {_audioProcessing.iosMode}, - onSelectionChanged: (s) { - setState(() => _audioProcessing.iosMode = s.first); + }, + ), + if (!_isIos && + (!Platform.isAndroid || + androidShowsAgcControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Auto gain control', + subtitle: 'AGC2 · -18 dBFS target', + value: _audioProcessing.agcEnabled, + onChanged: (v) { + setState(() => _audioProcessing.agcEnabled = v); + _notifyAudioConfig(); + }, + ), + if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing)) + AudioProcessingToggleRow( + dense: true, + label: 'High-pass filter', + subtitle: '80 Hz · DC removal', + value: _audioProcessing.hpfEnabled, + onChanged: (v) { + setState(() => _audioProcessing.hpfEnabled = v); + _notifyAudioConfig(); + }, + ), + if (!_isIos && + (!Platform.isAndroid || + androidShowsLimiterControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Peak limiter', + subtitle: '-1 dBFS soft-knee · 2 ms look-ahead', + value: _audioProcessing.limiterEnabled, + onChanged: (v) { + setState(() => _audioProcessing.limiterEnabled = v); _notifyAudioConfig(); }, ), - ], // VAD backend. const SizedBox(height: 8), @@ -766,7 +777,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { const SizedBox(height: 2), SegmentedButton( style: voiceSegmentedButtonStyle(theme), - segments: vadBackendSegments, + segments: _isDesktopSileroVadHost + ? desktopVadBackendSegments + : vadBackendSegments, selected: {_audioProcessing.vadBackend}, onSelectionChanged: (s) { setState(() => _audioProcessing.vadBackend = s.first); diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index 8fc3bfe..45cd4be 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -5,7 +5,7 @@ // - Automatic gain control (AGC2) // - High-pass filter (HPF) // - VAD backend -// - iOS voice processing mode +// - platform audio-processing mode selection where available import 'dart:io' show Platform; @@ -32,6 +32,16 @@ bool get _isIos { return Platform.isIOS; } +bool get _isMacOS { + if (kIsWeb) return false; + return Platform.isMacOS; +} + +bool get _isDesktopSileroVadHost { + if (kIsWeb) return false; + return Platform.isWindows || Platform.isLinux; +} + /// Result returned by [VoiceSettingsDialog]. class VoiceSettingsResult { const VoiceSettingsResult({ @@ -104,9 +114,6 @@ class _VoiceSettingsDialogState extends State { Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); - final platformVpio = - _audioProcessing.iosMode == - rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing; return AlertDialog( title: Text(l10n.voiceSettingsTitle), contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), @@ -177,19 +184,6 @@ class _VoiceSettingsDialogState extends State { const Divider(height: 24), const VoiceSectionHeader('Audio processing'), - // iOS mode selector (iOS only) - if (_isIos) ...[ - const VoiceSubHeader('Processing backend'), - SegmentedButton( - style: voiceSegmentedButtonStyle(theme), - segments: iosProcessingSegments, - selected: {_audioProcessing.iosMode}, - onSelectionChanged: (s) => - setState(() => _audioProcessing.iosMode = s.first), - ), - const SizedBox(height: 4), - ], - // Android HW/SW selector if (_isAndroid) ...[ const VoiceSubHeader('Processing backend'), @@ -201,51 +195,81 @@ class _VoiceSettingsDialogState extends State { setState(() => _audioProcessing.preferHardware = s.first), ), const SizedBox(height: 4), + Text( + _audioProcessing.preferHardware + ? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.' + : 'Android software mode applies the full WebRTC APM control set.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), ], // DSP toggles const VoiceSubHeader('DSP stages'), - AudioProcessingToggleRow( - label: 'Noise suppression (NS)', - subtitle: 'Wiener filter · stationary noise', - value: _audioProcessing.nsEnabled, - onChanged: (v) => - setState(() => _audioProcessing.nsEnabled = v), - ), - AudioProcessingToggleRow( - label: 'Echo cancellation (AEC3)', - subtitle: _isAndroid - ? 'WebRTC AEC3 · adaptive filter' - : platformVpio - ? 'Managed by platform VPIO' - : 'Adaptive NLMS · 80 ms tail', - value: _audioProcessing.aecEnabled, - // AEC is always on in VPIO mode — disable the toggle. - onChanged: (_isAndroid || !platformVpio) - ? (v) => setState(() => _audioProcessing.aecEnabled = v) - : null, - ), - AudioProcessingToggleRow( - label: 'Auto gain control (AGC2)', - subtitle: 'RNN VAD-gated · −18 dBFS target', - value: _audioProcessing.agcEnabled, - onChanged: (v) => - setState(() => _audioProcessing.agcEnabled = v), - ), - AudioProcessingToggleRow( - label: 'High-pass filter (HPF)', - subtitle: '80 Hz Butterworth · DC removal', - value: _audioProcessing.hpfEnabled, - onChanged: (v) => - setState(() => _audioProcessing.hpfEnabled = v), - ), - AudioProcessingToggleRow( - label: 'Peak limiter', - subtitle: '−1 dBFS soft-knee · 2 ms look-ahead', - value: _audioProcessing.limiterEnabled, - onChanged: (v) => - setState(() => _audioProcessing.limiterEnabled = v), - ), + if (_isIos) ...[ + Text( + 'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are ' + 'hidden here; only settings that still affect the shipping ' + 'iOS path are shown.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ], + if (!_isIos && + (!_isAndroid || androidShowsNsControl(_audioProcessing))) + AudioProcessingToggleRow( + label: 'Noise suppression (NS)', + subtitle: 'Wiener filter · stationary noise', + value: _audioProcessing.nsEnabled, + onChanged: (v) => + setState(() => _audioProcessing.nsEnabled = v), + ), + if (!_isIos && + (!_isAndroid || androidShowsAecControl(_audioProcessing))) + AudioProcessingToggleRow( + label: 'Echo cancellation (AEC3)', + subtitle: _isAndroid + ? (_audioProcessing.preferHardware + ? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable' + : 'WebRTC AEC3 · adaptive filter') + : (_isMacOS + ? 'Managed by platform VPIO' + : 'WebRTC AEC3 · adaptive filter'), + value: _isMacOS ? true : _audioProcessing.aecEnabled, + onChanged: _isMacOS + ? null + : (v) => setState(() => _audioProcessing.aecEnabled = v), + ), + if (!_isIos && + (!_isAndroid || androidShowsAgcControl(_audioProcessing))) + AudioProcessingToggleRow( + label: 'Auto gain control (AGC2)', + subtitle: 'RNN VAD-gated · -18 dBFS target', + value: _audioProcessing.agcEnabled, + onChanged: (v) => + setState(() => _audioProcessing.agcEnabled = v), + ), + if (!_isAndroid || androidShowsHpfControl(_audioProcessing)) + AudioProcessingToggleRow( + label: 'High-pass filter (HPF)', + subtitle: '80 Hz Butterworth · DC removal', + value: _audioProcessing.hpfEnabled, + onChanged: (v) => + setState(() => _audioProcessing.hpfEnabled = v), + ), + if (!_isIos && + (!_isAndroid || androidShowsLimiterControl(_audioProcessing))) + AudioProcessingToggleRow( + label: 'Peak limiter', + subtitle: '-1 dBFS soft-knee · 2 ms look-ahead', + value: _audioProcessing.limiterEnabled, + onChanged: (v) => + setState(() => _audioProcessing.limiterEnabled = v), + ), if (isTalkPowerBlocked( talkPower: widget.talkPower, @@ -267,7 +291,9 @@ class _VoiceSettingsDialogState extends State { const VoiceSubHeader('Backend'), SegmentedButton( style: voiceSegmentedButtonStyle(theme), - segments: vadBackendSegments, + segments: _isDesktopSileroVadHost + ? desktopVadBackendSegments + : vadBackendSegments, selected: {_audioProcessing.vadBackend}, onSelectionChanged: (s) => setState(() => _audioProcessing.vadBackend = s.first), diff --git a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart index e4df35c..7c15441 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart @@ -43,20 +43,6 @@ const androidProcessingSegments = [ ), ]; -/// iOS VPIO/Sonora selector segments. -const iosProcessingSegments = [ - ButtonSegment( - value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, - label: Text('VPIO'), - icon: Icon(Icons.phone_iphone, size: 14), - ), - ButtonSegment( - value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental, - label: Text('Sonora'), - icon: Icon(Icons.science_outlined, size: 14), - ), -]; - /// Voice activity detector selector segments. const vadBackendSegments = [ ButtonSegment( @@ -69,10 +55,17 @@ const vadBackendSegments = [ label: Text('Silero'), icon: Icon(Icons.psychology, size: 14), ), +]; + +/// Desktop VAD selector segments. +/// +/// Windows and Linux use Silero as the primary VAD. WebRTC remains an +/// internal runtime fallback when the model/runtime is unavailable. +const desktopVadBackendSegments = [ ButtonSegment( - value: rust.BridgeVadBackend.tenVad, - label: Text('TEN'), - icon: Icon(Icons.graphic_eq, size: 14), + value: rust.BridgeVadBackend.sileroOnnx, + label: Text('Silero'), + icon: Icon(Icons.psychology, size: 14), ), ]; diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 554940c..c513ed9 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -111,7 +111,6 @@ flutter: assets: - assets/models/silero_vad.onnx - - assets/models/ten_vad.onnx # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart b/apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart index 48793b6..d7f9567 100644 --- a/apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart +++ b/apps/chanora_flutter/test/widgets/audio_device_list_tile_test.dart @@ -29,4 +29,94 @@ void main() { expect(find.byType(CircularProgressIndicator), findsOneWidget); }, ); + + testWidgets('audio device tile shows selected device and details', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AudioDeviceListTile( + label: 'Output', + kind: AudioDeviceKind.output, + loadDevices: () async => const rust.BridgeAudioDeviceList( + inputDevices: [], + outputDevices: [ + rust.BridgeAudioDevice( + id: 'default-speakers', + name: 'Speakers', + details: 'Realtek · Speaker · id=1111', + isDefault: true, + isSelected: false, + ), + rust.BridgeAudioDevice( + id: 'usb-headset', + name: 'USB Headset', + details: 'SteelSeries · Headset · id=2222', + isDefault: false, + isSelected: true, + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('USB Headset'), findsOneWidget); + expect(find.text('SteelSeries · Headset · id=2222'), findsNothing); + + await tester.tap(find.text('Output')); + await tester.pumpAndSettle(); + + expect(find.text('SteelSeries · Headset · id=2222'), findsOneWidget); + expect(find.text('System default'), findsOneWidget); + }); + + testWidgets('audio device tile selects by id', (tester) async { + String? selectedId = 'device-a'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AudioDeviceListTile( + label: 'Input', + kind: AudioDeviceKind.input, + loadDevices: () async => const rust.BridgeAudioDeviceList( + inputDevices: [ + rust.BridgeAudioDevice( + id: 'device-a', + name: 'Built-in Mic', + details: 'Realtek · Microphone · id=aaaa', + isDefault: true, + isSelected: true, + ), + rust.BridgeAudioDevice( + id: 'device-b', + name: 'USB Mic', + details: 'Shure · Microphone · id=bbbb', + isDefault: false, + isSelected: false, + ), + ], + outputDevices: [], + ), + setInputDevice: ({String? id}) async { + selectedId = id; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.text('Input')); + await tester.pumpAndSettle(); + await tester.tap(find.text('USB Mic')); + await tester.pump(); + + expect(selectedId, 'device-b'); + }); } diff --git a/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart index 2aaeca5..bc3053a 100644 --- a/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart +++ b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'dart:io' show Platform; import 'package:chanora_flutter/src/rust/api.dart' as rust; import 'package:chanora_flutter/widgets/audio_processing_config_state.dart'; @@ -20,28 +21,64 @@ void main() { debugWavDumpEnabled: true, ); - test('normalizes hidden disabled VAD backend for UI state', () { + test('normalizes hidden disabled VAD backend to Silero for UI state', () { final state = AudioProcessingConfigState.fromConfig(baseConfig); - expect(state.vadBackend, rust.BridgeVadBackend.webrtcVad); + expect(state.vadBackend, rust.BridgeVadBackend.sileroOnnx); expect(state.preferHardware, isTrue); expect(state.nsEnabled, isFalse); expect(state.aecEnabled, isTrue); expect(state.agcEnabled, isTrue); }); - test('default config uses platform processing and Silero VAD', () { + test('normalizes desktop VAD backend to Silero', () { expect( - defaultAudioProcessingConfig.processingBackend, - rust.BridgeAudioBackend.platformVoiceProcessing, - ); - expect( - defaultAudioProcessingConfig.vadBackend, + normalizedVadBackend( + rust.BridgeVadBackend.webrtcVad, + isWindows: true, + isLinux: false, + ), rust.BridgeVadBackend.sileroOnnx, ); - expect(defaultAudioProcessingConfig.aec, rust.BridgeEffectOwner.platform); - expect(defaultAudioProcessingConfig.ns, rust.BridgeEffectOwner.platform); - expect(defaultAudioProcessingConfig.agc, rust.BridgeEffectOwner.platform); + expect( + normalizedVadBackend( + rust.BridgeVadBackend.webrtcVad, + isWindows: false, + isLinux: true, + ), + rust.BridgeVadBackend.sileroOnnx, + ); + }); + + test('default config matches the current platform fallback', () { + final fallback = defaultAudioProcessingConfig(); + final desktop = Platform.isWindows || Platform.isLinux; + + expect( + fallback.processingBackend, + desktop + ? rust.BridgeAudioBackend.webrtcApm + : rust.BridgeAudioBackend.platformVoiceProcessing, + ); + expect(fallback.vadBackend, rust.BridgeVadBackend.sileroOnnx); + expect( + fallback.aec, + desktop + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.platform, + ); + expect( + fallback.ns, + desktop + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.platform, + ); + expect( + fallback.agc, + desktop + ? rust.BridgeEffectOwner.webrtcApm + : rust.BridgeEffectOwner.platform, + ); }); test('builds Android hardware config consistently', () { @@ -57,25 +94,113 @@ void main() { config.processingBackend, rust.BridgeAudioBackend.platformVoiceProcessing, ); - expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad); + expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx); expect(config.aec, rust.BridgeEffectOwner.off); expect(config.ns, rust.BridgeEffectOwner.platform); expect(config.agc, rust.BridgeEffectOwner.platform); expect(config.debugWavDumpEnabled, isTrue); }); - test('builds Sonora config with WebRTC-owned enabled effects', () { + test('builds Windows/Linux software WebRTC APM config consistently', () { final state = AudioProcessingConfigState.fromConfig(baseConfig) - ..iosMode = rust.BridgeIosVoiceProcessingMode.sonoraExperimental ..nsEnabled = true ..aecEnabled = false ..agcEnabled = true; - final config = state.buildConfig(base: baseConfig, isAndroid: false); + final windowsConfig = state.buildConfig( + base: baseConfig, + isAndroid: false, + isIos: false, + isMacOS: false, + isWindows: true, + isLinux: false, + ); - expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm); - expect(config.aec, rust.BridgeEffectOwner.off); - expect(config.ns, rust.BridgeEffectOwner.webrtcApm); - expect(config.agc, rust.BridgeEffectOwner.webrtcApm); + final linuxConfig = state.buildConfig( + base: baseConfig, + isAndroid: false, + isIos: false, + isMacOS: false, + isWindows: false, + isLinux: true, + ); + + for (final config in [windowsConfig, linuxConfig]) { + expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm); + expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx); + expect(config.aec, rust.BridgeEffectOwner.off); + expect(config.ns, rust.BridgeEffectOwner.webrtcApm); + expect(config.agc, rust.BridgeEffectOwner.webrtcApm); + expect(config.debugWavDumpEnabled, isTrue); + } + }); + + test('normalizes hidden iOS sonora config back to platform VPIO', () { + const sonoraConfig = rust.BridgeAudioProcessingConfig( + route: rust.BridgeAudioRoute.unknown, + iosMode: rust.BridgeIosVoiceProcessingMode.sonoraExperimental, + processingBackend: rust.BridgeAudioBackend.webrtcApm, + vadBackend: rust.BridgeVadBackend.disabled, + aec: rust.BridgeEffectOwner.webrtcApm, + ns: rust.BridgeEffectOwner.webrtcApm, + agc: rust.BridgeEffectOwner.webrtcApm, + hpfEnabled: true, + limiterEnabled: false, + vadHangoverMs: 500, + vadPreRollMs: 160, + vadMinTxMs: 200, + debugWavDumpEnabled: true, + ); + + final state = AudioProcessingConfigState.fromConfig(sonoraConfig); + final config = state.buildConfig( + base: sonoraConfig, + isAndroid: false, + isIos: true, + isMacOS: false, + isWindows: false, + isLinux: false, + ); + + expect( + state.iosMode, + rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + ); + expect( + config.iosMode, + rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, + ); + expect( + config.processingBackend, + rust.BridgeAudioBackend.platformVoiceProcessing, + ); + expect(config.aec, rust.BridgeEffectOwner.platform); + expect(config.ns, rust.BridgeEffectOwner.platform); + expect(config.agc, rust.BridgeEffectOwner.platform); + }); + + test( + 'Android hardware mode keeps controls visible because stages can fall back to WebRTC', + () { + final state = AudioProcessingConfigState.fromConfig(baseConfig) + ..preferHardware = true; + + expect(androidShowsNsControl(state), isTrue); + expect(androidShowsAecControl(state), isTrue); + expect(androidShowsAgcControl(state), isTrue); + expect(androidShowsHpfControl(state), isTrue); + expect(androidShowsLimiterControl(state), isFalse); + }, + ); + + test('Android software mode shows effective WebRTC APM controls', () { + final state = AudioProcessingConfigState.fromConfig(baseConfig) + ..preferHardware = false; + + expect(androidShowsNsControl(state), isTrue); + expect(androidShowsAecControl(state), isTrue); + expect(androidShowsAgcControl(state), isTrue); + expect(androidShowsHpfControl(state), isTrue); + expect(androidShowsLimiterControl(state), isFalse); }); } diff --git a/apps/chanora_flutter/test/widgets/chat_views_test.dart b/apps/chanora_flutter/test/widgets/chat_views_test.dart index 88224cb..ed4f141 100644 --- a/apps/chanora_flutter/test/widgets/chat_views_test.dart +++ b/apps/chanora_flutter/test/widgets/chat_views_test.dart @@ -113,6 +113,64 @@ void main() { expect(target, isNull); }); + test('initial chat target falls back to server activity when present', () { + final target = resolveInitialChatTarget( + messages: [ + entry(const rust.BridgeMessageTarget.server()), + entry(const rust.BridgeMessageTarget.channel()), + ], + currentVoiceChannelId: null, + ); + + expect(target, const rust.BridgeMessageTarget.server()); + }); + + test('builds server activity entries for join, move, and disconnect', () { + final lobbyId = BigInt.from(10); + final quietId = BigInt.from(20); + final previous = snapshot( + channels: [ + channel(lobbyId, 'Default Channel'), + channel(quietId, 'Quiet Zone'), + ], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: quietId), + client(id: BigInt.from(2), name: 'Alice', channelId: lobbyId), + client(id: BigInt.from(3), name: 'Bob', channelId: lobbyId), + ], + ); + final current = snapshot( + channels: [ + channel(lobbyId, 'Default Channel'), + channel(quietId, 'Quiet Zone'), + ], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: quietId), + client(id: BigInt.from(2), name: 'Alice', channelId: quietId), + client(id: BigInt.from(4), name: 'Carol', channelId: lobbyId), + ], + ); + + final entries = buildServerActivityEntries( + previous: previous, + current: current, + timestamp: DateTime(2026, 5, 24, 11, 40, 39), + ); + + expect(entries.map((entry) => entry.message), [ + '"Alice" switched from channel "Default Channel" to channel "Quiet Zone"', + '"Carol" connected to channel "Default Channel"', + '"Bob" disconnected from channel "Default Channel"', + ]); + expect(entries.every((entry) => !entry.countsTowardUnread), isTrue); + expect( + entries.every( + (entry) => entry.target == const rust.BridgeMessageTarget.server(), + ), + isTrue, + ); + }); + test( 'groups chat picker clients by channel and excludes own/query clients', () { @@ -244,6 +302,76 @@ void main() { expect(groups.single.channelName, 'Lobby'); }); + test('builds server activity entries from snapshot deltas', () { + final before = snapshot( + channels: [ + channel(BigInt.from(10), 'Lobby'), + channel(BigInt.from(20), 'AFK'), + ], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)), + client(id: BigInt.from(2), name: 'Alice', channelId: BigInt.from(10)), + client(id: BigInt.from(3), name: 'Bob', channelId: BigInt.from(20)), + ], + ); + final after = snapshot( + channels: [ + channel(BigInt.from(10), 'Lobby'), + channel(BigInt.from(20), 'AFK'), + ], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)), + client(id: BigInt.from(2), name: 'Alice', channelId: BigInt.from(20)), + client(id: BigInt.from(4), name: 'Carol', channelId: BigInt.from(10)), + ], + ); + + final entries = buildServerActivityEntries( + previous: before, + current: after, + timestamp: DateTime(2026, 1, 2, 3, 4, 5), + ); + + expect(entries.map((entry) => entry.message), [ + '"Alice" switched from channel "Lobby" to channel "AFK"', + '"Carol" connected to channel "Lobby"', + '"Bob" disconnected from channel "AFK"', + ]); + expect( + entries.every( + (entry) => entry.target == const rust.BridgeMessageTarget.server(), + ), + isTrue, + ); + }); + + test('ignores ServerQuery clients in snapshot-driven server activity', () { + final before = snapshot( + channels: [channel(BigInt.from(10), 'Lobby')], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)), + client( + id: BigInt.from(2), + name: 'Query', + channelId: BigInt.from(10), + isServerQuery: true, + ), + ], + ); + final after = snapshot( + channels: [channel(BigInt.from(10), 'Lobby')], + clients: [client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10))], + ); + + final entries = buildServerActivityEntries( + previous: before, + current: after, + timestamp: DateTime(2026, 1, 2, 3, 4, 5), + ); + + expect(entries, isEmpty); + }); + test('formats chat target titles', () { expect( chatTargetTitle( @@ -366,9 +494,16 @@ void main() { final serverTop = tester.getTopLeft(find.text('Server').first).dy; final channelTop = tester.getTopLeft(find.text('Channel')).dy; final privateTop = tester.getTopLeft(find.text('Alpha').first).dy; + final serverTileSize = tester.getSize( + find + .ancestor(of: find.text('Server').first, matching: find.byType(Ink)) + .first, + ); expect(serverTop, lessThan(channelTop)); expect(channelTop, lessThan(privateTop)); + expect(serverTileSize.width, 92); + expect(serverTileSize.height, 92); }); testWidgets( @@ -476,6 +611,80 @@ void main() { expect(find.text('<05:10:37> “EdisonJwa”戳了你一下'), findsOneWidget); }); + testWidgets('server activity renders as timestamped styled row', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: ChatPage( + messages: [ + ChatEntry( + senderId: BigInt.zero, + senderName: 'Server', + message: '"Alice" connected to channel "Lobby"', + target: const rust.BridgeMessageTarget.server(), + timestamp: DateTime(2026, 5, 24, 11, 40, 39), + countsTowardUnread: false, + ), + ], + snapshot: snapshot(channels: const [], clients: const []), + initialTarget: const rust.BridgeMessageTarget.server(), + ), + ), + ); + + expect(find.textContaining('<11:40:39>'), findsOneWidget); + expect(find.textContaining('"Alice"'), findsOneWidget); + expect(find.textContaining('"Lobby"'), findsOneWidget); + expect(find.byType(CircleAvatar), findsNothing); + expect(find.byType(RichText), findsWidgets); + }); + + testWidgets('chat page updates while open when backing messages change', ( + tester, + ) async { + final messages = [ + ChatEntry( + senderId: BigInt.one, + senderName: 'Sender', + message: 'First', + target: const rust.BridgeMessageTarget.server(), + ), + ]; + var currentSnapshot = snapshot(channels: const [], clients: const []); + final refresh = ValueNotifier(0); + + await tester.pumpWidget( + MaterialApp( + home: ChatPage( + messages: messages, + snapshot: currentSnapshot, + messagesSource: () => messages, + snapshotSource: () => currentSnapshot, + refreshListenable: refresh, + initialTarget: const rust.BridgeMessageTarget.server(), + ), + ), + ); + + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsNothing); + + messages.add( + ChatEntry( + senderId: BigInt.from(2), + senderName: 'Other', + message: 'Second', + target: const rust.BridgeMessageTarget.server(), + ), + ); + refresh.value++; + await tester.pump(); + + expect(find.text('Second'), findsOneWidget); + refresh.dispose(); + }); + test('blocks channel chat when no voice channel is joined', () { expect( canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null), diff --git a/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart index 727b16d..9922be8 100644 --- a/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart +++ b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart @@ -17,18 +17,16 @@ void main() { expect(androidProcessingSegments.map((s) => s.value), [true, false]); }); - test('shared iOS processing segments expose VPIO and Sonora', () { - expect(iosProcessingSegments.map((s) => s.value), [ - rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, - rust.BridgeIosVoiceProcessingMode.sonoraExperimental, - ]); - }); - test('shared VAD segments expose supported non-disabled backends', () { expect(vadBackendSegments.map((s) => s.value), [ rust.BridgeVadBackend.webrtcVad, rust.BridgeVadBackend.sileroOnnx, - rust.BridgeVadBackend.tenVad, + ]); + }); + + test('desktop VAD segments expose Silero as the primary backend', () { + expect(desktopVadBackendSegments.map((s) => s.value), [ + rust.BridgeVadBackend.sileroOnnx, ]); }); diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index cebbe60..eada217 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -65,7 +65,7 @@ pub use chanora_diagnostics::{ }; pub use chanora_protocol::{ ChannelInfo, ChatMessage, ClientInfo, ConnectConfig, DisconnectReason, MessageTarget, - ProtocolError, ServerSnapshot, + ProtocolError, ServerActivity, ServerSnapshot, }; pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore}; @@ -255,6 +255,11 @@ pub enum SessionEvent { /// Target scope (server/channel/private/poke). target: MessageTarget, }, + /// Human-readable TeamSpeak-style server activity. + ServerActivity { + /// Activity line text. + message: String, + }, /// Audio route changed (speaker/earpiece/BT/wired headset). AudioRouteChanged { /// New audio route. @@ -644,17 +649,32 @@ impl ChanoraSession { if cfg.identity.is_none() { let store_guard = self.identity_store.lock().await; if let Some(store) = store_guard.as_ref() { - match store.load()? { - Some(saved) => { + match store.load() { + Ok(Some(saved)) => { info!(target: "chanora_core", "reusing persisted identity"); cfg.identity = Some(saved); } - None => { + Ok(None) => { let fresh = chanora_protocol::ProtocolClient::generate_identity(); - store.save(&fresh)?; - info!(target: "chanora_core", "generated + persisted fresh identity"); + if let Err(err) = store.save(&fresh) { + warn!( + target: "chanora_core", + error = %err, + "could not persist fresh identity; continuing with in-memory identity" + ); + } else { + info!(target: "chanora_core", "generated + persisted fresh identity"); + } cfg.identity = Some(fresh); } + Err(err) => { + warn!( + target: "chanora_core", + error = %err, + "identity store read failed; continuing with in-memory identity" + ); + cfg.identity = Some(chanora_protocol::ProtocolClient::generate_identity()); + } } } } @@ -770,6 +790,25 @@ impl ChanoraSession { }); } + if let Some(activity_rx) = client.take_activity_rx() { + let ev_tx = self.events_tx.clone(); + tokio::spawn(async move { + use tokio::time::{sleep, Duration}; + let mut rx = activity_rx; + loop { + match rx.try_recv() { + Ok(ServerActivity { message }) => { + let _ = ev_tx.send(SessionEvent::ServerActivity { message }); + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + sleep(Duration::from_millis(200)).await; + } + } + } + }); + } + *guard = Some(ConnectedState { protocol: client, audio: None, @@ -904,11 +943,11 @@ impl ChanoraSession { { let dev_in = self.preferred_input_device.lock().await; let dev_out = self.preferred_output_device.lock().await; - if cfg.input_device_name.is_none() { - cfg.input_device_name = dev_in.clone(); + if cfg.input_device_id.is_none() { + cfg.input_device_id = dev_in.clone(); } - if cfg.output_device_name.is_none() { - cfg.output_device_name = dev_out.clone(); + if cfg.output_device_id.is_none() { + cfg.output_device_id = dev_out.clone(); } } @@ -1292,8 +1331,8 @@ impl ChanoraSession { Ok(()) } - /// Route-change hook (SDD-100/SRS-112). Updates audio processing - /// config and notifies Flutter of the new route. + /// Route-change hook. Updates audio processing config, bounces the + /// platform backend when needed, and notifies Flutter of the new route. pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> { let guard = self.inner.lock().await; if let Some(state) = guard.as_ref() { @@ -1301,6 +1340,14 @@ impl ChanoraSession { let mut config = audio.audio_processing_config_snapshot(); config.route = route; audio.set_audio_processing_config(config)?; + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + audio.ios_restart_voice_unit()?; + } + #[cfg(target_os = "android")] + { + audio.android_restart_voice_unit()?; + } } } let _ = self @@ -1420,6 +1467,12 @@ impl ChanoraSession { channel_id: u64, password: Option, ) -> Result<(), CoreError> { + let join_started = std::time::Instant::now(); + info!( + target: "chanora_core", + channel_id, + "voice_join requested" + ); let mut guard = self.inner.lock().await; let state = guard.as_mut().ok_or(CoreError::NotConnected)?; let join_start = std::time::Instant::now(); @@ -1461,19 +1514,6 @@ impl ChanoraSession { .ok_or(CoreError::Invariant( "missing pending generation after join request", ))?; - let pending_key = state - .join_state - .pending - .filter(|pending| pending.generation == generation) - .map(|pending| channel_join::JoinOutcomeKey { - connection_epoch: pending.connection_epoch, - generation: pending.generation, - request_id: pending.request_id, - }) - .ok_or(CoreError::Invariant( - "missing pending key after join request", - ))?; - // 1. Send the move command. With send_with_result the // adapter now correlates against the server's typed // error reply, so on rejection (no permission, wrong @@ -1486,7 +1526,7 @@ impl ChanoraSession { .or_else(|| state.channel_passwords.get(&channel_id).cloned()); if let Err(e) = state .protocol - .move_to_channel(channel_id, password_to_send) + .queue_move_to_channel(channel_id, password_to_send) .await { // TS3 error 0x0302 = `channel_already_in`: we're already @@ -1520,9 +1560,11 @@ impl ChanoraSession { return Err(CoreError::Protocol(e)); } } - let _ = channel_join::reduce( - &mut state.join_state, - ChannelJoinEvent::ProtocolJoinSucceeded { key: pending_key }, + info!( + target: "chanora_core", + channel_id, + elapsed_ms = join_started.elapsed().as_millis() as u64, + "voice_join move_to_channel acknowledged" ); if let Some(pw) = requested_password { state.channel_passwords.insert(channel_id, pw); @@ -1540,37 +1582,26 @@ impl ChanoraSession { self.voice_selector.set_in_channel(true); self.emit_voice_state(projection).await; drop(guard); - // 2. Bring the audio engine up. Tolerate failure: the - // server-side channel move has ALREADY succeeded (step - // 1), so the user is in the channel from every other - // peer's perspective. Failing voice_join hard here would - // leave the UI in a phantom "you're in the channel - // visually but no controls" state because the calling - // Dart code wouldn't receive the VoiceState(true) event - // that gates the AppBar mute icons + the on-screen PTT - // button. Honest behaviour: surface the audio error - // once on the event channel (via the AudioStopped event - // consumers already handle), then continue so the UI - // matches reality — user is in the channel, but mic / - // speakers may be silent until they resolve the audio - // error (e.g. grant mic permission, plug in a working - // device). - if let Err(audio_err) = self.ensure_audio_running().await { - warn!( - target: "chanora_core", - error = %audio_err, - channel_id, - "voice_join: server move succeeded but audio engine \ - failed to start; continuing with no-audio in-channel \ - state so the UI matches the server-side state" - ); - // Tell subscribers the audio engine is not running so - // any audio-stats poll / level meter renders correctly. - // The voice_join itself still resolves Ok below so the - // UI gains the channel + mute + PTT controls. - let _ = self.events_tx.send(SessionEvent::AudioStopped); - } info!(target: "chanora_core", channel_id, "voice_join accepted by server"); + let session = self.clone(); + tokio::spawn(async move { + if let Err(audio_err) = session.ensure_audio_running().await { + warn!( + target: "chanora_core", + error = %audio_err, + channel_id, + "voice_join: deferred audio engine start failed after \ + server-side join; continuing with no-audio in-channel state" + ); + let _ = session.events_tx.send(SessionEvent::AudioStopped); + } + }); + info!( + target: "chanora_core", + channel_id, + elapsed_ms = join_started.elapsed().as_millis() as u64, + "voice_join returned to caller" + ); Ok(()) } @@ -1661,19 +1692,29 @@ impl ChanoraSession { self.voice_selector.hard_mute() } - /// Set the preferred input device name (SRS-026). Takes effect + /// Set the preferred input device id (SRS-026). Takes effect /// on the next audio engine start. - pub async fn set_input_device(&self, name: Option) -> Result<(), CoreError> { - *self.preferred_input_device.lock().await = name; + pub async fn set_input_device(&self, id: Option) -> Result<(), CoreError> { + *self.preferred_input_device.lock().await = id; Ok(()) } - /// Set the preferred output device name (SRS-026). - pub async fn set_output_device(&self, name: Option) -> Result<(), CoreError> { - *self.preferred_output_device.lock().await = name; + /// Set the preferred output device id (SRS-026). + pub async fn set_output_device(&self, id: Option) -> Result<(), CoreError> { + *self.preferred_output_device.lock().await = id; Ok(()) } + /// Read the preferred input device id, if one is pinned. + pub async fn preferred_input_device(&self) -> Option { + self.preferred_input_device.lock().await.clone() + } + + /// Read the preferred output device id, if one is pinned. + pub async fn preferred_output_device(&self) -> Option { + self.preferred_output_device.lock().await.clone() + } + /// Update the release-tail (SDD-096). Clamped to `0..=500` ms /// inclusive. Persists best-effort and re-emits voice state. pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> { diff --git a/crates/chanora_audio/Cargo.toml b/crates/chanora_audio/Cargo.toml index 1a14951..9bd2e93 100644 --- a/crates/chanora_audio/Cargo.toml +++ b/crates/chanora_audio/Cargo.toml @@ -17,7 +17,7 @@ sonora = "0.1" webrtc-vad = "0.4" # ndarray is required by ort's tensor construction API and by -# Silero / TEN VAD ONNX inference across all platforms. +# Silero VAD ONNX inference across all platforms. ndarray = "0.17" # Opus encoder. tsclientlib already pulls this; we depend explicitly so diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 14a7ee8..4d06dab 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -145,16 +145,18 @@ struct AndroidCaptureState { voice_activity_selector: Option>, vad_detector: crate::vad::WebRtcFallbackVad, silero_vad_worker: Option, - ten_vad_worker: Option, current_vad_backend: crate::VadBackend, silero_model_epoch: u64, - ten_model_epoch: u64, capture_frame_seq: u64, vad_state: crate::voice_activity::VoiceActivityStateMachine, webrtc_apm_processor: crate::processor::WebRtcApmProcessor, audio_processing_config: Arc>, audio_processing_stats: Arc, render_reference: Arc, + input_sample_rate_hz: u32, + resample_pos: f64, + resample_last: i16, + resample_scratch: Vec, pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms_len: usize, fallback_warned_backend: Option, @@ -171,29 +173,18 @@ impl AndroidCaptureState { audio_processing_config: Arc>, audio_processing_stats: Arc, render_reference: Arc, + input_sample_rate_hz: u32, ) -> Result { let encoder = crate::opus_voice::new_voip_encoder("android")?; - // Android always uses software WebRTC APM for AEC/NS/AGC/HPF. - // The config's EffectOwner fields are resolved by open() AFTER - // hardware-effect binding; the processor is constructed here - // with all modules enabled regardless, so the resolved config - // (Platform vs WebrtcApm) only affects diagnostics, not behaviour. + // Seed the processor from the current shared config snapshot. + // `open()` may later resolve Platform-owned stages to WebRTC + // fallback (or keep them hardware-owned) once hardware-effect + // binding completes; that resolved config is pushed back into + // the live processor before the streams are started. let webrtc_apm_config = audio_processing_config .lock() - .map(|cfg| { - let mut c = crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg); - c.aec = true; - c.ns = true; - c.agc = true; - c - }) - .unwrap_or(crate::processor::webrtc_apm::WebRtcApmConfig { - aec: true, - ns: true, - agc: true, - hpf: true, - ..Default::default() - }); + .map(|cfg| webrtc_apm_config_from_audio_config(&cfg)) + .unwrap_or_default(); Ok(Self { encoder, pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), @@ -206,10 +197,8 @@ impl AndroidCaptureState { voice_activity_selector, vad_detector: crate::vad::WebRtcFallbackVad::default(), silero_vad_worker: None, - ten_vad_worker: None, current_vad_backend: crate::VadBackend::WebrtcVad, silero_model_epoch: crate::vad::silero_model_epoch(), - ten_model_epoch: crate::vad::ten_model_epoch(), capture_frame_seq: 0, vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config( @@ -218,6 +207,10 @@ impl AndroidCaptureState { audio_processing_config, audio_processing_stats, render_reference, + input_sample_rate_hz: input_sample_rate_hz.max(1), + resample_pos: 0.0, + resample_last: 0, + resample_scratch: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms_len: 0, fallback_warned_backend: None, @@ -227,6 +220,17 @@ impl AndroidCaptureState { /// Consume i16 mono frames from Oboe. Accumulate to 10 ms chunks, /// process each through WebRTC APM + VAD, then encode 20 ms frames. fn ingest_i16(&mut self, samples: &[i16]) { + self.audio_processing_stats + .record_callback_frames(samples.len() as u64); + if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ { + let resampled = self.resample_capture_to_48k(samples); + self.ingest_48k_i16(&resampled); + return; + } + self.ingest_48k_i16(samples); + } + + fn ingest_48k_i16(&mut self, samples: &[i16]) { let mut offset = 0; while offset < samples.len() { let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len; @@ -284,6 +288,41 @@ impl AndroidCaptureState { } } + fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec { + if samples.is_empty() { + return Vec::new(); + } + self.resample_scratch.clear(); + let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64; + let mut pos = self.resample_pos; + while pos < samples.len() as f64 { + let i = pos.floor() as isize; + let frac = pos - i as f64; + let a = if i <= 0 { + self.resample_last as f64 + } else { + samples[(i - 1) as usize] as f64 + }; + let b = if i < samples.len() as isize { + samples[i as usize] as f64 + } else { + a + }; + let value = (a + frac * (b - a)) + .round() + .clamp(i16::MIN as f64, i16::MAX as f64) as i16; + self.resample_scratch.push(value); + pos += ratio; + } + self.resample_pos = pos - samples.len() as f64; + self.resample_last = *samples.last().unwrap_or(&self.resample_last); + self.resample_scratch.clone() + } + + fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) { + self.input_sample_rate_hz = sample_rate_hz.max(1); + } + fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) { if self.fallback_warned_backend != Some(failed_backend) { self.fallback_warned_backend = Some(failed_backend); @@ -317,6 +356,18 @@ impl AndroidCaptureState { self.webrtc_apm_processor.process_render(&render_ref); self.webrtc_apm_processor.process_capture(&mut frame); + let voice_activity_mode = self + .voice_activity_selector + .as_ref() + .map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity) + .unwrap_or(false); + if !voice_activity_mode { + self.silero_vad_worker = None; + self.current_vad_backend = crate::VadBackend::Disabled; + self.fallback_warned_backend = None; + self.audio_processing_stats.set_vad_fallback_active(false); + } + let (vad_hangover, vad_backend) = self .audio_processing_config .try_lock() @@ -325,30 +376,28 @@ impl AndroidCaptureState { crate::voice_activity::VAD_HANGOVER_MS, crate::VadBackend::WebrtcVad, )); - self.vad_state.configure( - crate::voice_activity::VAD_OPEN_AFTER_MS, - vad_hangover, - crate::voice_activity::VAD_MIN_TX_MS, - ); + if voice_activity_mode { + self.vad_state.configure( + crate::voice_activity::VAD_OPEN_AFTER_MS, + vad_hangover, + crate::voice_activity::VAD_MIN_TX_MS, + ); + } - // VAD backend switching (mirrors iOS Raw path). + // VAD backend switching only while VoiceActivity mode is active. let silero_epoch = crate::vad::silero_model_epoch(); - let ten_epoch = crate::vad::ten_model_epoch(); - let silero_changed = - vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch; - let ten_changed = - vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch; - if vad_backend != self.current_vad_backend || silero_changed || ten_changed { + let silero_changed = voice_activity_mode + && vad_backend == crate::VadBackend::SileroOnnx + && silero_epoch != self.silero_model_epoch; + if voice_activity_mode && (vad_backend != self.current_vad_backend || silero_changed) { self.current_vad_backend = vad_backend; self.silero_model_epoch = silero_epoch; - self.ten_model_epoch = ten_epoch; self.fallback_warned_backend = None; match vad_backend { crate::VadBackend::SileroOnnx => { let path = crate::vad::silero_model_bundle_path(); self.silero_vad_worker = crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path); - self.ten_vad_worker = None; if self.silero_vad_worker.is_none() { warn!( target: "chanora_audio", @@ -356,48 +405,43 @@ impl AndroidCaptureState { ); } } - crate::VadBackend::TenVad => { - let path = crate::vad::ten_model_bundle_path(); - self.ten_vad_worker = crate::vad::TenOnnxVadWorker::try_new(&path); - self.silero_vad_worker = None; - if self.ten_vad_worker.is_none() { - warn!( - target: "chanora_audio", - "android: TEN VAD ONNX model not found at {path}; falling back to WebRTC VAD" - ); - } - } _ => { self.silero_vad_worker = None; - self.ten_vad_worker = None; } } self.vad_state.reset(); } - self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1); - let capture_seq = self.capture_frame_seq; - let mut used_fallback_vad = false; - let vad = if vad_backend == crate::VadBackend::Disabled { - crate::vad::VadOutput { - probability: 1.0, - speech: true, - } - } else if vad_backend == crate::VadBackend::SileroOnnx { - if let Some(worker) = self.silero_vad_worker.as_ref() { - let enqueued = worker.try_send(capture_seq, &frame); - if !worker.is_stale(capture_seq) { - let p = worker.latest_probability(); - crate::vad::VadOutput { - probability: p, - speech: p >= 0.5, - } - } else if enqueued { - // Warm-up: worker dispatched but hasn't finished yet. - // Default to no-speech until first result arrives (~100ms). - crate::vad::VadOutput { - probability: 0.0, - speech: false, + let (vad_probability, active) = if voice_activity_mode { + self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1); + let capture_seq = self.capture_frame_seq; + let mut used_fallback_vad = false; + let vad = if vad_backend == crate::VadBackend::Disabled { + crate::vad::VadOutput { + probability: 1.0, + speech: true, + } + } else if vad_backend == crate::VadBackend::SileroOnnx { + if let Some(worker) = self.silero_vad_worker.as_ref() { + let enqueued = worker.try_send(capture_seq, &frame); + if !worker.is_stale(capture_seq) { + let p = worker.latest_probability(); + crate::vad::VadOutput { + probability: p, + speech: p >= 0.5, + } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } + } else { + used_fallback_vad = true; + self.mark_vad_fallback_active(vad_backend); + crate::vad::VoiceActivityDetector::process_10ms( + &mut self.vad_detector, + &frame, + ) } } else { used_fallback_vad = true; @@ -405,53 +449,28 @@ impl AndroidCaptureState { crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) } } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(vad_backend); crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } - } else if vad_backend == crate::VadBackend::TenVad { - if let Some(worker) = self.ten_vad_worker.as_ref() { - let enqueued = worker.try_send(capture_seq, &frame); - if !worker.is_stale(capture_seq) { - let p = worker.latest_probability(); - crate::vad::VadOutput { - probability: p, - speech: p >= 0.5, - } - } else if enqueued { - crate::vad::VadOutput { - probability: 0.0, - speech: false, - } - } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } - } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } + }; + self.audio_processing_stats + .set_vad_fallback_active(used_fallback_vad); + (vad.probability, self.vad_state.update(vad.speech)) } else { - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) + (0.0, false) }; - self.audio_processing_stats - .set_vad_fallback_active(used_fallback_vad); - let active = self.vad_state.update(vad.speech); - let output_muted = self.output_muted.load(Ordering::Relaxed); if let Some(sel) = &self.voice_activity_selector { - sel.set_voice_activity_open(active && !output_muted); + sel.set_voice_activity_open(voice_activity_mode && active); } self.audio_processing_stats.update_capture( input_dbfs, crate::frame::dbfs(&frame), - vad.probability, - active && !output_muted, + vad_probability, + voice_activity_mode && active, self.transmit_active.load(Ordering::Relaxed), ); + self.audio_processing_stats + .record_capture_frame(frame.iter().all(|sample| sample.abs() < 1.0e-6)); - if !self.transmit_active.load(Ordering::Relaxed) || output_muted { + if !self.transmit_active.load(Ordering::Relaxed) { return; } @@ -511,6 +530,9 @@ struct OutputCallback { event_tx: BackendEventTx, scratch: Arc>>, render_reference: Arc, + audio_processing_stats: Arc, + pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES], + pending_render_ref_len: usize, } impl AudioOutputCallback for OutputCallback { @@ -552,19 +574,20 @@ impl AudioOutputCallback for OutputCallback { gain, muted, ); + self.audio_processing_stats + .update_render(crate::frame::dbfs(&scratch[..needed]), frames.len() as u32); - // Write the first 10 ms of render audio into the reference - // buffer for the capture-side AEC. - let mono_n = needed / 2; - let render_n = mono_n.min(crate::frame::FRAME_10MS_SAMPLES); - let mut ref_frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES]; - for (i, chunk) in scratch[..render_n * 2].chunks_exact(2).enumerate() { - if i >= render_n { - break; + // Accumulate the full render callback into 10 ms mono chunks so + // AEC sees consistent reference timing even when output callbacks + // are shorter or longer than 10 ms. + for chunk in scratch[..needed].chunks_exact(2) { + self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5; + self.pending_render_ref_len += 1; + if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES { + self.render_reference.write(&self.pending_render_ref); + self.pending_render_ref_len = 0; } - ref_frame[i] = (chunk[0] + chunk[1]) * 0.5; } - self.render_reference.write(&ref_frame); })); DataCallbackResult::Continue } @@ -640,6 +663,7 @@ impl AndroidVoiceUnit { // Clone the APM config Arc before params is partially moved // into the capture state constructor below. let apm_config_clone = params.audio_processing_config.clone(); + let audio_processing_stats = params.audio_processing_stats.clone(); let capture_state = Arc::new(Mutex::new( AndroidCaptureState::new( @@ -650,8 +674,9 @@ impl AndroidVoiceUnit { params.mic_gain, params.voice_activity_selector, params.audio_processing_config, - params.audio_processing_stats, + audio_processing_stats.clone(), render_ref_for_capture, + cfg.sample_rate, ) .map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?, )); @@ -768,6 +793,9 @@ impl AndroidVoiceUnit { event_tx: event_tx.clone(), scratch: scratch.clone(), render_reference: render_ref_for_output, + audio_processing_stats: audio_processing_stats.clone(), + pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], + pending_render_ref_len: 0, }; let output_builder = output_builder.set_callback(output_cb); @@ -785,6 +813,7 @@ impl AndroidVoiceUnit { params.handler.clone(), params.output_gain.clone(), params.output_muted.clone(), + audio_processing_stats.clone(), scratch.clone(), render_ref_buf, )? @@ -869,6 +898,14 @@ impl AndroidVoiceUnit { "android: audio processing config resolved (hardware effects: aec={hw_aec} ns={hw_ns} agc={hw_agc})" ); } + if let Ok(mut capture) = capture_state.lock() { + capture.set_input_sample_rate_hz(input_sample_rate.max(1) as u32); + let resolved_cfg = apm_config_clone + .lock() + .map(|cfg| webrtc_apm_config_from_audio_config(&cfg)) + .unwrap_or_default(); + capture.webrtc_apm_processor.apply_config(resolved_cfg); + } // --- SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3 --- // Publish the diagnostics snapshot. Per-effect engagement is @@ -910,6 +947,9 @@ impl AndroidVoiceUnit { latency_tier: latency_tier_for(input_perf), }; publish_android_audio_diagnostics(diagnostics); + params + .audio_processing_stats + .set_actual_sample_rate_hz(input_sample_rate.max(0) as u32); Ok(Self { input: input_stream, @@ -1005,6 +1045,7 @@ impl AndroidVoiceUnit { handler: Arc>>, output_gain: Arc, output_muted: Arc, + audio_processing_stats: Arc, scratch: Arc>>, render_reference: Arc, ) -> Result, BackendError> { @@ -1015,6 +1056,9 @@ impl AndroidVoiceUnit { event_tx: event_tx.clone(), scratch, render_reference, + audio_processing_stats, + pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], + pending_render_ref_len: 0, }; let builder = AudioStreamBuilder::default() .set_direction::() @@ -1038,6 +1082,12 @@ impl AndroidVoiceUnit { } } +fn webrtc_apm_config_from_audio_config( + config: &crate::AudioProcessingConfig, +) -> crate::processor::webrtc_apm::WebRtcApmConfig { + crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(config) +} + impl MobileVoiceAudioBackend for AndroidVoiceUnit { fn start(&mut self) -> Result<(), BackendError> { if let Some(s) = self.input.as_mut() { diff --git a/crates/chanora_audio/src/audio_processing.rs b/crates/chanora_audio/src/audio_processing.rs index a0dddec..f7ec530 100644 --- a/crates/chanora_audio/src/audio_processing.rs +++ b/crates/chanora_audio/src/audio_processing.rs @@ -92,9 +92,6 @@ impl AudioBackend { pub enum VadBackend { /// Silero ONNX VAD. P1 schema default when model/runtime exist. SileroOnnx, - /// TEN VAD backend. Native TEN runtime is optional; unavailable - /// builds fall back to the realtime-safe WebRTC detector. - TenVad, /// WebRTC-style fallback VAD. WebrtcVad, /// Debug-only energy VAD. @@ -108,7 +105,6 @@ impl VadBackend { pub fn as_str(self) -> &'static str { match self { Self::SileroOnnx => "silero_vad_onnx", - Self::TenVad => "ten_vad", Self::WebrtcVad => "webrtc_vad", Self::EnergyDebug => "energy_debug", Self::Disabled => "disabled", @@ -168,7 +164,7 @@ impl Default for AudioProcessingConfig { route: AudioRoute::Speaker, ios_mode: IosVoiceProcessingMode::PlatformVoiceProcessing, processing_backend: AudioBackend::PlatformVoiceProcessing, - vad_backend: VadBackend::TenVad, + vad_backend: VadBackend::SileroOnnx, aec: EffectOwner::Platform, // iOS VPIO owns NS/AGC on the default shipping path. Software // effects are opt-in through the experimental raw route only. @@ -246,7 +242,7 @@ mod tests { assert_eq!(config.aec, EffectOwner::Platform); assert_eq!(config.ns, EffectOwner::Platform); assert_eq!(config.agc, EffectOwner::Platform); - assert_eq!(config.vad_backend, VadBackend::TenVad); + assert_eq!(config.vad_backend, VadBackend::SileroOnnx); } #[test] @@ -259,11 +255,6 @@ mod tests { assert!(config.validate_for_ios().is_err()); } - #[test] - fn ten_vad_has_stable_debug_string() { - assert_eq!(VadBackend::TenVad.as_str(), "ten_vad"); - } - #[test] fn raw_processing_allows_full_webrtc_apm_chain() { let config = AudioProcessingConfig { @@ -342,6 +333,16 @@ pub struct AudioProcessingStats { pub callback_xruns: u64, /// Clipped sample count. pub clipped_samples: u64, + /// Number of effectively silent processed capture frames. + pub zero_frames: u64, + /// Number of processed capture frames. + pub capture_frames: u64, + /// Number of input callbacks carrying 10 ms of audio. + pub callbacks_10ms: u64, + /// Number of input callbacks carrying 20 ms of audio. + pub callbacks_20ms: u64, + /// Number of input callbacks carrying any other size. + pub callbacks_other: u64, /// Sonora enabled. pub sonora_enabled: bool, /// Platform voice processing enabled. @@ -361,6 +362,11 @@ pub struct SharedAudioProcessingStats { output_underruns: AtomicU64, callback_xruns: AtomicU64, clipped_samples: AtomicU64, + zero_frames: AtomicU64, + capture_frames: AtomicU64, + callbacks_10ms: AtomicU64, + callbacks_20ms: AtomicU64, + callbacks_other: AtomicU64, actual_sample_rate_hz: AtomicU32, actual_io_buffer_frames: AtomicU32, } @@ -379,6 +385,11 @@ impl Default for SharedAudioProcessingStats { output_underruns: AtomicU64::new(0), callback_xruns: AtomicU64::new(0), clipped_samples: AtomicU64::new(0), + zero_frames: AtomicU64::new(0), + capture_frames: AtomicU64::new(0), + callbacks_10ms: AtomicU64::new(0), + callbacks_20ms: AtomicU64::new(0), + callbacks_other: AtomicU64::new(0), actual_sample_rate_hz: AtomicU32::new(crate::frame::SAMPLE_RATE_HZ), actual_io_buffer_frames: AtomicU32::new(crate::frame::FRAME_20MS_SAMPLES as u32), } @@ -412,6 +423,12 @@ impl SharedAudioProcessingStats { .store(io_buffer_frames, Ordering::Relaxed); } + /// Record the actual device sample rate. + pub fn set_actual_sample_rate_hz(&self, sample_rate_hz: u32) { + self.actual_sample_rate_hz + .store(sample_rate_hz, Ordering::Relaxed); + } + /// Increment output underrun count. pub fn increment_output_underrun(&self) { self.output_underruns.fetch_add(1, Ordering::Relaxed); @@ -427,6 +444,32 @@ impl SharedAudioProcessingStats { self.clipped_samples.fetch_add(count, Ordering::Relaxed); } + /// Record one processed capture frame and whether it was effectively silent. + pub fn record_capture_frame(&self, zero_frame: bool) { + self.capture_frames.fetch_add(1, Ordering::Relaxed); + if zero_frame { + self.zero_frames.fetch_add(1, Ordering::Relaxed); + } + } + + /// Bucket callback delivery sizes to diagnose timing jitter and packetization. + pub fn record_callback_frames(&self, frames: u64) { + let sample_rate_hz = self.actual_sample_rate_hz.load(Ordering::Relaxed).max(1); + let frames_10ms = (sample_rate_hz / 100) as u64; + let frames_20ms = (sample_rate_hz / 50) as u64; + match frames { + value if value == frames_10ms => { + self.callbacks_10ms.fetch_add(1, Ordering::Relaxed); + } + value if value == frames_20ms => { + self.callbacks_20ms.fetch_add(1, Ordering::Relaxed); + } + _ => { + self.callbacks_other.fetch_add(1, Ordering::Relaxed); + } + } + } + /// Store whether the selected VAD backend is currently using a fallback. pub fn set_vad_fallback_active(&self, active: bool) { self.vad_fallback_active.store(active, Ordering::Relaxed); @@ -452,6 +495,11 @@ impl SharedAudioProcessingStats { output_underruns: self.output_underruns.load(Ordering::Relaxed), callback_xruns: self.callback_xruns.load(Ordering::Relaxed), clipped_samples: self.clipped_samples.load(Ordering::Relaxed), + zero_frames: self.zero_frames.load(Ordering::Relaxed), + capture_frames: self.capture_frames.load(Ordering::Relaxed), + callbacks_10ms: self.callbacks_10ms.load(Ordering::Relaxed), + callbacks_20ms: self.callbacks_20ms.load(Ordering::Relaxed), + callbacks_other: self.callbacks_other.load(Ordering::Relaxed), sonora_enabled: config.processing_backend == AudioBackend::Sonora, platform_voice_processing_enabled: config.processing_backend == AudioBackend::PlatformVoiceProcessing, diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index c2882a2..376ee23 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -20,6 +20,18 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; not(target_os = "android") ))] use cpal::{SampleFormat, SizedSample}; +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] +use std::collections::hash_map::DefaultHasher; +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] +use std::hash::{Hash, Hasher}; use tokio::sync::mpsc; use tracing::{debug, info}; // `error!` and `warn!` are used only inside the cpal capture / @@ -80,12 +92,77 @@ pub struct AudioDeviceList { /// Info about a single audio device. #[derive(Debug, Clone)] pub struct AudioDeviceInfo { + /// Stable platform-reported device identifier. + pub id: String, /// Human-readable device name from the OS. pub name: String, + /// Additional device details useful for disambiguation. + pub details: String, /// True if the OS reports this as the default device. pub is_default: bool, } +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] +fn desktop_device_id(device: &cpal::Device) -> Option { + device.id().ok().map(|id| id.to_string()) +} + +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] +fn short_device_id(id: &str) -> String { + let mut hasher = DefaultHasher::new(); + id.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +#[cfg(all( + not(target_os = "ios"), + not(target_os = "macos"), + not(target_os = "android") +))] +fn describe_device(device: &cpal::Device) -> Option { + let id = desktop_device_id(device)?; + let description = device.description().ok(); + let name = description + .as_ref() + .map(|d| d.name().trim().to_owned()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("Device {}", &short_device_id(&id)[..8])); + + let mut details = Vec::new(); + if let Some(description) = description.as_ref() { + if let Some(manufacturer) = description.manufacturer() { + details.push(manufacturer.to_owned()); + } + if let Some(driver) = description.driver() { + details.push(driver.to_owned()); + } + let device_type = description.device_type(); + if device_type != cpal::device_description::DeviceType::Unknown { + details.push(format!("{device_type}")); + } + let interface_type = description.interface_type(); + if interface_type != cpal::device_description::InterfaceType::Unknown { + details.push(format!("{interface_type}")); + } + } + details.push(format!("id={}", short_device_id(&id))); + + Some(AudioDeviceInfo { + id, + name, + details: details.join(" · "), + is_default: false, + }) +} + /// Enumerate available audio input and output devices. /// On mobile platforms (iOS, Android) returns an empty list because /// device selection is managed by the OS audio session. @@ -100,38 +177,30 @@ pub fn list_audio_devices() -> AudioDeviceList { input_devices: Vec::new(), output_devices: Vec::new(), }; - let Ok(host) = cpal::default_host() else { - return list; - }; - let default_in = host.default_input_device(); - let default_out = host.default_output_device(); + let host = cpal::default_host(); + let default_in = host + .default_input_device() + .and_then(|device| desktop_device_id(&device)); + let default_out = host + .default_output_device() + .and_then(|device| desktop_device_id(&device)); if let Ok(devices) = host.input_devices() { for d in devices { - let name = d - .description() - .map(|n| n.name().to_owned()) - .unwrap_or_default(); - if !name.is_empty() { - let is_default = default_in + if let Some(mut device) = describe_device(&d) { + device.is_default = default_in .as_ref() - .is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str())); - list.input_devices - .push(AudioDeviceInfo { name, is_default }); + .is_some_and(|default_id| default_id == &device.id); + list.input_devices.push(device); } } } if let Ok(devices) = host.output_devices() { for d in devices { - let name = d - .description() - .map(|n| n.name().to_owned()) - .unwrap_or_default(); - if !name.is_empty() { - let is_default = default_out + if let Some(mut device) = describe_device(&d) { + device.is_default = default_out .as_ref() - .is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str())); - list.output_devices - .push(AudioDeviceInfo { name, is_default }); + .is_some_and(|default_id| default_id == &device.id); + list.output_devices.push(device); } } } @@ -172,12 +241,12 @@ pub struct AudioEngineConfig { /// is rejected on Android because the P0 path intentionally has /// no generic mobile-audio fallback. pub mobile_voice_preset: bool, - /// Optional input device name override. When `None`, the system - /// default input device is used. Set to a device name from + /// Optional input device id override. When `None`, the system + /// default input device is used. Set to a device id from /// [`list_audio_devices`] to pin a specific microphone. - pub input_device_name: Option, - /// Optional output device name override. - pub output_device_name: Option, + pub input_device_id: Option, + /// Optional output device id override. + pub output_device_id: Option, /// Optional selector used by P1 VoiceActivity to publish VAD state. #[doc(hidden)] pub voice_activity_selector: Option>, @@ -190,8 +259,8 @@ impl std::fmt::Debug for AudioEngineConfig { .field("ptt_initial", &self.ptt_initial) .field("effects", &self.effects) .field("mobile_voice_preset", &self.mobile_voice_preset) - .field("input_device_name", &self.input_device_name) - .field("output_device_name", &self.output_device_name) + .field("input_device_id", &self.input_device_id) + .field("output_device_id", &self.output_device_id) .field( "voice_activity_selector", &self.voice_activity_selector.as_ref().map(|_| "present"), @@ -207,8 +276,8 @@ impl Default for AudioEngineConfig { ptt_initial: false, effects: crate::AudioEffects::default(), mobile_voice_preset: true, - input_device_name: None, - output_device_name: None, + input_device_id: None, + output_device_id: None, voice_activity_selector: None, } } @@ -237,13 +306,13 @@ pub struct AudioEngine { audio_processing_config: Arc>, audio_processing_stats: Arc, audio_handler: Arc>>, - #[cfg(any(target_os = "ios", target_os = "macos"))] + #[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] voice_out_tx: mpsc::Sender, - #[cfg(any(target_os = "ios", target_os = "macos"))] + #[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] voice_activity_selector: Option>, - #[cfg(any(target_os = "ios", target_os = "macos"))] + #[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))] mic_gain: f32, - + #[cfg(any(target_os = "ios", target_os = "macos"))] // Streams must be dropped to stop audio. Both are `!Send` because // cpal's Stream isn't Send on some backends; we keep them in an // Option wrapped by Mutex so stop() can move them out. On Linux @@ -277,7 +346,11 @@ pub struct AudioEngine { /// unbinds effects and stops the service in SDD-115 reverse /// order. #[cfg(target_os = "android")] - _android_voice_unit: Mutex>, + _android_voice_unit: Arc>>, + /// Persist the Android stream request so route-change reopen uses the + /// same effect and latency policy as the original session start. + #[cfg(target_os = "android")] + android_voice_stream_config: Arc>, /// SDD-108 §1/§2: refcount-composable audio-mode controller. /// Snapshots `AudioManager.getMode()` on the 0 → 1 transition and /// restores it on the 1 → 0 transition. Held in a `Mutex` so the @@ -286,7 +359,7 @@ pub struct AudioEngine { /// mode lifecycle is bound to the voice-session lifecycle /// (SDD-108 §3). #[cfg(target_os = "android")] - audio_mode_stack: Mutex, + audio_mode_stack: Arc>, // Hand the inbound-voice forwarder task a shutdown signal. shutdown_tx: Option>, /// True if the capture stream actually opened. If false (typical @@ -385,6 +458,183 @@ fn open_ios_voice_backend( } impl AudioEngine { + #[cfg(target_os = "android")] + fn spawn_android_backend_event_task( + android_voice_unit: Arc>>, + android_voice_stream_config: Arc< + Mutex, + >, + voice_out_tx: mpsc::Sender, + transmit_gate: crate::ptt::AudioTransmitGate, + frames_sent: Arc, + audio_handler: Arc>>, + output_gain: Arc, + output_muted: Arc, + voice_activity_selector: Option>, + audio_processing_config: Arc>, + audio_processing_stats: Arc, + mic_gain: f32, + audio_mode_stack: Arc>, + mut event_rx: crate::mobile_voice_backend::BackendEventRx, + ) { + use crate::mobile_voice_backend::BackendEvent; + + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + match event { + BackendEvent::Disconnected => { + warn!(target: "chanora_audio", "android: backend disconnected; reopening voice unit"); + crate::android_voice_unit::chanora_android_stop_bluetooth_sco(); + crate::android_voice_unit::chanora_android_abandon_audio_focus(); + crate::android_voice_unit::clear_global_event_sender(); + + if let Some(mut unit) = android_voice_unit.lock().unwrap().take() { + use crate::mobile_voice_backend::MobileVoiceAudioBackend; + if let Err(e) = unit.close() { + warn!( + target: "chanora_audio", + error = %e, + "android: failed to close disconnected voice unit before reopen" + ); + } + } + + if crate::android_voice_unit::chanora_android_start_voice_service() { + info!( + target: "chanora_audio", + "android: voice foreground service restart dispatched after backend disconnect" + ); + } + + match android_get_audio_mode() { + Ok(current_mode) => { + if current_mode != ANDROID_MODE_IN_COMMUNICATION { + match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) { + Ok(()) => info!( + target: "chanora_audio", + prior_mode = current_mode, + "android: AudioManager mode re-engaged after backend disconnect" + ), + Err(e) => warn!( + target: "chanora_audio", + error = %e, + prior_mode = current_mode, + "android: failed to re-engage MODE_IN_COMMUNICATION after backend disconnect" + ), + } + } + } + Err(e) => warn!( + target: "chanora_audio", + error = %e, + "android: AudioManager.getMode failed during backend reopen" + ), + } + + let cfg_av = android_voice_stream_config.lock().unwrap().clone(); + let params = crate::mobile_voice_backend::VoiceAudioParams { + voice_out_tx: voice_out_tx.clone(), + transmit_active: transmit_gate.flag_arc(), + frames_sent: frames_sent.clone(), + mic_gain, + handler: audio_handler.clone(), + output_gain: output_gain.clone(), + output_muted: output_muted.clone(), + voice_activity_selector: voice_activity_selector.clone(), + audio_processing_config: audio_processing_config.clone(), + audio_processing_stats: audio_processing_stats.clone(), + }; + + match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) { + Ok(mut reopened) => { + use crate::mobile_voice_backend::MobileVoiceAudioBackend; + match reopened.start() { + Ok(()) => { + if let Some(next_rx) = reopened.take_event_rx() { + Self::spawn_android_backend_event_task( + android_voice_unit.clone(), + android_voice_stream_config.clone(), + voice_out_tx.clone(), + transmit_gate.clone(), + frames_sent.clone(), + audio_handler.clone(), + output_gain.clone(), + output_muted.clone(), + voice_activity_selector.clone(), + audio_processing_config.clone(), + audio_processing_stats.clone(), + mic_gain, + audio_mode_stack.clone(), + next_rx, + ); + } + crate::android_voice_unit::register_global_event_sender( + reopened.event_sender(), + ); + if crate::android_voice_unit::chanora_android_request_audio_focus() { + info!( + target: "chanora_audio", + "android: audio focus re-requested after backend disconnect" + ); + } + if crate::android_voice_unit::chanora_android_start_bluetooth_sco() { + info!( + target: "chanora_audio", + "android: bluetooth route re-engaged after backend disconnect" + ); + } + *android_voice_unit.lock().unwrap() = Some(reopened); + } + Err(e) => warn!( + target: "chanora_audio", + error = %e, + "android: reopened voice unit failed to start after backend disconnect" + ), + } + } + Err(e) => warn!( + target: "chanora_audio", + error = %e, + "android: failed to reopen voice unit after backend disconnect" + ), + } + } + BackendEvent::FocusLost => { + warn!( + target: "chanora_audio", + "android: audio focus lost permanently (SDD-109); engine should leave session" + ); + } + BackendEvent::FocusTransient => { + info!( + target: "chanora_audio", + "android: transient audio focus loss (SDD-109); pausing capture" + ); + } + BackendEvent::FocusTransientCanDuck => { + info!( + target: "chanora_audio", + "android: transient audio focus loss with ducking (SDD-109); continuing" + ); + } + BackendEvent::FocusGain => { + info!( + target: "chanora_audio", + "android: audio focus regained (SDD-109); resuming capture" + ); + } + BackendEvent::BluetoothScoStateChanged(s) => { + info!( + target: "chanora_audio", + sco_state = s, + "android: Bluetooth SCO state changed (SDD-110)" + ); + } + } + } + }); + } + /// Start the engine: open capture + playback streams, spawn the /// inbound-voice forwarder, return a handle. pub fn start( @@ -461,21 +711,22 @@ impl AudioEngine { "starting audio engine: cpal host selected" ); - /// Helper: find a device by name, falling back to default. - fn find_device( + /// Helper: find a device by stable id, falling back to default. + fn find_device( host: &cpal::Host, - default_fn: fn(&cpal::Host) -> Option, - all_fn: fn(&cpal::Host) -> Result, + default_fn: DefaultFn, + all_fn: AllFn, prefer: Option<&str>, - ) -> Option { - if let Some(name) = prefer { + ) -> Option + where + DefaultFn: Fn(&cpal::Host) -> Option, + AllFn: Fn(&cpal::Host) -> Result, + Devices: IntoIterator, + { + if let Some(id) = prefer { if let Ok(devices) = all_fn(host) { for d in devices { - let dn = d - .description() - .map(|n| n.name().to_owned()) - .unwrap_or_default(); - if dn == name { + if desktop_device_id(&d).as_deref() == Some(id) { return Some(d); } } @@ -488,7 +739,7 @@ impl AudioEngine { &host, cpal::Host::default_input_device, cpal::Host::input_devices, - cfg.input_device_name.as_deref(), + cfg.input_device_id.as_deref(), ) .ok_or(AudioError::NoInputDevice)?; @@ -496,7 +747,7 @@ impl AudioEngine { &host, cpal::Host::default_output_device, cpal::Host::output_devices, - cfg.output_device_name.as_deref(), + cfg.output_device_id.as_deref(), ) .ok_or(AudioError::NoOutputDevice)?; @@ -729,7 +980,7 @@ impl AudioEngine { mut voice_in_rx: mpsc::Receiver, transmit_gate: crate::ptt::AudioTransmitGate, ) -> Result { - use crate::mobile_voice_backend::{BackendEvent, MobileVoiceAudioBackend}; + use crate::mobile_voice_backend::MobileVoiceAudioBackend; info!(target: "chanora_audio", "starting audio engine: Android Oboe backend"); @@ -799,7 +1050,7 @@ impl AudioEngine { ..Default::default() }; let params = crate::mobile_voice_backend::VoiceAudioParams { - voice_out_tx, + voice_out_tx: voice_out_tx.clone(), transmit_active: transmit_flag_for_capture, frames_sent: frames_sent.clone(), mic_gain: cfg.mic_gain, @@ -821,53 +1072,42 @@ impl AudioEngine { ))); } - if let Some(mut event_rx) = android_voice_unit.take_event_rx() { - tokio::spawn(async move { - while let Some(event) = event_rx.recv().await { - match event { - BackendEvent::Disconnected => { - warn!( - target: "chanora_audio", - "android: backend disconnected event received; stream reconnect requires session restart" - ); - } - BackendEvent::FocusLost => { - warn!( - target: "chanora_audio", - "android: audio focus lost permanently (SDD-109); engine should leave session" - ); - } - BackendEvent::FocusTransient => { - info!( - target: "chanora_audio", - "android: transient audio focus loss (SDD-109); pausing capture" - ); - } - BackendEvent::FocusTransientCanDuck => { - info!( - target: "chanora_audio", - "android: transient audio focus loss with ducking (SDD-109); continuing" - ); - } - BackendEvent::FocusGain => { - info!( - target: "chanora_audio", - "android: audio focus regained (SDD-109); resuming capture" - ); - } - BackendEvent::BluetoothScoStateChanged(s) => { - info!( - target: "chanora_audio", - sco_state = s, - "android: Bluetooth SCO state changed (SDD-110)" - ); - } - } - } - }); + let android_voice_unit = Arc::new(Mutex::new(Some(android_voice_unit))); + let android_voice_stream_config = Arc::new(Mutex::new(cfg_av.clone())); + let audio_mode_stack = Arc::new(Mutex::new(audio_mode_stack)); + + if let Some(event_rx) = android_voice_unit + .lock() + .unwrap() + .as_mut() + .and_then(|unit| unit.take_event_rx()) + { + Self::spawn_android_backend_event_task( + android_voice_unit.clone(), + android_voice_stream_config.clone(), + voice_out_tx.clone(), + transmit_gate.clone(), + frames_sent.clone(), + audio_handler.clone(), + output_gain.clone(), + output_muted.clone(), + cfg.voice_activity_selector.clone(), + audio_processing_config.clone(), + audio_processing_stats.clone(), + cfg.mic_gain, + audio_mode_stack.clone(), + event_rx, + ); } - crate::android_voice_unit::register_global_event_sender(android_voice_unit.event_sender()); + crate::android_voice_unit::register_global_event_sender( + android_voice_unit + .lock() + .unwrap() + .as_ref() + .expect("android voice unit installed") + .event_sender(), + ); if crate::android_voice_unit::chanora_android_request_audio_focus() { info!( @@ -932,8 +1172,12 @@ impl AudioEngine { audio_processing_config, audio_processing_stats, audio_handler, - _android_voice_unit: Mutex::new(Some(android_voice_unit)), - audio_mode_stack: Mutex::new(audio_mode_stack), + voice_out_tx, + voice_activity_selector: cfg.voice_activity_selector.clone(), + mic_gain: cfg.mic_gain, + _android_voice_unit: android_voice_unit, + android_voice_stream_config, + audio_mode_stack, shutdown_tx: Some(shutdown_tx), capture_active, }) @@ -1182,6 +1426,96 @@ impl AudioEngine { } } + /// Android-only: reopen the underlying Oboe voice backend after a + /// route/device change while preserving the engine-owned state. + pub fn android_restart_voice_unit(&self) -> Result<(), AudioError> { + #[cfg(target_os = "android")] + { + use crate::mobile_voice_backend::MobileVoiceAudioBackend; + + crate::android_voice_unit::chanora_android_stop_bluetooth_sco(); + crate::android_voice_unit::chanora_android_abandon_audio_focus(); + crate::android_voice_unit::clear_global_event_sender(); + + if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() { + unit.close().map_err(|e| { + AudioError::Backend(format!( + "android: failed to close Oboe voice unit during route restart: {e}" + )) + })?; + } + + if crate::android_voice_unit::chanora_android_start_voice_service() { + info!( + target: "chanora_audio", + "android: voice foreground service restart dispatched after route change" + ); + } + + if crate::android_voice_unit::chanora_android_request_audio_focus() { + info!( + target: "chanora_audio", + "android: audio focus re-requested after route change" + ); + } + + let cfg_av = self.android_voice_stream_config.lock().unwrap().clone(); + let params = crate::mobile_voice_backend::VoiceAudioParams { + voice_out_tx: self.voice_out_tx.clone(), + transmit_active: self.transmit_gate.flag_arc(), + frames_sent: self.frames_sent.clone(), + mic_gain: self.mic_gain, + handler: self.audio_handler.clone(), + output_gain: self.output_gain.clone(), + output_muted: self.output_muted.clone(), + voice_activity_selector: self.voice_activity_selector.clone(), + audio_processing_config: self.audio_processing_config.clone(), + audio_processing_stats: self.audio_processing_stats.clone(), + }; + let mut reopened = crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) + .map_err(|e| { + AudioError::Backend(format!("android: failed to reopen Oboe voice unit: {e}")) + })?; + reopened.start().map_err(|e| { + AudioError::Backend(format!("android: failed to restart Oboe voice unit: {e}")) + })?; + if let Some(event_rx) = reopened.take_event_rx() { + Self::spawn_android_backend_event_task( + self._android_voice_unit.clone(), + self.android_voice_stream_config.clone(), + self.voice_out_tx.clone(), + self.transmit_gate.clone(), + self.frames_sent.clone(), + self.audio_handler.clone(), + self.output_gain.clone(), + self.output_muted.clone(), + self.voice_activity_selector.clone(), + self.audio_processing_config.clone(), + self.audio_processing_stats.clone(), + self.mic_gain, + self.audio_mode_stack.clone(), + event_rx, + ); + } + crate::android_voice_unit::register_global_event_sender(reopened.event_sender()); + + if crate::android_voice_unit::chanora_android_start_bluetooth_sco() { + info!( + target: "chanora_audio", + "android: bluetooth route re-engaged after route change" + ); + } + + let mut guard = self._android_voice_unit.lock().unwrap(); + *guard = Some(reopened); + Ok(()) + } + #[cfg(not(target_os = "android"))] + { + Ok(()) + } + } + /// iOS-only: pause the underlying VoiceProcessingIO unit. pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> { #[cfg(any(target_os = "ios", target_os = "macos"))] @@ -2086,7 +2420,7 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> { #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] #[doc(hidden)] pub mod bench_seam { - use super::{Arc, AtomicBool, AtomicU32, CaptureState, OpusEncoder, OutPacket}; + use super::{Arc, AtomicBool, AtomicU32, CaptureState, OutPacket}; use tokio::sync::mpsc; /// Opaque handle wrapping a CaptureState plus the dummy mpsc diff --git a/crates/chanora_audio/src/ios_raw_unit.rs b/crates/chanora_audio/src/ios_raw_unit.rs index f084544..46275e3 100644 --- a/crates/chanora_audio/src/ios_raw_unit.rs +++ b/crates/chanora_audio/src/ios_raw_unit.rs @@ -117,10 +117,8 @@ mod inner { voice_activity_selector: Option>, vad_detector: crate::vad::WebRtcFallbackVad, silero_vad_worker: Option, - ten_vad_worker: Option, current_vad_backend: crate::VadBackend, silero_model_epoch: u64, - ten_model_epoch: u64, capture_frame_seq: u64, vad_state: crate::voice_activity::VoiceActivityStateMachine, /// Processing config — retained for route-change reloads. @@ -157,10 +155,8 @@ mod inner { voice_activity_selector: params.voice_activity_selector.clone(), vad_detector: crate::vad::WebRtcFallbackVad::default(), silero_vad_worker: None, - ten_vad_worker: None, current_vad_backend: crate::VadBackend::WebrtcVad, silero_model_epoch: crate::vad::silero_model_epoch(), - ten_model_epoch: crate::vad::ten_model_epoch(), capture_frame_seq: 0, vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), audio_processing_config: params.audio_processing_config.clone(), @@ -274,6 +270,18 @@ mod inner { rec.push_processed_mic(&frame); } + let voice_activity_mode = self + .voice_activity_selector + .as_ref() + .map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity) + .unwrap_or(false); + if !voice_activity_mode { + self.silero_vad_worker = None; + self.current_vad_backend = crate::VadBackend::Disabled; + self.fallback_warned_backend = None; + self.audio_processing_stats.set_vad_fallback_active(false); + } + let (vad_backend, vad_hangover) = self .audio_processing_config .try_lock() @@ -282,30 +290,28 @@ mod inner { crate::VadBackend::WebrtcVad, crate::voice_activity::VAD_HANGOVER_MS, )); - self.vad_state.configure( - crate::voice_activity::VAD_OPEN_AFTER_MS, - vad_hangover, - crate::voice_activity::VAD_MIN_TX_MS, - ); + if voice_activity_mode { + self.vad_state.configure( + crate::voice_activity::VAD_OPEN_AFTER_MS, + vad_hangover, + crate::voice_activity::VAD_MIN_TX_MS, + ); + } - // Switch VAD backend when config changes. + // Switch VAD backend only while VoiceActivity mode is active. let silero_epoch = crate::vad::silero_model_epoch(); - let ten_epoch = crate::vad::ten_model_epoch(); - let silero_changed = vad_backend == crate::VadBackend::SileroOnnx + let silero_changed = voice_activity_mode + && vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch; - let ten_changed = - vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch; - if vad_backend != self.current_vad_backend || silero_changed || ten_changed { + if voice_activity_mode && (vad_backend != self.current_vad_backend || silero_changed) { self.current_vad_backend = vad_backend; self.silero_model_epoch = silero_epoch; - self.ten_model_epoch = ten_epoch; self.fallback_warned_backend = None; match vad_backend { crate::VadBackend::SileroOnnx => { let path = crate::vad::silero_model_bundle_path(); self.silero_vad_worker = crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path); - self.ten_vad_worker = None; if self.silero_vad_worker.is_none() { tracing::warn!( target: "chanora_audio", @@ -313,46 +319,43 @@ mod inner { ); } } - crate::VadBackend::TenVad => { - let path = crate::vad::ten_model_bundle_path(); - self.ten_vad_worker = crate::vad::TenOnnxVadWorker::try_new(&path); - self.silero_vad_worker = None; - if self.ten_vad_worker.is_none() { - tracing::warn!( - target: "chanora_audio", - "TEN VAD ONNX model not found at {path}; falling back to WebRTC VAD" - ); - } - } _ => { self.silero_vad_worker = None; - self.ten_vad_worker = None; } } self.vad_state.reset(); } - self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1); - let capture_seq = self.capture_frame_seq; - let mut used_fallback_vad = false; - let vad = if vad_backend == crate::VadBackend::Disabled { - crate::vad::VadOutput { - probability: 1.0, - speech: true, - } - } else if vad_backend == crate::VadBackend::SileroOnnx { - if let Some(worker) = self.silero_vad_worker.as_ref() { - let enqueued = worker.try_send(capture_seq, &frame); - if !worker.is_stale(capture_seq) { - let p = worker.latest_probability(); - crate::vad::VadOutput { - probability: p, - speech: p >= 0.5, - } - } else if enqueued { - crate::vad::VadOutput { - probability: 0.0, - speech: false, + let (vad_probability, active) = if voice_activity_mode { + self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1); + let capture_seq = self.capture_frame_seq; + let mut used_fallback_vad = false; + let vad = if vad_backend == crate::VadBackend::Disabled { + crate::vad::VadOutput { + probability: 1.0, + speech: true, + } + } else if vad_backend == crate::VadBackend::SileroOnnx { + if let Some(worker) = self.silero_vad_worker.as_ref() { + let enqueued = worker.try_send(capture_seq, &frame); + if !worker.is_stale(capture_seq) { + let p = worker.latest_probability(); + crate::vad::VadOutput { + probability: p, + speech: p >= 0.5, + } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } + } else { + used_fallback_vad = true; + self.mark_vad_fallback_active(vad_backend); + crate::vad::VoiceActivityDetector::process_10ms( + &mut self.vad_detector, + &frame, + ) } } else { used_fallback_vad = true; @@ -363,56 +366,26 @@ mod inner { ) } } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(vad_backend); crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } - } else if vad_backend == crate::VadBackend::TenVad { - if let Some(worker) = self.ten_vad_worker.as_ref() { - let enqueued = worker.try_send(capture_seq, &frame); - if !worker.is_stale(capture_seq) { - let p = worker.latest_probability(); - crate::vad::VadOutput { - probability: p, - speech: p >= 0.5, - } - } else if enqueued { - crate::vad::VadOutput { - probability: 0.0, - speech: false, - } - } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms( - &mut self.vad_detector, - &frame, - ) - } - } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(vad_backend); - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } + }; + self.audio_processing_stats + .set_vad_fallback_active(used_fallback_vad); + (vad.probability, self.vad_state.update(vad.speech)) } else { - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) + (0.0, false) }; - self.audio_processing_stats - .set_vad_fallback_active(used_fallback_vad); - let active = self.vad_state.update(vad.speech); - let output_muted = self.output_muted.load(Ordering::Relaxed); if let Some(sel) = &self.voice_activity_selector { - sel.set_voice_activity_open(active && !output_muted); + sel.set_voice_activity_open(voice_activity_mode && active); } self.audio_processing_stats.update_capture( input_dbfs, crate::frame::dbfs(&frame), - vad.probability, - active && !output_muted, + vad_probability, + voice_activity_mode && active, self.transmit_active.load(Ordering::Relaxed), ); - if !self.transmit_active.load(Ordering::Relaxed) || output_muted { + if !self.transmit_active.load(Ordering::Relaxed) { return; } diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index d1dc79d..b36769b 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -146,13 +146,10 @@ struct IosCaptureState { /// Background Silero worker — enqueues frames off the realtime /// callback and publishes the latest probability atomically. silero_vad_worker: Option, - ten_vad: Option, /// Last VAD backend we configured — used to detect backend changes. current_vad_backend: crate::VadBackend, /// Last observed configured Silero model epoch. silero_model_epoch: u64, - /// Last observed configured TEN model epoch. - ten_model_epoch: u64, fallback_warned_backend: Option, vad_state: crate::voice_activity::VoiceActivityStateMachine, audio_processing_config: Arc>, @@ -191,10 +188,8 @@ impl IosCaptureState { voice_activity_selector: params.voice_activity_selector.clone(), vad_detector: crate::vad::WebRtcFallbackVad::default(), silero_vad_worker: None, - ten_vad: None, current_vad_backend: crate::VadBackend::WebrtcVad, silero_model_epoch: crate::vad::silero_model_epoch(), - ten_model_epoch: crate::vad::ten_model_epoch(), fallback_warned_backend: None, vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), audio_processing_config: params.audio_processing_config.clone(), @@ -361,13 +356,23 @@ impl IosCaptureState { crate::AudioBackend::PlatformVoiceProcessing, )); - // Switch VAD backend when the config changes. + let voice_activity_mode = self + .voice_activity_selector + .as_ref() + .map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity) + .unwrap_or(false); + if !voice_activity_mode { + self.silero_vad_worker = None; + self.current_vad_backend = crate::VadBackend::Disabled; + self.fallback_warned_backend = None; + self.audio_processing_stats.set_vad_fallback_active(false); + } + + // Switch VAD backend only while VoiceActivity mode is active. let silero_model_epoch = crate::vad::silero_model_epoch(); - let ten_model_epoch = crate::vad::ten_model_epoch(); - let silero_model_changed = vad_backend == crate::VadBackend::SileroOnnx + let silero_model_changed = voice_activity_mode + && vad_backend == crate::VadBackend::SileroOnnx && silero_model_epoch != self.silero_model_epoch; - let ten_model_changed = - vad_backend == crate::VadBackend::TenVad && ten_model_epoch != self.ten_model_epoch; if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() { if debug_wav_dump_enabled { @@ -382,11 +387,11 @@ impl IosCaptureState { } } - if vad_backend != self.current_vad_backend || silero_model_changed || ten_model_changed { + if voice_activity_mode && (vad_backend != self.current_vad_backend || silero_model_changed) + { self.current_vad_backend = vad_backend; self.fallback_warned_backend = None; self.silero_model_epoch = silero_model_epoch; - self.ten_model_epoch = ten_model_epoch; match vad_backend { crate::VadBackend::SileroOnnx => { // Attempt to load Silero model from the well-known @@ -407,23 +412,8 @@ impl IosCaptureState { self.audio_processing_stats .set_vad_fallback_active(self.silero_vad_worker.is_none()); } - crate::VadBackend::TenVad => { - self.silero_vad_worker = None; - let model_path = crate::vad::ten_model_bundle_path(); - self.ten_vad = crate::vad::TenOnnxVadWorker::try_new(&model_path); - if self.ten_vad.is_none() { - warn!( - target: "chanora_audio", - "TEN VAD ONNX model not available at {model_path}; falling back to WebRTC VAD" - ); - self.mark_vad_fallback_active(crate::VadBackend::TenVad); - } - self.audio_processing_stats - .set_vad_fallback_active(self.ten_vad.is_none()); - } _ => { self.silero_vad_worker = None; - self.ten_vad = None; self.audio_processing_stats.set_vad_fallback_active(false); } } @@ -436,13 +426,16 @@ impl IosCaptureState { self.vad_state.reset(); } - // Keep the VAD state machine aligned with the active config. - self.vad_state.configure( - crate::voice_activity::VAD_OPEN_AFTER_MS, - vad_hangover, - crate::voice_activity::VAD_MIN_TX_MS, - ); let transmit_active = self.transmit_active.load(Ordering::Relaxed); + if voice_activity_mode { + // Keep the VAD state machine aligned with the active config only + // while VoiceActivity mode owns the transmit gate. + self.vad_state.configure( + crate::voice_activity::VAD_OPEN_AFTER_MS, + vad_hangover, + crate::voice_activity::VAD_MIN_TX_MS, + ); + } // VPIO owns AEC. Keep the legacy non-AEC conditioning path here until // the raw WebRTC APM path is explicitly selected. @@ -461,29 +454,37 @@ impl IosCaptureState { self.sonora_processor.process_capture(&mut frame); } - // VAD: use Silero if loaded, otherwise WebRTC fallback. - // Disabled backend → always open (Continuous-like for VAD mode). - self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1); - let capture_seq = self.capture_frame_seq; - let mut used_fallback_vad = false; - let vad = if vad_backend == crate::VadBackend::Disabled { - crate::vad::VadOutput { - probability: 1.0, - speech: true, - } - } else if vad_backend == crate::VadBackend::SileroOnnx { - if let Some(worker) = self.silero_vad_worker.as_ref() { - let enqueued = worker.try_send(capture_seq, &frame); - if !worker.is_stale(capture_seq) { - let probability = worker.latest_probability(); - crate::vad::VadOutput { - probability, - speech: probability >= 0.5, - } - } else if enqueued { - crate::vad::VadOutput { - probability: 0.0, - speech: false, + // VAD: only evaluate while VoiceActivity mode is active. + let (vad_probability, gate_open) = if voice_activity_mode { + self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1); + let capture_seq = self.capture_frame_seq; + let mut used_fallback_vad = false; + let vad = if vad_backend == crate::VadBackend::Disabled { + crate::vad::VadOutput { + probability: 1.0, + speech: true, + } + } else if vad_backend == crate::VadBackend::SileroOnnx { + if let Some(worker) = self.silero_vad_worker.as_ref() { + let enqueued = worker.try_send(capture_seq, &frame); + if !worker.is_stale(capture_seq) { + let probability = worker.latest_probability(); + crate::vad::VadOutput { + probability, + speech: probability >= 0.5, + } + } else if enqueued { + crate::vad::VadOutput { + probability: 0.0, + speech: false, + } + } else { + used_fallback_vad = true; + self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx); + crate::vad::VoiceActivityDetector::process_10ms( + &mut self.vad_detector, + &frame, + ) } } else { used_fallback_vad = true; @@ -491,49 +492,23 @@ impl IosCaptureState { crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) } } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx); crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } - } else if vad_backend == crate::VadBackend::TenVad { - if let Some(worker) = self.ten_vad.as_ref() { - let enqueued = worker.try_send(capture_seq, &frame); - if !worker.is_stale(capture_seq) { - let probability = worker.latest_probability(); - crate::vad::VadOutput { - probability, - speech: probability >= 0.5, - } - } else if enqueued { - crate::vad::VadOutput { - probability: 0.0, - speech: false, - } - } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(crate::VadBackend::TenVad); - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } - } else { - used_fallback_vad = true; - self.mark_vad_fallback_active(crate::VadBackend::TenVad); - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) - } + }; + self.audio_processing_stats + .set_vad_fallback_active(used_fallback_vad); + (vad.probability, self.vad_state.update(vad.speech)) } else { - crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) + (0.0, false) }; - self.audio_processing_stats - .set_vad_fallback_active(used_fallback_vad); - let gate_open = self.vad_state.update(vad.speech); let output_muted = self.output_muted.load(Ordering::Relaxed); if let Some(selector) = &self.voice_activity_selector { - selector.set_voice_activity_open(gate_open && !output_muted); + selector.set_voice_activity_open(voice_activity_mode && gate_open && !output_muted); } self.audio_processing_stats.update_capture( input_dbfs, crate::frame::dbfs(&frame), - vad.probability, - gate_open && !output_muted, + vad_probability, + voice_activity_mode && gate_open && !output_muted, transmit_active, ); diff --git a/crates/chanora_audio/src/mobile_voice_backend.rs b/crates/chanora_audio/src/mobile_voice_backend.rs index 303c80f..8a352cd 100644 --- a/crates/chanora_audio/src/mobile_voice_backend.rs +++ b/crates/chanora_audio/src/mobile_voice_backend.rs @@ -203,8 +203,8 @@ impl Default for AndroidVoiceStreamConfig { Self { sample_rate: 48_000, channel_count: 1, - request_low_latency: true, - request_exclusive: true, + request_low_latency: false, + request_exclusive: false, effects: AudioEffects::default(), } } @@ -488,7 +488,7 @@ impl fmt::Display for AchievedInputPreset { #[derive(Debug, Clone)] pub struct AndroidAudioDiagnostics { // Requested side (SDD-112 items 4..7) — fixed by SDD-112. - /// Requested performance mode (always "LowLatency" in P0). + /// Requested performance mode for the current Android request profile. pub requested_performance_mode: &'static str, /// Requested output usage (always "VoiceCommunication" in P0). pub requested_usage: &'static str, @@ -496,7 +496,7 @@ pub struct AndroidAudioDiagnostics { pub requested_content_type: &'static str, /// Requested input preset (always "VoiceCommunication" first). pub requested_input_preset: &'static str, - /// Requested sharing mode (always "Exclusive" first). + /// Requested sharing mode for the current Android request profile. pub requested_sharing_mode: &'static str, /// Requested sample rate (Hz). pub requested_sample_rate_hz: u32, @@ -640,15 +640,15 @@ pub fn current_android_audio_diagnostics() -> Option { mod tests { use super::*; - /// SWE4-UV-047: default config records requested low-latency, - /// exclusive sharing, mono 48 kHz, and default effects. + /// SWE4-UV-047: default config records the conservative shared + /// voice-input request profile, mono 48 kHz, and default effects. #[test] fn swe4_uv_047_default_config_records_requested_values() { let cfg = AndroidVoiceStreamConfig::default(); assert_eq!(cfg.sample_rate, 48_000); assert_eq!(cfg.channel_count, 1); - assert!(cfg.request_low_latency); - assert!(cfg.request_exclusive); + assert!(!cfg.request_low_latency); + assert!(!cfg.request_exclusive); // AudioEffects defaults are all-on per DEC-007..010. assert!(cfg.effects.aec); assert!(cfg.effects.noise_suppression); @@ -702,7 +702,9 @@ mod tests { ); } - /// SWE4-UV-049: sharing-mode ladder is Exclusive -> Shared -> exhausted. + /// SWE4-UV-049: sharing-mode ladder remains Exclusive -> Shared -> exhausted. + /// The default request profile may start at Shared, but the ladder still + /// exists for explicitly opt-in low-latency / exclusive experiments. #[test] fn swe4_uv_049_sharing_mode_fallback_ladder_order() { assert_eq!( diff --git a/crates/chanora_audio/src/vad/mod.rs b/crates/chanora_audio/src/vad/mod.rs index f68df9c..db878d9 100644 --- a/crates/chanora_audio/src/vad/mod.rs +++ b/crates/chanora_audio/src/vad/mod.rs @@ -7,7 +7,6 @@ pub mod resampler; pub mod silero_onnx; -pub mod ten_onnx; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{OnceLock, RwLock}; @@ -17,7 +16,6 @@ use crate::AudioError; use resampler::{Downsampler48to16, INPUT_FRAME_10MS}; pub use silero_onnx::SileroOnnxVad; -pub use ten_onnx::{TenOnnxVad, TenOnnxVadWorker}; /// Voice activity detector output for one 10 ms frame. #[derive(Debug, Clone, Copy)] @@ -108,17 +106,11 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16 static SILERO_MODEL_PATH_OVERRIDE: OnceLock>> = OnceLock::new(); static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0); -static TEN_MODEL_PATH_OVERRIDE: OnceLock>> = OnceLock::new(); -static TEN_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0); fn silero_model_path_override() -> &'static RwLock> { SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None)) } -fn ten_model_path_override() -> &'static RwLock> { - TEN_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None)) -} - /// Configure the preferred Silero ONNX model path. /// /// The path is validated eagerly. A successful call increments the @@ -149,32 +141,6 @@ pub fn silero_model_epoch() -> u64 { SILERO_MODEL_EPOCH.load(Ordering::Relaxed) } -/// Configure the preferred TEN VAD ONNX model path. -pub fn set_ten_model_path(path: &str) -> Result<(), AudioError> { - let path = path.trim(); - if path.is_empty() { - return Err(AudioError::InvalidAudioProcessingConfig( - "ten vad model path must not be empty".to_string(), - )); - } - if !std::path::Path::new(path).is_file() { - return Err(AudioError::InvalidAudioProcessingConfig(format!( - "ten vad model path does not exist or is not a file: {path}" - ))); - } - let mut guard = ten_model_path_override() - .write() - .map_err(|_| AudioError::Backend("ten vad model path lock poisoned".to_string()))?; - *guard = Some(path.to_string()); - TEN_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed); - Ok(()) -} - -/// Monotonic counter incremented whenever the configured TEN model path changes. -pub fn ten_model_epoch() -> u64 { - TEN_MODEL_EPOCH.load(Ordering::Relaxed) -} - /// Return the expected path of the Silero VAD v6 ONNX model. /// The model is shipped as a Flutter asset and copied to the app's /// data directory by the Dart-side asset loader. @@ -231,46 +197,6 @@ pub fn silero_model_bundle_path() -> String { } } -/// Return the expected path of the TEN VAD ONNX model copied by Flutter. -pub fn ten_model_bundle_path() -> String { - if let Ok(guard) = ten_model_path_override().read() { - if let Some(path) = guard.as_ref() { - return path.clone(); - } - } - - #[cfg(any(target_os = "ios", target_os = "macos"))] - { - if let Ok(home) = std::env::var("HOME") { - let docs = format!("{home}/Documents/ten_vad.onnx"); - if std::path::Path::new(&docs).exists() { - return docs; - } - let bundle = format!("{home}/../Library/ten_vad.onnx"); - if std::path::Path::new(&bundle).exists() { - return bundle; - } - } - "ten_vad.onnx".to_string() - } - - #[cfg(target_os = "android")] - { - "ten_vad.onnx".to_string() - } - - #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] - { - if let Ok(cwd) = std::env::current_dir() { - let local = cwd.join("ten_vad.onnx"); - if local.exists() { - return local.to_string_lossy().to_string(); - } - } - "ten_vad.onnx".to_string() - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/chanora_audio/src/vad/ten_onnx.rs b/crates/chanora_audio/src/vad/ten_onnx.rs deleted file mode 100644 index b63a778..0000000 --- a/crates/chanora_audio/src/vad/ten_onnx.rs +++ /dev/null @@ -1,452 +0,0 @@ -//! TEN VAD ONNX backend. -//! -//! TEN's ONNX graph does not accept raw PCM. It expects the same feature -//! stack produced by TEN's `AUP_Aed_aivad_proc`: three context frames of -//! 40 log-mel powers plus one pitch feature, followed by four recurrent -//! state tensors. This module ports that preprocessing path to Rust and -//! keeps ONNX Runtime off the realtime callback where possible. - -use crate::frame::f32_to_i16; - -use rustfft::{num_complex::Complex32, FftPlanner}; - -use super::resampler::{Downsampler48to16, INPUT_FRAME_10MS}; -use super::{VadOutput, VoiceActivityDetector}; - -const SAMPLE_RATE_16K: f32 = 16_000.0; -const HOP_16K: usize = 256; -const WINDOW_16K: usize = 768; -const FFT_SIZE: usize = 1024; -const N_BINS: usize = FFT_SIZE / 2 + 1; -const MEL_BANDS: usize = 40; -const FEATURE_LEN: usize = 41; -const CONTEXT: usize = 3; -const HIDDEN: usize = 64; -const POWER_NORMALIZER: f32 = 32768.0 * 32768.0; -const EPS: f32 = 1.0e-20; - -const FEATURE_MEANS: [f32; FEATURE_LEN] = [ - -8.198236, -6.2657166, -5.4838185, -4.7586913, -4.417089, -4.142893, -3.9128504, -3.845928, - -3.6570904, -3.7234187, -3.8761342, -3.843891, -3.6904051, -3.7560658, -3.6986961, -3.650463, - -3.7004688, -3.5673213, -3.4989002, -3.477807, -3.458816, -3.4449239, -3.4013286, -3.3062613, - -3.2785568, -3.2332509, -3.198616, -3.2045264, -3.2087986, -3.257838, -3.3813767, -3.5340214, - -3.640868, -3.7268589, -3.773731, -3.8046672, -3.832901, -3.8711205, -3.990593, -4.4802895, - 92.3569, -]; - -const FEATURE_STDS: [f32; FEATURE_LEN] = [ - 5.166064, 4.9772096, 4.698896, 4.6306214, 4.634348, 4.641156, 4.6406765, 4.666367, 4.6505346, - 4.640021, 4.6374, 4.620099, 4.5963163, 4.562655, 4.5543604, 4.5669107, 4.56249, 4.5624127, - 4.5852995, 4.6001797, 4.592846, 4.5859227, 4.5834966, 4.626093, 4.626958, 4.6262894, 4.637006, - 4.683016, 4.726814, 4.7342896, 4.753227, 4.849723, 4.869435, 4.884483, 4.921327, 4.9592123, - 4.996619, 5.0448236, 5.072217, 5.0964394, 115.21369, -]; - -/// TEN VAD using ONNX Runtime and Rust-ported TEN feature preprocessing. -pub struct TenOnnxVad { - session: ort::session::Session, - downsampler: Downsampler48to16, - hop_accum: Vec, - sample_fifo: Vec, - feature_stack: [[f32; FEATURE_LEN]; CONTEXT], - states: [[f32; HIDDEN]; 4], - mel_filters: Vec<[f32; N_BINS]>, - fft: std::sync::Arc>, - fft_buffer: Vec, - last_probability: f32, - last_speech: bool, -} - -unsafe impl Send for TenOnnxVad {} - -impl TenOnnxVad { - /// Load TEN VAD ONNX model. - pub fn try_new(model_path: &str) -> Option { - if !std::path::Path::new(model_path).exists() { - tracing::warn!(target: "chanora_audio", path = model_path, "TEN VAD ONNX model not found"); - return None; - } - let session = match std::panic::catch_unwind(|| { - ort::session::Session::builder().and_then(|mut b| b.commit_from_file(model_path)) - }) { - Ok(Ok(session)) => session, - Ok(Err(error)) => { - tracing::warn!(target: "chanora_audio", %error, path = model_path, "TEN VAD ONNX model load failed"); - return None; - } - Err(_) => { - tracing::warn!(target: "chanora_audio", path = model_path, "TEN VAD ONNX Runtime panicked during load"); - return None; - } - }; - let mut fft_planner = FftPlanner::::new(); - let fft = fft_planner.plan_fft_forward(FFT_SIZE); - tracing::info!(target: "chanora_audio", path = model_path, "TEN VAD ONNX model loaded"); - Some(Self { - session, - downsampler: Downsampler48to16::default(), - hop_accum: Vec::with_capacity(HOP_16K + super::resampler::OUTPUT_FRAME_10MS), - sample_fifo: Vec::with_capacity(WINDOW_16K + HOP_16K), - feature_stack: [[0.0; FEATURE_LEN]; CONTEXT], - states: [[0.0; HIDDEN]; 4], - mel_filters: build_mel_filters(), - fft, - fft_buffer: vec![Complex32::ZERO; FFT_SIZE], - last_probability: 0.0, - last_speech: false, - }) - } - - fn process_hop(&mut self, hop: &[f32]) { - self.sample_fifo.extend_from_slice(hop); - let frame = if self.sample_fifo.len() >= WINDOW_16K { - let start = self.sample_fifo.len() - WINDOW_16K; - self.sample_fifo[start..].to_vec() - } else { - let mut padded = vec![0.0; WINDOW_16K - self.sample_fifo.len()]; - padded.extend_from_slice(&self.sample_fifo); - padded - }; - if self.sample_fifo.len() > WINDOW_16K { - let excess = self.sample_fifo.len() - WINDOW_16K; - self.sample_fifo.drain(..excess); - } - - let feature = compute_feature( - &self.mel_filters, - self.fft.as_ref(), - &mut self.fft_buffer, - &frame, - ); - self.feature_stack.copy_within(1..CONTEXT, 0); - self.feature_stack[CONTEXT - 1] = feature; - self.run_onnx(); - } - - fn run_onnx(&mut self) { - use ndarray::{Array, IxDyn}; - use ort::value::Value; - - let input: Vec = self.feature_stack.iter().flatten().copied().collect(); - let input_arr = match Array::from_shape_vec(IxDyn(&[1, CONTEXT, FEATURE_LEN]), input) { - Ok(v) => v, - Err(_) => return, - }; - let state_arrs = [0, 1, 2, 3] - .map(|idx| Array::from_shape_vec(IxDyn(&[1, HIDDEN]), self.states[idx].to_vec())); - let input_val = match Value::from_array(input_arr) { - Ok(v) => v, - Err(error) => { - tracing::warn!(target: "chanora_audio", %error, "TEN VAD input tensor error"); - return; - } - }; - let state_vals = match state_arrs { - [Ok(a), Ok(b), Ok(c), Ok(d)] => [a, b, c, d], - _ => return, - }; - let state_vals = match state_vals.map(Value::from_array) { - [Ok(a), Ok(b), Ok(c), Ok(d)] => [a, b, c, d], - _ => return, - }; - - let outputs = match self.session.run([ - (&input_val).into(), - (&state_vals[0]).into(), - (&state_vals[1]).into(), - (&state_vals[2]).into(), - (&state_vals[3]).into(), - ]) { - Ok(outputs) => outputs, - Err(error) => { - tracing::warn!(target: "chanora_audio", %error, "TEN VAD ONNX inference failed"); - return; - } - }; - - if let Ok((_, prob)) = outputs["output_1"].try_extract_tensor::() { - if let Some(&p) = prob.first() { - self.last_probability = p.clamp(0.0, 1.0); - self.last_speech = self.last_probability >= 0.5; - } - } - for (idx, name) in ["output_2", "output_3", "output_6", "output_7"] - .iter() - .enumerate() - { - if let Ok((_, state)) = outputs[*name].try_extract_tensor::() { - let copy_len = state.len().min(HIDDEN); - self.states[idx][..copy_len].copy_from_slice(&state[..copy_len]); - } - } - } -} - -fn compute_feature( - mel_filters: &[[f32; N_BINS]], - fft: &dyn rustfft::Fft, - fft_buffer: &mut [Complex32], - frame: &[f32], -) -> [f32; FEATURE_LEN] { - let power = power_spectrum(fft, fft_buffer, frame); - let mut feature = [0.0; FEATURE_LEN]; - for band in 0..MEL_BANDS { - let energy = mel_filters[band] - .iter() - .zip(power.iter()) - .map(|(w, p)| w * p) - .sum::() - / POWER_NORMALIZER; - let log_energy = (energy + EPS).ln(); - feature[band] = (log_energy - FEATURE_MEANS[band]) / (FEATURE_STDS[band] + EPS); - } - let pitch_hz = estimate_pitch_hz(frame); - feature[MEL_BANDS] = (pitch_hz - FEATURE_MEANS[MEL_BANDS]) / (FEATURE_STDS[MEL_BANDS] + EPS); - feature -} - -impl VoiceActivityDetector for TenOnnxVad { - fn process_10ms(&mut self, samples: &[f32]) -> VadOutput { - debug_assert_eq!(samples.len(), INPUT_FRAME_10MS); - let mut input = [0.0_f32; INPUT_FRAME_10MS]; - input.copy_from_slice(samples); - let downsampled = self.downsampler.process_frame_10ms(&input); - self.hop_accum.extend_from_slice(&downsampled); - while self.hop_accum.len() >= HOP_16K { - let hop: Vec = self.hop_accum[..HOP_16K].to_vec(); - self.hop_accum.drain(..HOP_16K); - self.process_hop(&hop); - } - VadOutput { - probability: self.last_probability, - speech: self.last_speech, - } - } -} - -fn hz_to_mel(hz: f32) -> f32 { - 2595.0 * (1.0 + hz / 700.0).log10() -} - -fn mel_to_hz(mel: f32) -> f32 { - 700.0 * (10.0_f32.powf(mel / 2595.0) - 1.0) -} - -fn build_mel_filters() -> Vec<[f32; N_BINS]> { - let low_mel = hz_to_mel(0.0); - let high_mel = hz_to_mel(8000.0); - let mut bins = [0_usize; MEL_BANDS + 2]; - for idx in 0..bins.len() { - let mel = idx as f32 * (high_mel - low_mel) / (MEL_BANDS as f32 + 1.0) + low_mel; - let hz = mel_to_hz(mel); - let mut bin = ((FFT_SIZE as f32 + 1.0) * hz / SAMPLE_RATE_16K).floor() as usize; - bin = bin.min(N_BINS - 1); - if idx > 0 && bin == bins[idx - 1] { - bin = (bin + 1).min(N_BINS - 1); - } - bins[idx] = bin; - } - - let mut filters = vec![[0.0_f32; N_BINS]; MEL_BANDS]; - for band in 0..MEL_BANDS { - let left = bins[band]; - let center = bins[band + 1].max(left + 1); - let right = bins[band + 2].max(center + 1).min(N_BINS - 1); - for (i, weight) in filters[band] - .iter_mut() - .enumerate() - .take(center.min(N_BINS)) - .skip(left) - { - *weight = (i - left) as f32 / (center - left) as f32; - } - for (i, weight) in filters[band] - .iter_mut() - .enumerate() - .take(right + 1) - .skip(center) - { - *weight = (right - i) as f32 / (right - center).max(1) as f32; - } - } - filters -} - -fn power_spectrum( - fft: &dyn rustfft::Fft, - fft_buffer: &mut [Complex32], - frame: &[f32], -) -> [f32; N_BINS] { - debug_assert_eq!(fft_buffer.len(), FFT_SIZE); - fft_buffer.fill(Complex32::ZERO); - for (idx, sample) in frame.iter().take(WINDOW_16K).enumerate() { - let hann = 0.5 - 0.5 * (2.0 * std::f32::consts::PI * idx as f32 / WINDOW_16K as f32).cos(); - fft_buffer[idx].re = f32_to_i16(*sample) as f32 * hann; - } - - fft.process(fft_buffer); - - let mut out = [0.0_f32; N_BINS]; - for (dst, bin) in out.iter_mut().zip(fft_buffer.iter()) { - *dst = bin.norm_sqr(); - } - out -} - -fn estimate_pitch_hz(frame: &[f32]) -> f32 { - let min_lag = (SAMPLE_RATE_16K / 400.0) as usize; - let max_lag = (SAMPLE_RATE_16K / 60.0) as usize; - let mut best_lag = 0_usize; - let mut best_corr = 0.0_f32; - for lag in min_lag..=max_lag.min(frame.len().saturating_sub(1)) { - let mut corr = 0.0_f32; - let mut energy = 0.0_f32; - for i in lag..frame.len() { - corr += frame[i] * frame[i - lag]; - energy += frame[i - lag] * frame[i - lag]; - } - let norm = if energy > 1.0e-8 { - corr / energy.sqrt() - } else { - 0.0 - }; - if norm > best_corr { - best_corr = norm; - best_lag = lag; - } - } - if best_lag == 0 || best_corr < 0.01 { - 0.0 - } else { - SAMPLE_RATE_16K / best_lag as f32 - } -} - -// --------------------------------------------------------------------------- -// Background worker — same pattern as SileroOnnxVadWorker so the realtime -// callback never blocks on STFT / pitch / ONNX inference. -// --------------------------------------------------------------------------- - -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64}; -use std::sync::Arc; -use std::thread::JoinHandle; - -/// Maximum number of 10 ms frames the worker may lag before the callback -/// treats its output as stale and uses WebRTC fallback instead. -const TEN_MAX_STALE_FRAMES: u64 = 8; - -struct TenFrameMessage { - seq: u64, - frame: [f32; INPUT_FRAME_10MS], -} - -/// Background TEN VAD worker. The realtime callback only enqueues 10 ms -/// frames and reads the latest probability atomically. -pub struct TenOnnxVadWorker { - tx: Option>, - latest_probability: Arc, - latest_processed_seq: Arc, - alive: Arc, - handle: Option>, -} - -impl TenOnnxVadWorker { - /// Start a background TEN worker if the model loads. - pub fn try_new(model_path: &str) -> Option { - let vad = TenOnnxVad::try_new(model_path)?; - let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits())); - let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX)); - let alive = Arc::new(AtomicBool::new(true)); - let (tx, rx) = std::sync::mpsc::sync_channel::(128); - let prob_arc = latest_probability.clone(); - let seq_arc = latest_processed_seq.clone(); - let alive_arc = alive.clone(); - - let handle = std::thread::Builder::new() - .name("chanora-ten-vad".to_string()) - .spawn(move || { - let mut vad = vad; - while alive_arc.load(std::sync::atomic::Ordering::Relaxed) { - let msg = match rx.recv() { - Ok(m) => m, - Err(_) => break, - }; - let mut frame_f32 = [0.0_f32; INPUT_FRAME_10MS]; - frame_f32.copy_from_slice(&msg.frame); - let out = VoiceActivityDetector::process_10ms(&mut vad, &frame_f32); - prob_arc.store( - out.probability.clamp(0.0, 1.0).to_bits(), - std::sync::atomic::Ordering::Relaxed, - ); - seq_arc.store(msg.seq, std::sync::atomic::Ordering::Relaxed); - } - }) - .ok()?; - - Some(Self { - tx: Some(tx), - latest_probability, - latest_processed_seq, - alive, - handle: Some(handle), - }) - } - - /// Best-effort enqueue of a 10 ms frame for background inference. - pub fn try_send(&self, seq: u64, frame: &[f32; INPUT_FRAME_10MS]) -> bool { - let Some(tx) = &self.tx else { - return false; - }; - tx.try_send(TenFrameMessage { seq, frame: *frame }).is_ok() - } - - /// Latest probability published by the background worker. - pub fn latest_probability(&self) -> f32 { - f32::from_bits( - self.latest_probability - .load(std::sync::atomic::Ordering::Relaxed), - ) - } - - /// True when the worker is too far behind to trust its output. - pub fn is_stale(&self, capture_seq: u64) -> bool { - let latest = self - .latest_processed_seq - .load(std::sync::atomic::Ordering::Relaxed); - latest == u64::MAX || capture_seq.saturating_sub(latest) > TEN_MAX_STALE_FRAMES - } -} - -impl Drop for TenOnnxVadWorker { - fn drop(&mut self) { - self.alive - .store(false, std::sync::atomic::Ordering::Relaxed); - drop(self.tx.take()); - if let Some(h) = self.handle.take() { - let _ = h.join(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mel_filter_bank_has_expected_shape() { - let filters = build_mel_filters(); - assert_eq!(filters.len(), MEL_BANDS); - assert!(filters.iter().all(|f| f.iter().any(|&v| v > 0.0))); - } - - #[test] - fn preprocessing_produces_finite_features() { - let filters = build_mel_filters(); - let mut planner = FftPlanner::::new(); - let fft = planner.plan_fft_forward(FFT_SIZE); - let mut fft_buffer = vec![Complex32::ZERO; FFT_SIZE]; - let frame = vec![0.0_f32; WINDOW_16K]; - let feature = compute_feature(&filters, fft.as_ref(), &mut fft_buffer, &frame); - assert!(feature.iter().all(|v| v.is_finite())); - } -} diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 5f25c6a..611c6ab 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -145,7 +145,7 @@ pub(crate) fn publish_permission_state(permission: String, state: PermissionStat /// flood the diagnostic export during transient packet loss; /// users can still raise verbosity via `RUST_LOG=info`. const DEFAULT_LOG_FILTER: &str = - "info,tsproto::resend=error,tsproto::packet_codec=error,tsclientlib=error"; + "info,tsproto::resend=error,tsproto::packet_codec=error,tsclientlib=error,ts_bookkeeping::messages::s2c=error"; /// Initialise the bridge. Must be called once on Dart side before /// any other API call. Sets up panic logging. @@ -921,8 +921,6 @@ pub enum BridgeAudioBackend { pub enum BridgeVadBackend { /// Silero ONNX VAD. SileroOnnx, - /// TEN VAD. - TenVad, /// WebRTC fallback VAD. WebrtcVad, /// Debug energy VAD. @@ -1014,6 +1012,16 @@ pub struct BridgeAudioProcessingStats { pub callback_xruns: u64, /// Clipped samples. pub clipped_samples: u64, + /// Number of effectively silent processed capture frames. + pub zero_frames: u64, + /// Number of processed capture frames. + pub capture_frames: u64, + /// Input callbacks carrying 10 ms of audio. + pub callbacks_10ms: u64, + /// Input callbacks carrying 20 ms of audio. + pub callbacks_20ms: u64, + /// Input callbacks carrying other sizes. + pub callbacks_other: u64, /// Sonora enabled. pub sonora_enabled: bool, /// Platform voice processing enabled. @@ -1092,7 +1100,6 @@ impl From for chanora_core::VadBackend { fn from(backend: BridgeVadBackend) -> Self { match backend { BridgeVadBackend::SileroOnnx => Self::SileroOnnx, - BridgeVadBackend::TenVad => Self::TenVad, BridgeVadBackend::WebrtcVad => Self::WebrtcVad, BridgeVadBackend::EnergyDebug => Self::EnergyDebug, BridgeVadBackend::Disabled => Self::Disabled, @@ -1104,7 +1111,6 @@ impl From for BridgeVadBackend { fn from(backend: chanora_core::VadBackend) -> Self { match backend { chanora_core::VadBackend::SileroOnnx => Self::SileroOnnx, - chanora_core::VadBackend::TenVad => Self::TenVad, chanora_core::VadBackend::WebrtcVad => Self::WebrtcVad, chanora_core::VadBackend::EnergyDebug => Self::EnergyDebug, chanora_core::VadBackend::Disabled => Self::Disabled, @@ -1196,6 +1202,11 @@ impl From for BridgeAudioProcessingStats { output_underruns: stats.output_underruns, callback_xruns: stats.callback_xruns, clipped_samples: stats.clipped_samples, + zero_frames: stats.zero_frames, + capture_frames: stats.capture_frames, + callbacks_10ms: stats.callbacks_10ms, + callbacks_20ms: stats.callbacks_20ms, + callbacks_other: stats.callbacks_other, sonora_enabled: stats.sonora_enabled, platform_voice_processing_enabled: stats.platform_voice_processing_enabled, } @@ -1236,8 +1247,31 @@ pub fn export_diagnostics() -> String { let android_audio_yaml = chanora_audio::mobile_voice_backend::current_android_audio_diagnostics() .map(|d| d.to_yaml_fragment()); + let audio_health = runtime().block_on(async { session().audio_processing_stats().await.ok() }); let network_info = runtime().block_on(async { session().network_diagnostics_summary().await }); let protocol_events = runtime().block_on(async { session().drain_protocol_events().await }); + let android_audio_yaml = android_audio_yaml.map(|mut yaml| { + if let Some(stats) = audio_health { + yaml.push_str(&format!( + " health:\n\ + \x20\x20\x20\x20capture_frames: {}\n\ + \x20\x20\x20\x20zero_frames: {}\n\ + \x20\x20\x20\x20callbacks_10ms: {}\n\ + \x20\x20\x20\x20callbacks_20ms: {}\n\ + \x20\x20\x20\x20callbacks_other: {}\n\ + \x20\x20\x20\x20callback_xruns: {}\n\ + \x20\x20\x20\x20clipped_samples: {}\n", + stats.capture_frames, + stats.zero_frames, + stats.callbacks_10ms, + stats.callbacks_20ms, + stats.callbacks_other, + stats.callback_xruns, + stats.clipped_samples, + )); + } + yaml + }); match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) { Ok(exp) => exp .with_android_audio(android_audio_yaml) @@ -1506,6 +1540,11 @@ pub enum BridgeEvent { /// Target scope (server/channel/private/poke). target: BridgeMessageTarget, }, + /// Human-readable server activity surfaced from protocol bookkeeping events. + ServerActivity { + /// TeamSpeak-style activity line. + message: String, + }, /// Audio route changed (speaker/earpiece/BT/wired). AudioRouteChanged { /// The new audio route. @@ -1692,6 +1731,9 @@ impl From for BridgeEvent { message, target: target.into(), }, + chanora_core::SessionEvent::ServerActivity { message } => { + BridgeEvent::ServerActivity { message } + } chanora_core::SessionEvent::AudioRouteChanged { route } => { BridgeEvent::AudioRouteChanged { route: route.into(), @@ -1848,10 +1890,16 @@ pub async fn audio_processing_stats() -> Result BridgeAudioDeviceList { - let list = chanora_audio::list_audio_devices(); - BridgeAudioDeviceList { +pub async fn list_audio_devices() -> Result { + let (list, selected_input, selected_output) = runtime() + .spawn(async move { + let selected_input = session().preferred_input_device().await; + let selected_output = session().preferred_output_device().await; + let list = chanora_audio::list_audio_devices(); + (list, selected_input, selected_output) + }) + .await + .map_err(|e| task_join_error("list_audio_devices", e))?; + + Ok(BridgeAudioDeviceList { input_devices: list .input_devices .into_iter() .map(|d| BridgeAudioDevice { + is_selected: selected_input.as_ref().is_some_and(|id| id == &d.id), + id: d.id, name: d.name, + details: d.details, is_default: d.is_default, }) .collect(), @@ -1879,27 +1939,30 @@ pub fn list_audio_devices() -> BridgeAudioDeviceList { .output_devices .into_iter() .map(|d| BridgeAudioDevice { + is_selected: selected_output.as_ref().is_some_and(|id| id == &d.id), + id: d.id, name: d.name, + details: d.details, is_default: d.is_default, }) .collect(), - } + }) } -/// Set the preferred input device by name. Takes effect on next +/// Set the preferred input device by id. Takes effect on next /// `start_audio`. -pub async fn set_input_device(name: Option) -> Result<(), BridgeError> { +pub async fn set_input_device(id: Option) -> Result<(), BridgeError> { runtime() - .spawn(async move { session().set_input_device(name).await }) + .spawn(async move { session().set_input_device(id).await }) .await .map_err(|e| task_join_error("set_input_device", e))??; Ok(()) } -/// Set the preferred output device by name. -pub async fn set_output_device(name: Option) -> Result<(), BridgeError> { +/// Set the preferred output device by id. +pub async fn set_output_device(id: Option) -> Result<(), BridgeError> { runtime() - .spawn(async move { session().set_output_device(name).await }) + .spawn(async move { session().set_output_device(id).await }) .await .map_err(|e| task_join_error("set_output_device", e))??; Ok(()) @@ -1919,21 +1982,6 @@ pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> { Ok(()) } -/// Configure the TEN VAD ONNX model path. -pub async fn set_ten_vad_model_path(path: String) -> Result<(), BridgeError> { - if path.trim().is_empty() { - return Err(BridgeError::InvalidCommand( - "ten vad model path must not be empty".to_string(), - )); - } - runtime() - .spawn(async move { chanora_audio::vad::set_ten_model_path(&path) }) - .await - .map_err(|e| task_join_error("set_ten_vad_model_path", e))? - .map_err(|e| BridgeError::Unmapped(format!("set_ten_vad_model_path: {e}")))?; - Ok(()) -} - /// Enable or disable audio debug WAV dumping. pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeError> { runtime() diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 66e5e75..62c3299 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1433826599; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -560177922; // Section: executor @@ -743,7 +743,7 @@ fn wire__crate__api__list_audio_devices_impl( rust_vec_len_: i32, data_len_: i32, ) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { debug_name: "list_audio_devices", port: Some(port_), @@ -760,11 +760,14 @@ fn wire__crate__api__list_audio_devices_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); deserializer.end(); - move |context| { - transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::list_audio_devices())?; - Ok(output_ok) - })()) + move |context| async move { + transform_result_sse::<_, crate::BridgeError>( + (move || async move { + let output_ok = crate::api::list_audio_devices().await?; + Ok(output_ok) + })() + .await, + ) } }, ) @@ -1141,12 +1144,12 @@ fn wire__crate__api__set_input_device_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_name = >::sse_decode(&mut deserializer); + let api_id = >::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, crate::BridgeError>( (move || async move { - let output_ok = crate::api::set_input_device(api_name).await?; + let output_ok = crate::api::set_input_device(api_id).await?; Ok(output_ok) })() .await, @@ -1282,12 +1285,12 @@ fn wire__crate__api__set_output_device_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_name = >::sse_decode(&mut deserializer); + let api_id = >::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, crate::BridgeError>( (move || async move { - let output_ok = crate::api::set_output_device(api_name).await?; + let output_ok = crate::api::set_output_device(api_id).await?; Ok(output_ok) })() .await, @@ -1478,42 +1481,6 @@ fn wire__crate__api__set_release_tail_ms_impl( }, ) } -fn wire__crate__api__set_ten_vad_model_path_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "set_ten_vad_model_path", - port: Some(port_), - mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_path = ::sse_decode(&mut deserializer); - deserializer.end(); - move |context| async move { - transform_result_sse::<_, crate::BridgeError>( - (move || async move { - let output_ok = crate::api::set_ten_vad_model_path(api_path).await?; - Ok(output_ok) - })() - .await, - ) - } - }, - ) -} fn wire__crate__api__set_transmit_mode_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1783,11 +1750,17 @@ impl SseDecode for crate::api::BridgeAudioBackend { impl SseDecode for crate::api::BridgeAudioDevice { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_id = ::sse_decode(deserializer); let mut var_name = ::sse_decode(deserializer); + let mut var_details = ::sse_decode(deserializer); let mut var_isDefault = ::sse_decode(deserializer); + let mut var_isSelected = ::sse_decode(deserializer); return crate::api::BridgeAudioDevice { + id: var_id, name: var_name, + details: var_details, is_default: var_isDefault, + is_selected: var_isSelected, }; } } @@ -1859,6 +1832,11 @@ impl SseDecode for crate::api::BridgeAudioProcessingStats { let mut var_outputUnderruns = ::sse_decode(deserializer); let mut var_callbackXruns = ::sse_decode(deserializer); let mut var_clippedSamples = ::sse_decode(deserializer); + let mut var_zeroFrames = ::sse_decode(deserializer); + let mut var_captureFrames = ::sse_decode(deserializer); + let mut var_callbacks10Ms = ::sse_decode(deserializer); + let mut var_callbacks20Ms = ::sse_decode(deserializer); + let mut var_callbacksOther = ::sse_decode(deserializer); let mut var_sonoraEnabled = ::sse_decode(deserializer); let mut var_platformVoiceProcessingEnabled = ::sse_decode(deserializer); return crate::api::BridgeAudioProcessingStats { @@ -1879,6 +1857,11 @@ impl SseDecode for crate::api::BridgeAudioProcessingStats { output_underruns: var_outputUnderruns, callback_xruns: var_callbackXruns, clipped_samples: var_clippedSamples, + zero_frames: var_zeroFrames, + capture_frames: var_captureFrames, + callbacks_10ms: var_callbacks10Ms, + callbacks_20ms: var_callbacks20Ms, + callbacks_other: var_callbacksOther, sonora_enabled: var_sonoraEnabled, platform_voice_processing_enabled: var_platformVoiceProcessingEnabled, }; @@ -2147,6 +2130,12 @@ impl SseDecode for crate::api::BridgeEvent { }; } 12 => { + let mut var_message = ::sse_decode(deserializer); + return crate::api::BridgeEvent::ServerActivity { + message: var_message, + }; + } + 13 => { let mut var_route = ::sse_decode(deserializer); return crate::api::BridgeEvent::AudioRouteChanged { route: var_route }; } @@ -2291,10 +2280,9 @@ impl SseDecode for crate::api::BridgeVadBackend { let mut inner = ::sse_decode(deserializer); return match inner { 0 => crate::api::BridgeVadBackend::SileroOnnx, - 1 => crate::api::BridgeVadBackend::TenVad, - 2 => crate::api::BridgeVadBackend::WebrtcVad, - 3 => crate::api::BridgeVadBackend::EnergyDebug, - 4 => crate::api::BridgeVadBackend::Disabled, + 1 => crate::api::BridgeVadBackend::WebrtcVad, + 2 => crate::api::BridgeVadBackend::EnergyDebug, + 3 => crate::api::BridgeVadBackend::Disabled, _ => unreachable!("Invalid variant for BridgeVadBackend: {}", inner), }; } @@ -2544,13 +2532,12 @@ fn pde_ffi_dispatcher_primary_impl( 39 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), 40 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), 41 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -2609,8 +2596,11 @@ impl flutter_rust_bridge::IntoIntoDart impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioDevice { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ + self.id.into_into_dart().into_dart(), self.name.into_into_dart().into_dart(), + self.details.into_into_dart().into_dart(), self.is_default.into_into_dart().into_dart(), + self.is_selected.into_into_dart().into_dart(), ] .into_dart() } @@ -2697,6 +2687,11 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingStats { self.output_underruns.into_into_dart().into_dart(), self.callback_xruns.into_into_dart().into_dart(), self.clipped_samples.into_into_dart().into_dart(), + self.zero_frames.into_into_dart().into_dart(), + self.capture_frames.into_into_dart().into_dart(), + self.callbacks_10ms.into_into_dart().into_dart(), + self.callbacks_20ms.into_into_dart().into_dart(), + self.callbacks_other.into_into_dart().into_dart(), self.sonora_enabled.into_into_dart().into_dart(), self.platform_voice_processing_enabled .into_into_dart() @@ -2973,8 +2968,11 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent { target.into_into_dart().into_dart(), ] .into_dart(), + crate::api::BridgeEvent::ServerActivity { message } => { + [12.into_dart(), message.into_into_dart().into_dart()].into_dart() + } crate::api::BridgeEvent::AudioRouteChanged { route } => { - [12.into_dart(), route.into_into_dart().into_dart()].into_dart() + [13.into_dart(), route.into_into_dart().into_dart()].into_dart() } _ => { unimplemented!(""); @@ -3170,10 +3168,9 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeVadBackend { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { Self::SileroOnnx => 0.into_dart(), - Self::TenVad => 1.into_dart(), - Self::WebrtcVad => 2.into_dart(), - Self::EnergyDebug => 3.into_dart(), - Self::Disabled => 4.into_dart(), + Self::WebrtcVad => 1.into_dart(), + Self::EnergyDebug => 2.into_dart(), + Self::Disabled => 3.into_dart(), _ => unreachable!(), } } @@ -3313,8 +3310,11 @@ impl SseEncode for crate::api::BridgeAudioBackend { impl SseEncode for crate::api::BridgeAudioDevice { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.id, serializer); ::sse_encode(self.name, serializer); + ::sse_encode(self.details, serializer); ::sse_encode(self.is_default, serializer); + ::sse_encode(self.is_selected, serializer); } } @@ -3368,6 +3368,11 @@ impl SseEncode for crate::api::BridgeAudioProcessingStats { ::sse_encode(self.output_underruns, serializer); ::sse_encode(self.callback_xruns, serializer); ::sse_encode(self.clipped_samples, serializer); + ::sse_encode(self.zero_frames, serializer); + ::sse_encode(self.capture_frames, serializer); + ::sse_encode(self.callbacks_10ms, serializer); + ::sse_encode(self.callbacks_20ms, serializer); + ::sse_encode(self.callbacks_other, serializer); ::sse_encode(self.sonora_enabled, serializer); ::sse_encode(self.platform_voice_processing_enabled, serializer); } @@ -3595,8 +3600,12 @@ impl SseEncode for crate::api::BridgeEvent { ::sse_encode(message, serializer); ::sse_encode(target, serializer); } - crate::api::BridgeEvent::AudioRouteChanged { route } => { + crate::api::BridgeEvent::ServerActivity { message } => { ::sse_encode(12, serializer); + ::sse_encode(message, serializer); + } + crate::api::BridgeEvent::AudioRouteChanged { route } => { + ::sse_encode(13, serializer); ::sse_encode(route, serializer); } _ => { @@ -3734,10 +3743,9 @@ impl SseEncode for crate::api::BridgeVadBackend { ::sse_encode( match self { crate::api::BridgeVadBackend::SileroOnnx => 0, - crate::api::BridgeVadBackend::TenVad => 1, - crate::api::BridgeVadBackend::WebrtcVad => 2, - crate::api::BridgeVadBackend::EnergyDebug => 3, - crate::api::BridgeVadBackend::Disabled => 4, + crate::api::BridgeVadBackend::WebrtcVad => 1, + crate::api::BridgeVadBackend::EnergyDebug => 2, + crate::api::BridgeVadBackend::Disabled => 3, _ => { unimplemented!(""); } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 928a0c9..04f51ab 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -659,17 +659,11 @@ async fn connection_task( .. } = &ev { - let own_client = con - .get_state() - .ok() - .map(|state| state.own_client); + let own_client = con.get_state().ok().map(|state| state.own_client); if own_client == Some(*client_id) { - let current_channel = con - .get_state() - .ok() - .and_then(|state| { - state.clients.get(client_id).map(|client| client.channel.0) - }); + let current_channel = con.get_state().ok().and_then(|state| { + state.clients.get(client_id).map(|client| client.channel.0) + }); if let Some(current_channel) = current_channel { let matched: Vec = pending_moves .iter() @@ -682,7 +676,9 @@ async fn connection_task( }) .collect(); for handle in matched { - if let Some((_, reply, _)) = pending_moves.remove(&handle) { + if let Some((_, reply, _)) = + pending_moves.remove(&handle) + { info!( target: "chanora_protocol", channel_id = current_channel, @@ -727,7 +723,9 @@ async fn connection_task( } } StreamItem::MessageResult(handle, result) => { - if let Some((_target_channel, reply, _deadline)) = pending_moves.remove(&handle) { + if let Some((_target_channel, reply, _deadline)) = + pending_moves.remove(&handle) + { let mapped = match result { Ok(()) => Ok(()), Err(cmd_err) => { @@ -814,36 +812,32 @@ async fn connection_task( channel_id, password, reply, - }) => { - match move_self_to(&mut con, channel_id, password.as_deref()) { - Ok(handle) => { - let deadline = std::time::Instant::now() + Duration::from_secs(3); - pending_moves.insert(handle, (channel_id, Some(reply), deadline)); - } - Err(e) => { - let _ = reply.send(Err(e)); - } + }) => match move_self_to(&mut con, channel_id, password.as_deref()) { + Ok(handle) => { + let deadline = std::time::Instant::now() + Duration::from_secs(3); + pending_moves.insert(handle, (channel_id, Some(reply), deadline)); } - } + Err(e) => { + let _ = reply.send(Err(e)); + } + }, Ok(Request::MoveToChannelNoWait { channel_id, password, - }) => { - match move_self_to(&mut con, channel_id, password.as_deref()) { - Ok(handle) => { - let deadline = std::time::Instant::now() + Duration::from_secs(3); - pending_moves.insert(handle, (channel_id, None, deadline)); - } - Err(e) => { - warn!( - target: "chanora_protocol", - error = %e, - channel_id, - "fire-and-forget client_move could not be queued" - ); - } + }) => match move_self_to(&mut con, channel_id, password.as_deref()) { + Ok(handle) => { + let deadline = std::time::Instant::now() + Duration::from_secs(3); + pending_moves.insert(handle, (channel_id, None, deadline)); } - } + Err(e) => { + warn!( + target: "chanora_protocol", + error = %e, + channel_id, + "fire-and-forget client_move could not be queued" + ); + } + }, Ok(Request::SetMuted { input, output, @@ -1261,10 +1255,7 @@ fn activity_channel_group_name( .map(|group| quoted(&group.name)) } -fn activity_server_group_name( - con: &Connection, - id: tsclientlib::ServerGroupId, -) -> Option { +fn activity_server_group_name(con: &Connection, id: tsclientlib::ServerGroupId) -> Option { con.get_state() .ok() .and_then(|state| state.server_groups.get(&id)) @@ -1282,7 +1273,11 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) -> use tsproto_types::Reason; match ev { - Event::PropertyAdded { id: PropertyId::Client(client_id), extra, .. } => { + Event::PropertyAdded { + id: PropertyId::Client(client_id), + extra, + .. + } => { if extra.reason.is_none() { return None; } @@ -1294,7 +1289,12 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) -> channel )) } - Event::PropertyRemoved { id: PropertyId::Client(_), old, extra, .. } => { + Event::PropertyRemoved { + id: PropertyId::Client(_), + old, + extra, + .. + } => { let PropertyValue::Client(client) = old else { return None; }; @@ -1305,12 +1305,9 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) -> Some(Reason::Clientdisconnect) => { Some(format!("{} disconnected (Leaving)", quoted(&client.name))) } - Some(Reason::ClientdisconnectServerShutdown) | Some(Reason::Serverstop) => { - Some(format!( - "{} disconnected (server shutdown)", - quoted(&client.name) - )) - } + Some(Reason::ClientdisconnectServerShutdown) | Some(Reason::Serverstop) => Some( + format!("{} disconnected (server shutdown)", quoted(&client.name)), + ), _ => Some(format!( "{} dropped (connection lost)", quoted(&client.name) @@ -1329,8 +1326,7 @@ fn format_server_activity(con: &Connection, ev: &tsclientlib::events::Event) -> let client = activity_client(con, *client_id)?; let from = activity_channel_name(con, *from_channel_id)?; let to = activity_channel_name(con, client.channel)?; - if invoker.as_ref().map(|invoker| invoker.id) == Some(*client_id) || invoker.is_none() - { + if invoker.as_ref().map(|invoker| invoker.id) == Some(*client_id) || invoker.is_none() { Some(format!( "{} switched from channel {} to {}", quoted(&client.name),