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