feat: stabilize voice activity and audio routing
This commit is contained in:
+25
-4
@@ -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<out AudioDeviceInfo>) {
|
||||
@@ -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
|
||||
|
||||
+20
@@ -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()}"
|
||||
|
||||
|
||||
+127
-15
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -51,146 +51,147 @@ List<PlatformCapability> currentPlatformCapabilities() {
|
||||
}
|
||||
|
||||
List<PlatformCapability> _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<PlatformCapability> _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<PlatformCapability> _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<PlatformCapability> _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<PlatformCapability> _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,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -76,12 +76,18 @@ String _kAppVersion = appSemverBaseline;
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await RustLib.init();
|
||||
_kAppVersion = await resolveAppVersion();
|
||||
unawaited(wireStorage());
|
||||
unawaited(wireConnectivity());
|
||||
wireAudioLifecycle();
|
||||
await configureBundledVadModels();
|
||||
runApp(const ChanoraApp());
|
||||
unawaited(_finishDeferredStartup());
|
||||
}
|
||||
|
||||
Future<void> _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<rust.BridgeEvent>? _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<rust.BridgeBookmark> _bookmarks = const [];
|
||||
final List<ChatEntry> _chatMessages = [];
|
||||
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
||||
int _chatUnread = 0;
|
||||
bool _chatOpen = false;
|
||||
final ValueNotifier<List<_ReceivedPoke>> _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<void> _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<void> _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<void> _onRefresh() async {
|
||||
await _refreshSnapshot(recordActivity: true, reportErrors: true);
|
||||
}
|
||||
|
||||
Future<void> _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 <ChatEntry>[];
|
||||
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<void> _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<void> _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,
|
||||
),
|
||||
|
||||
@@ -270,23 +270,19 @@ Future<BridgeAudioProcessingStats> audioProcessingStats() =>
|
||||
Future<BridgeAudioDeviceList> 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<void> setInputDevice({String? name}) =>
|
||||
RustLib.instance.api.crateApiSetInputDevice(name: name);
|
||||
Future<void> setInputDevice({String? id}) =>
|
||||
RustLib.instance.api.crateApiSetInputDevice(id: id);
|
||||
|
||||
/// Set the preferred output device by name.
|
||||
Future<void> setOutputDevice({String? name}) =>
|
||||
RustLib.instance.api.crateApiSetOutputDevice(name: name);
|
||||
/// Set the preferred output device by id.
|
||||
Future<void> setOutputDevice({String? id}) =>
|
||||
RustLib.instance.api.crateApiSetOutputDevice(id: id);
|
||||
|
||||
/// Configure the VAD model path.
|
||||
Future<void> setVadModelPath({required String path}) =>
|
||||
RustLib.instance.api.crateApiSetVadModelPath(path: path);
|
||||
|
||||
/// Configure the TEN VAD ONNX model path.
|
||||
Future<void> setTenVadModelPath({required String path}) =>
|
||||
RustLib.instance.api.crateApiSetTenVadModelPath(path: path);
|
||||
|
||||
/// Enable or disable audio debug WAV dumping.
|
||||
Future<void> 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,
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent {
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({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 extends Object?>({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<TResult extends Object?>({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<TResult extends Object?>({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 extends Object?>({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 extends Object?>({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 extends Object?>({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 extends Object?>({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<TResult extends Object?>({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<TResult extends Object?>({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 extends Object?>({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 extends Object?>({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<BridgeEvent_ServerActivity> get copyWith => _$BridgeEvent_ServerActivityCopyWithImpl<BridgeEvent_ServerActivity>(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._();
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
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<void> crateApiSetHardMute({required bool muted});
|
||||
|
||||
Future<void> crateApiSetInputDevice({String? name});
|
||||
Future<void> crateApiSetInputDevice({String? id});
|
||||
|
||||
Future<void> crateApiSetInputMuted({required bool muted});
|
||||
|
||||
@@ -166,7 +166,7 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
void crateApiSetNetworkState({required BridgeNetworkState state});
|
||||
|
||||
Future<void> crateApiSetOutputDevice({String? name});
|
||||
Future<void> crateApiSetOutputDevice({String? id});
|
||||
|
||||
Future<void> crateApiSetOutputGain({required double gain});
|
||||
|
||||
@@ -181,8 +181,6 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiSetReleaseTailMs({required int ms});
|
||||
|
||||
Future<void> crateApiSetTenVadModelPath({required String path});
|
||||
|
||||
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
|
||||
|
||||
Future<void> 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<void> crateApiSetInputDevice({String? name}) {
|
||||
Future<void> 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<void> crateApiSetInputMuted({required bool muted}) {
|
||||
@@ -1191,12 +1189,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
const TaskConstMeta(debugName: "set_network_state", argNames: ["state"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiSetOutputDevice({String? name}) {
|
||||
Future<void> 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<void> 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<void> 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<void> 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<dynamic>;
|
||||
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<dynamic>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,8 @@ class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
|
||||
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<AudioDebugStatsPanel> {
|
||||
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
|
||||
BridgeVadBackend.webrtcVad => 'webrtc',
|
||||
BridgeVadBackend.sileroOnnx => 'silero',
|
||||
BridgeVadBackend.tenVad => 'ten',
|
||||
BridgeVadBackend.energyDebug => 'energy',
|
||||
BridgeVadBackend.disabled => 'off',
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import '../src/rust/api.dart' as rust;
|
||||
/// Loads available input/output audio devices.
|
||||
typedef AudioDeviceListLoader = Future<rust.BridgeAudioDeviceList> Function();
|
||||
|
||||
/// Persists a selected audio device name.
|
||||
typedef AudioDeviceSetter = Future<void> Function({String? name});
|
||||
/// Persists a selected audio device id.
|
||||
typedef AudioDeviceSetter = Future<void> 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<AudioDeviceListTile> {
|
||||
List<rust.BridgeAudioDevice> _devices = [];
|
||||
String? _selectedDeviceId;
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
@@ -68,28 +69,56 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
||||
AudioDeviceKind.input => list.inputDevices,
|
||||
AudioDeviceKind.output => list.outputDevices,
|
||||
};
|
||||
_selectedDeviceId = _devices
|
||||
.where((device) => device.isSelected)
|
||||
.firstOrNull
|
||||
?.id;
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectDevice(rust.BridgeAudioDevice device) async {
|
||||
Future<void> _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<AudioDeviceListTile> {
|
||||
}
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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<ChatEntry> 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<ChatEntry> 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 = <ChatEntry>[];
|
||||
|
||||
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<ChatEntry> 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<ChatPage> {
|
||||
String _selectedClientName = '';
|
||||
final Set<BigInt> _closedPrivateChats = {};
|
||||
|
||||
List<ChatEntry> 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<ChatPage> {
|
||||
}
|
||||
|
||||
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<ChatPage> {
|
||||
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<ChatPage> {
|
||||
|
||||
List<_PrivateChatItem> get _privateChats {
|
||||
final chats = <BigInt, _PrivateChatItem>{};
|
||||
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<ChatPage> {
|
||||
}
|
||||
|
||||
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<ChatEntry> 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 = <InlineSpan>[
|
||||
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});
|
||||
|
||||
|
||||
@@ -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<rust.BridgeIosVoiceProcessingMode>(
|
||||
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<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegments,
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) {
|
||||
setState(() => _audioProcessing.vadBackend = s.first);
|
||||
|
||||
@@ -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<VoiceSettingsDialog> {
|
||||
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<VoiceSettingsDialog> {
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio processing'),
|
||||
|
||||
// iOS mode selector (iOS only)
|
||||
if (_isIos) ...[
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
|
||||
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<VoiceSettingsDialog> {
|
||||
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<VoiceSettingsDialog> {
|
||||
const VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegments,
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>[
|
||||
ChatEntry(
|
||||
senderId: BigInt.one,
|
||||
senderName: 'Sender',
|
||||
message: 'First',
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
];
|
||||
var currentSnapshot = snapshot(channels: const [], clients: const []);
|
||||
final refresh = ValueNotifier<int>(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),
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user