feat: stabilize voice activity and audio routing

This commit is contained in:
Edison Jwa
2026-05-25 01:19:09 +09:00
parent eb9014cd81
commit 5515ff6643
34 changed files with 3054 additions and 1751 deletions
@@ -51,146 +51,147 @@ List<PlatformCapability> currentPlatformCapabilities() {
}
List<PlatformCapability> _androidCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'Oboe AAudio backend; Bluetooth SCO supported.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Foreground service',
description: 'Persistent notification during active connection.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on Android.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System-managed; wired/BT/SCO automatic routing.',
tier: CapabilityTier.degraded,
),
const PlatformCapability(
feature: 'Push-to-talk button',
description: 'Media button / volume-key binding supported.',
tier: CapabilityTier.supported,
),
];
const PlatformCapability(
feature: 'Voice capture',
description: 'Oboe AAudio backend; Bluetooth SCO supported.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Foreground service',
description: 'Persistent notification during active connection.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on Android.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System-managed; wired/BT/SCO automatic routing.',
tier: CapabilityTier.degraded,
),
const PlatformCapability(
feature: 'Push-to-talk button',
description: 'Media button / volume-key binding supported.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _iosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'VoiceProcessingIO AudioUnit (default); Sonora raw mode optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'Platform AEC/NS/AGC via VPIO; optional Rust AEC3/NS/AGC2 via Sonora.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio route switching',
description: 'Handles speaker/earpiece/Bluetooth route changes and interruptions.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on iOS.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Background audio',
description: 'Supported via AVAudioSession background mode.',
tier: CapabilityTier.supported,
),
];
const PlatformCapability(
feature: 'Voice capture',
description: 'VoiceProcessingIO AudioUnit (default).',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'Platform AEC/NS/AGC via VPIO.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio route switching',
description:
'Handles speaker/earpiece/Bluetooth route changes and interruptions.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on iOS.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Background audio',
description: 'Supported via AVAudioSession background mode.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _macosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'cpal device enumeration; VoiceProcessingIO optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global key-binding API.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'macOS Keychain.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System audio output route picker.',
tier: CapabilityTier.supported,
),
];
const PlatformCapability(
feature: 'Voice capture',
description: 'cpal device enumeration; VoiceProcessingIO optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global key-binding API.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'macOS Keychain.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System audio output route picker.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _windowsCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'WASAPI via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global hotkey binding.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Windows Credential Manager / DPAPI.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Installer',
description: 'MSIX packaging not yet available in beta.',
tier: CapabilityTier.unavailable,
),
];
const PlatformCapability(
feature: 'Voice capture',
description: 'WASAPI via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2 with Silero VAD.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global hotkey binding.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Windows Credential Manager / DPAPI.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Installer',
description: 'MSIX packaging not yet available in beta.',
tier: CapabilityTier.unavailable,
),
];
List<PlatformCapability> _linuxCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PulseAudio/ALSA via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; no platform VPIO.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via X11/Wayland global key-binding. ',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Secret Service / libsecret.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Desktop environment',
description: 'DE-specific behaviour: screen locker may suspend audio.',
tier: CapabilityTier.degraded,
),
];
const PlatformCapability(
feature: 'Voice capture',
description: 'PulseAudio/ALSA via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2 with Silero VAD; no platform VPIO.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via X11/Wayland global key-binding. ',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Secret Service / libsecret.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Desktop environment',
description: 'DE-specific behaviour: screen locker may suspend audio.',
tier: CapabilityTier.degraded,
),
];
+152 -63
View File
@@ -76,12 +76,18 @@ String _kAppVersion = appSemverBaseline;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await RustLib.init();
_kAppVersion = await resolveAppVersion();
unawaited(wireStorage());
unawaited(wireConnectivity());
wireAudioLifecycle();
await configureBundledVadModels();
runApp(const ChanoraApp());
unawaited(_finishDeferredStartup());
}
Future<void> _finishDeferredStartup() async {
try {
_kAppVersion = await resolveAppVersion();
} catch (_) {}
}
class ChanoraApp extends StatelessWidget {
@@ -149,7 +155,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
int _statsTick = 0;
bool _voiceStatusRefreshInFlight = false;
bool _snapshotRefreshInFlight = false;
bool _snapshotRefreshQueued = false;
bool _snapshotRefreshQueuedRecordActivity = false;
bool _snapshotRefreshQueuedReportErrors = false;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
@@ -164,6 +173,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
BigInt? _currentVoiceChannelId;
BigInt? _pendingVoiceChannelId;
bool _canJoinVoiceChannel = true;
bool _voiceStateInitialized = false;
String? _lostReason;
int? _reconnectAttempt;
@@ -193,13 +203,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
List<rust.BridgeBookmark> _bookmarks = const [];
final List<ChatEntry> _chatMessages = [];
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
int _chatUnread = 0;
bool _chatOpen = false;
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeSnackBarVisible = false;
IconData? _audioRoute;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
// Constructed at startup so cold-launch state is captured before the
@@ -421,6 +431,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _reloadBookmarks() async {
try {
await wireStorage();
final list = await rust.listBookmarks();
if (!mounted) return;
setState(() => _bookmarks = list);
@@ -467,7 +478,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_phase = ConnectionPhase.connected;
}
});
unawaited(_onRefresh());
unawaited(_refreshSnapshot(recordActivity: true, reportErrors: true));
case rust.BridgeEvent_PttCapability(
:final level,
:final backendId,
@@ -491,6 +502,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
:final canJoin,
):
setState(() {
_voiceStateInitialized = true;
_inChannel = inChannel;
_transmitMode = transmitMode;
_hardMute = mute;
@@ -565,7 +577,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final isPoke = target is rust.BridgeMessageTarget_Poke;
final receivedAt = DateTime.now();
setState(() {
_chatMessages.add(
_appendChatEntryUnlocked(
ChatEntry(
senderId: senderId,
senderName: senderName,
@@ -575,12 +587,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
timestamp: receivedAt,
),
);
if (_chatMessages.length > 200) {
_chatMessages.removeRange(0, _chatMessages.length - 200);
}
if (!_chatOpen && !isPoke) {
_chatUnread++;
}
});
if (isPoke) {
_showPokeSnackBar(
@@ -601,8 +607,21 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
}());
}
case rust.BridgeEvent_AudioRouteChanged(:final route):
setState(() => _audioRoute = _routeIcon(route));
case rust.BridgeEvent_ServerActivity(:final message):
setState(() {
_appendChatEntryUnlocked(
ChatEntry(
senderId: BigInt.zero,
senderName: 'Server',
message: message,
target: const rust.BridgeMessageTarget.server(),
timestamp: DateTime.now(),
countsTowardUnread: false,
),
);
});
case rust.BridgeEvent_AudioRouteChanged():
break;
}
}
@@ -617,13 +636,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
void _ensureStatsTimer() {
if (_statsTimer != null) return;
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() => _audioStats = s);
_statsTick += 1;
if (_statsTick % 5 == 0) {
if (_statsTick % 4 == 0) {
unawaited(_refreshSnapshotForVoiceStatus());
}
} catch (_) {}
@@ -631,18 +650,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
Future<void> _refreshSnapshotForVoiceStatus() async {
if (_voiceStatusRefreshInFlight) return;
_voiceStatusRefreshInFlight = true;
try {
final snap = await rust.snapshot();
if (!mounted) return;
setState(() => _applySnapshot(snap));
} catch (_) {
// Best-effort visual refresh only. Connection/loss paths still
// surface via the normal bridge events and explicit refreshes.
} finally {
_voiceStatusRefreshInFlight = false;
}
await _refreshSnapshot(recordActivity: true, reportErrors: false);
}
@override
@@ -669,10 +677,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
_eventsSub?.cancel();
_statsTimer?.cancel();
_voiceStatusRefreshInFlight = false;
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
_chatFeedRevision.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
@@ -880,9 +892,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
}
}
await configureBundledVadModels();
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
if (!mounted) return;
setState(() {
_currentVoiceChannelId = ch.id;
_pendingVoiceChannelId = null;
_inChannel = true;
_canJoinVoiceChannel = true;
_voiceStateInitialized = true;
});
unawaited(_onRefresh());
} catch (e) {
if (!mounted) return;
@@ -959,10 +977,25 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
try {
return await rust.getAudioProcessingConfig();
} catch (_) {
return defaultAudioProcessingConfig;
return defaultAudioProcessingConfig();
}
}
void _appendChatEntryUnlocked(ChatEntry entry) {
_chatMessages.add(entry);
if (_chatMessages.length > 200) {
_chatMessages.removeRange(0, _chatMessages.length - 200);
}
_notifyChatFeedChanged();
if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) {
_chatUnread++;
}
}
void _notifyChatFeedChanged() {
_chatFeedRevision.value++;
}
/// Narrow-mode voice controls modal sheet (Plan E status chip
/// trigger). On mobile this is the **single** voice-controls
/// surface: route picker + inline mode radio + inline release-tail
@@ -1061,13 +1094,65 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
Future<void> _onRefresh() async {
await _refreshSnapshot(recordActivity: true, reportErrors: true);
}
Future<void> _refreshSnapshot({
required bool recordActivity,
required bool reportErrors,
}) async {
if (_snapshotRefreshInFlight) {
_snapshotRefreshQueued = true;
_snapshotRefreshQueuedRecordActivity =
_snapshotRefreshQueuedRecordActivity || recordActivity;
_snapshotRefreshQueuedReportErrors =
_snapshotRefreshQueuedReportErrors || reportErrors;
return;
}
_snapshotRefreshInFlight = true;
var nextRecordActivity = recordActivity;
var nextReportErrors = reportErrors;
try {
final snap = await rust.snapshot();
if (!mounted) return;
setState(() => _applySnapshot(snap));
} catch (e) {
if (!mounted) return;
_showUiError('refresh snapshot', e);
while (mounted) {
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
final previousSnapshot = _snapshot;
try {
final snap = await rust.snapshot();
if (!mounted) return;
final activityEntries = nextRecordActivity && previousSnapshot != null
? buildServerActivityEntries(
previous: previousSnapshot,
current: snap,
)
: const <ChatEntry>[];
setState(() {
_applySnapshot(snap);
for (final entry in activityEntries) {
_appendChatEntryUnlocked(entry);
}
});
} catch (e) {
if (nextReportErrors && mounted) {
_showUiError('refresh snapshot', e);
}
}
if (!_snapshotRefreshQueued) {
break;
}
nextRecordActivity = _snapshotRefreshQueuedRecordActivity;
nextReportErrors = _snapshotRefreshQueuedReportErrors;
}
} finally {
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
}
}
@@ -1109,7 +1194,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_statsTimer?.cancel();
_statsTimer = null;
_statsTick = 0;
_voiceStatusRefreshInFlight = false;
_snapshotRefreshInFlight = false;
_snapshotRefreshQueued = false;
_snapshotRefreshQueuedRecordActivity = false;
_snapshotRefreshQueuedReportErrors = false;
try {
await rust.disconnect();
} catch (_) {}
@@ -1131,34 +1219,21 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_currentVoiceChannelId = null;
_pendingVoiceChannelId = null;
_canJoinVoiceChannel = true;
_voiceStateInitialized = false;
_lostReason = null;
_reconnectAttempt = null;
_reconnectDelay = null;
_chatMessages.clear();
_chatUnread = 0;
_chatOpen = false;
}
IconData _routeIcon(rust.BridgeAudioRoute r) {
switch (r) {
case rust.BridgeAudioRoute.earpiece:
return Icons.phone_android;
case rust.BridgeAudioRoute.speaker:
return Icons.volume_up;
case rust.BridgeAudioRoute.wiredHeadset:
return Icons.headset;
case rust.BridgeAudioRoute.bluetoothHfp:
case rust.BridgeAudioRoute.bluetoothA2Dp:
return Icons.bluetooth;
case rust.BridgeAudioRoute.unknown:
return Icons.help_outline;
}
_notifyChatFeedChanged();
}
Future<void> _onOpenChat({
rust.BridgeMessageTarget? target,
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
setState(() {
_chatUnread = 0;
_chatOpen = true;
@@ -1167,7 +1242,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
MaterialPageRoute(
builder: (_) => ChatPage(
messages: _chatMessages,
snapshot: _snapshot!,
snapshot: initialSnapshot,
messagesSource: () => _chatMessages,
snapshotSource: () => _snapshot ?? initialSnapshot,
refreshListenable: _chatFeedRevision,
initialTarget:
target ??
resolveInitialChatTarget(
@@ -1312,12 +1390,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_snapshot = snap;
final own = ownClientSnapshotState(snap);
if (own == null) return;
_currentVoiceChannelId = own.channelId;
_inputMuted = own.inputMuted;
_outputMuted = own.outputMuted;
_pendingVoiceChannelId = null;
_inChannel = true;
_canJoinVoiceChannel = true;
if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
_currentVoiceChannelId = own.channelId;
_pendingVoiceChannelId = null;
_inChannel = true;
_canJoinVoiceChannel = true;
_voiceStateInitialized = true;
} else if (_pendingVoiceChannelId == null) {
_currentVoiceChannelId = own.channelId;
}
_notifyChatFeedChanged();
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
_hardMuteByTalkPower = true;
@@ -1525,6 +1609,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (name == null || name.trim().isEmpty) return;
if (!mounted) return;
try {
await wireStorage();
await rust.addBookmark(
b: rust.BridgeBookmark(
id: 0,
@@ -1543,6 +1628,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
try {
await wireStorage();
await rust.deleteBookmark(id: b.id);
await _reloadBookmarks();
} catch (e) {
@@ -1580,6 +1666,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
try {
await wireStorage();
await rust.addBookmark(
b: rust.BridgeBookmark(
id: 0,
@@ -1652,7 +1739,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final appBarTitle = _serverReachable
? Text(
_snapshot?.serverName ?? l10n.appTitle,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
)
: headerTitle;
@@ -1701,10 +1792,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
),
],
),
if (_serverReachable && _audioRoute != null) ...[
const SizedBox(width: 8),
Icon(_audioRoute, size: 16, color: theme.colorScheme.tertiary),
],
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
@@ -1925,6 +2012,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
onPressed: _onConfirmDisconnect,
)
: null,
leadingWidth: _phase.canDisconnect ? 44 : null,
titleSpacing: _phase.canDisconnect ? 4 : null,
title: appBarTitle,
actions: headerActions,
),
+68 -16
View File
@@ -270,23 +270,19 @@ Future<BridgeAudioProcessingStats> audioProcessingStats() =>
Future<BridgeAudioDeviceList> listAudioDevices() =>
RustLib.instance.api.crateApiListAudioDevices();
/// Set the preferred input device by name. Takes effect on next
/// Set the preferred input device by id. Takes effect on next
/// `start_audio`.
Future<void> setInputDevice({String? name}) =>
RustLib.instance.api.crateApiSetInputDevice(name: name);
Future<void> setInputDevice({String? id}) =>
RustLib.instance.api.crateApiSetInputDevice(id: id);
/// Set the preferred output device by name.
Future<void> setOutputDevice({String? name}) =>
RustLib.instance.api.crateApiSetOutputDevice(name: name);
/// Set the preferred output device by id.
Future<void> setOutputDevice({String? id}) =>
RustLib.instance.api.crateApiSetOutputDevice(id: id);
/// Configure the VAD model path.
Future<void> setVadModelPath({required String path}) =>
RustLib.instance.api.crateApiSetVadModelPath(path: path);
/// Configure the TEN VAD ONNX model path.
Future<void> setTenVadModelPath({required String path}) =>
RustLib.instance.api.crateApiSetTenVadModelPath(path: path);
/// Enable or disable audio debug WAV dumping.
Future<void> enableAudioDebugWavDump({required bool enabled}) =>
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
@@ -321,24 +317,47 @@ enum BridgeAudioBackend {
/// Audio device info from the platform.
class BridgeAudioDevice {
/// Stable platform-reported device identifier.
final String id;
/// Human-readable device name.
final String name;
/// Additional device details useful for disambiguation.
final String details;
/// True if the OS reports this as the default device.
final bool isDefault;
const BridgeAudioDevice({required this.name, required this.isDefault});
/// True if Chanora currently has this device pinned.
final bool isSelected;
const BridgeAudioDevice({
required this.id,
required this.name,
required this.details,
required this.isDefault,
required this.isSelected,
});
@override
int get hashCode => name.hashCode ^ isDefault.hashCode;
int get hashCode =>
id.hashCode ^
name.hashCode ^
details.hashCode ^
isDefault.hashCode ^
isSelected.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgeAudioDevice &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
isDefault == other.isDefault;
details == other.details &&
isDefault == other.isDefault &&
isSelected == other.isSelected;
}
/// List of available audio devices.
@@ -512,6 +531,21 @@ class BridgeAudioProcessingStats {
/// Clipped samples.
final BigInt clippedSamples;
/// Number of effectively silent processed capture frames.
final BigInt zeroFrames;
/// Number of processed capture frames.
final BigInt captureFrames;
/// Input callbacks carrying 10 ms of audio.
final BigInt callbacks10Ms;
/// Input callbacks carrying 20 ms of audio.
final BigInt callbacks20Ms;
/// Input callbacks carrying other sizes.
final BigInt callbacksOther;
/// Sonora enabled.
final bool sonoraEnabled;
@@ -536,6 +570,11 @@ class BridgeAudioProcessingStats {
required this.outputUnderruns,
required this.callbackXruns,
required this.clippedSamples,
required this.zeroFrames,
required this.captureFrames,
required this.callbacks10Ms,
required this.callbacks20Ms,
required this.callbacksOther,
required this.sonoraEnabled,
required this.platformVoiceProcessingEnabled,
});
@@ -559,6 +598,11 @@ class BridgeAudioProcessingStats {
outputUnderruns.hashCode ^
callbackXruns.hashCode ^
clippedSamples.hashCode ^
zeroFrames.hashCode ^
captureFrames.hashCode ^
callbacks10Ms.hashCode ^
callbacks20Ms.hashCode ^
callbacksOther.hashCode ^
sonoraEnabled.hashCode ^
platformVoiceProcessingEnabled.hashCode;
@@ -584,6 +628,11 @@ class BridgeAudioProcessingStats {
outputUnderruns == other.outputUnderruns &&
callbackXruns == other.callbackXruns &&
clippedSamples == other.clippedSamples &&
zeroFrames == other.zeroFrames &&
captureFrames == other.captureFrames &&
callbacks10Ms == other.callbacks10Ms &&
callbacks20Ms == other.callbacks20Ms &&
callbacksOther == other.callbacksOther &&
sonoraEnabled == other.sonoraEnabled &&
platformVoiceProcessingEnabled ==
other.platformVoiceProcessingEnabled;
@@ -976,6 +1025,12 @@ sealed class BridgeEvent with _$BridgeEvent {
required BridgeMessageTarget target,
}) = BridgeEvent_ChatMessage;
/// Human-readable server activity surfaced from protocol bookkeeping events.
const factory BridgeEvent.serverActivity({
/// TeamSpeak-style activity line.
required String message,
}) = BridgeEvent_ServerActivity;
/// Audio route changed (speaker/earpiece/BT/wired).
const factory BridgeEvent.audioRouteChanged({
/// The new audio route.
@@ -1169,9 +1224,6 @@ enum BridgeVadBackend {
/// Silero ONNX VAD.
sileroOnnx,
/// TEN VAD.
tenVad,
/// WebRTC fallback VAD.
webrtcVad,
@@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,TResult Function( BridgeEvent_ChatMessage value)? chatMessage,TResult Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,TResult Function( BridgeEvent_ChatMessage value)? chatMessage,TResult Function( BridgeEvent_ServerActivity value)? serverActivity,TResult Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -70,7 +70,8 @@ return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != nul
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return chatMessage(_that);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that);case _:
return orElse();
@@ -89,7 +90,7 @@ return audioRouteChanged(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,required TResult Function( BridgeEvent_ChatMessage value) chatMessage,required TResult Function( BridgeEvent_AudioRouteChanged value) audioRouteChanged,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,required TResult Function( BridgeEvent_ChatMessage value) chatMessage,required TResult Function( BridgeEvent_ServerActivity value) serverActivity,required TResult Function( BridgeEvent_AudioRouteChanged value) audioRouteChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected():
@@ -104,7 +105,8 @@ return pttCapability(_that);case BridgeEvent_VoiceState():
return voiceState(_that);case BridgeEvent_InterruptionState():
return interruptionState(_that);case BridgeEvent_PermissionState():
return permissionState(_that);case BridgeEvent_ChatMessage():
return chatMessage(_that);case BridgeEvent_AudioRouteChanged():
return chatMessage(_that);case BridgeEvent_ServerActivity():
return serverActivity(_that);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that);}
}
/// A variant of `map` that fallback to returning `null`.
@@ -119,7 +121,7 @@ return audioRouteChanged(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,TResult? Function( BridgeEvent_ChatMessage value)? chatMessage,TResult? Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,TResult? Function( BridgeEvent_ChatMessage value)? chatMessage,TResult? Function( BridgeEvent_ServerActivity value)? serverActivity,TResult? Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -134,7 +136,8 @@ return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != nul
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return chatMessage(_that);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that);case _:
return null;
@@ -152,7 +155,7 @@ return audioRouteChanged(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( BridgeAudioRoute route)? audioRouteChanged,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -166,7 +169,8 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case _:
return orElse();
@@ -185,7 +189,7 @@ return audioRouteChanged(_that.route);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( BridgeAudioRoute route) audioRouteChanged,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -199,7 +203,8 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity():
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that.route);}
}
/// A variant of `when` that fallback to returning `null`
@@ -214,7 +219,7 @@ return audioRouteChanged(_that.route);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -228,7 +233,8 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case _:
return null;
@@ -1041,6 +1047,73 @@ $BridgeMessageTargetCopyWith<$Res> get target {
/// @nodoc
class BridgeEvent_ServerActivity extends BridgeEvent {
const BridgeEvent_ServerActivity({required this.message}): super._();
/// TeamSpeak-style activity line.
final String message;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_ServerActivityCopyWith<BridgeEvent_ServerActivity> get copyWith => _$BridgeEvent_ServerActivityCopyWithImpl<BridgeEvent_ServerActivity>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ServerActivity&&(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType,message);
@override
String toString() {
return 'BridgeEvent.serverActivity(message: $message)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_ServerActivityCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_ServerActivityCopyWith(BridgeEvent_ServerActivity value, $Res Function(BridgeEvent_ServerActivity) _then) = _$BridgeEvent_ServerActivityCopyWithImpl;
@useResult
$Res call({
String message
});
}
/// @nodoc
class _$BridgeEvent_ServerActivityCopyWithImpl<$Res>
implements $BridgeEvent_ServerActivityCopyWith<$Res> {
_$BridgeEvent_ServerActivityCopyWithImpl(this._self, this._then);
final BridgeEvent_ServerActivity _self;
final $Res Function(BridgeEvent_ServerActivity) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? message = null,}) {
return _then(BridgeEvent_ServerActivity(
message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class BridgeEvent_AudioRouteChanged extends BridgeEvent {
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => 1433826599;
int get rustContentHash => -560177922;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -156,7 +156,7 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiSetHardMute({required bool muted});
Future<void> crateApiSetInputDevice({String? name});
Future<void> crateApiSetInputDevice({String? id});
Future<void> crateApiSetInputMuted({required bool muted});
@@ -166,7 +166,7 @@ abstract class RustLibApi extends BaseApi {
void crateApiSetNetworkState({required BridgeNetworkState state});
Future<void> crateApiSetOutputDevice({String? name});
Future<void> crateApiSetOutputDevice({String? id});
Future<void> crateApiSetOutputGain({required double gain});
@@ -181,8 +181,6 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiSetReleaseTailMs({required int ms});
Future<void> crateApiSetTenVadModelPath({required String path});
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
Future<void> crateApiSetVadModelPath({required String path});
@@ -772,7 +770,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
},
codec: SseCodec(
decodeSuccessData: sse_decode_bridge_audio_device_list,
decodeErrorData: null,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiListAudioDevicesConstMeta,
argValues: [],
@@ -1079,12 +1077,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "set_hard_mute", argNames: ["muted"]);
@override
Future<void> crateApiSetInputDevice({String? name}) {
Future<void> crateApiSetInputDevice({String? id}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_opt_String(name, serializer);
sse_encode_opt_String(id, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
@@ -1097,14 +1095,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiSetInputDeviceConstMeta,
argValues: [name],
argValues: [id],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiSetInputDeviceConstMeta =>
const TaskConstMeta(debugName: "set_input_device", argNames: ["name"]);
const TaskConstMeta(debugName: "set_input_device", argNames: ["id"]);
@override
Future<void> crateApiSetInputMuted({required bool muted}) {
@@ -1191,12 +1189,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "set_network_state", argNames: ["state"]);
@override
Future<void> crateApiSetOutputDevice({String? name}) {
Future<void> crateApiSetOutputDevice({String? id}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_opt_String(name, serializer);
sse_encode_opt_String(id, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
@@ -1209,14 +1207,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiSetOutputDeviceConstMeta,
argValues: [name],
argValues: [id],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiSetOutputDeviceConstMeta =>
const TaskConstMeta(debugName: "set_output_device", argNames: ["name"]);
const TaskConstMeta(debugName: "set_output_device", argNames: ["id"]);
@override
Future<void> crateApiSetOutputGain({required double gain}) {
@@ -1364,36 +1362,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiSetReleaseTailMsConstMeta =>
const TaskConstMeta(debugName: "set_release_tail_ms", argNames: ["ms"]);
@override
Future<void> crateApiSetTenVadModelPath({required String path}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(path, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 42,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiSetTenVadModelPathConstMeta,
argValues: [path],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiSetTenVadModelPathConstMeta => const TaskConstMeta(
debugName: "set_ten_vad_model_path",
argNames: ["path"],
);
@override
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode}) {
return handler.executeNormal(
@@ -1404,7 +1372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 43,
funcId: 42,
port: port_,
);
},
@@ -1432,7 +1400,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 44,
funcId: 43,
port: port_,
);
},
@@ -1459,7 +1427,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 45,
funcId: 44,
port: port_,
);
},
@@ -1487,7 +1455,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 46,
funcId: 45,
port: port_,
);
},
@@ -1519,7 +1487,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 47,
funcId: 46,
port: port_,
);
},
@@ -1548,7 +1516,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 48,
funcId: 47,
port: port_,
);
},
@@ -1643,11 +1611,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 2)
throw Exception('unexpected arr length: expect 2 but see ${arr.length}');
if (arr.length != 5)
throw Exception('unexpected arr length: expect 5 but see ${arr.length}');
return BridgeAudioDevice(
name: dco_decode_String(arr[0]),
isDefault: dco_decode_bool(arr[1]),
id: dco_decode_String(arr[0]),
name: dco_decode_String(arr[1]),
details: dco_decode_String(arr[2]),
isDefault: dco_decode_bool(arr[3]),
isSelected: dco_decode_bool(arr[4]),
);
}
@@ -1694,8 +1665,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 19)
throw Exception('unexpected arr length: expect 19 but see ${arr.length}');
if (arr.length != 24)
throw Exception('unexpected arr length: expect 24 but see ${arr.length}');
return BridgeAudioProcessingStats(
inputDbfs: dco_decode_f_32(arr[0]),
renderDbfs: dco_decode_f_32(arr[1]),
@@ -1716,8 +1687,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
outputUnderruns: dco_decode_u_64(arr[14]),
callbackXruns: dco_decode_u_64(arr[15]),
clippedSamples: dco_decode_u_64(arr[16]),
sonoraEnabled: dco_decode_bool(arr[17]),
platformVoiceProcessingEnabled: dco_decode_bool(arr[18]),
zeroFrames: dco_decode_u_64(arr[17]),
captureFrames: dco_decode_u_64(arr[18]),
callbacks10Ms: dco_decode_u_64(arr[19]),
callbacks20Ms: dco_decode_u_64(arr[20]),
callbacksOther: dco_decode_u_64(arr[21]),
sonoraEnabled: dco_decode_bool(arr[22]),
platformVoiceProcessingEnabled: dco_decode_bool(arr[23]),
);
}
@@ -1887,6 +1863,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
);
case 12:
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
case 13:
return BridgeEvent_AudioRouteChanged(
route: dco_decode_bridge_audio_route(raw[1]),
);
@@ -2194,9 +2172,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_id = sse_decode_String(deserializer);
var var_name = sse_decode_String(deserializer);
var var_details = sse_decode_String(deserializer);
var var_isDefault = sse_decode_bool(deserializer);
return BridgeAudioDevice(name: var_name, isDefault: var_isDefault);
var var_isSelected = sse_decode_bool(deserializer);
return BridgeAudioDevice(
id: var_id,
name: var_name,
details: var_details,
isDefault: var_isDefault,
isSelected: var_isSelected,
);
}
@protected
@@ -2270,6 +2257,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_outputUnderruns = sse_decode_u_64(deserializer);
var var_callbackXruns = sse_decode_u_64(deserializer);
var var_clippedSamples = sse_decode_u_64(deserializer);
var var_zeroFrames = sse_decode_u_64(deserializer);
var var_captureFrames = sse_decode_u_64(deserializer);
var var_callbacks10Ms = sse_decode_u_64(deserializer);
var var_callbacks20Ms = sse_decode_u_64(deserializer);
var var_callbacksOther = sse_decode_u_64(deserializer);
var var_sonoraEnabled = sse_decode_bool(deserializer);
var var_platformVoiceProcessingEnabled = sse_decode_bool(deserializer);
return BridgeAudioProcessingStats(
@@ -2290,6 +2282,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
outputUnderruns: var_outputUnderruns,
callbackXruns: var_callbackXruns,
clippedSamples: var_clippedSamples,
zeroFrames: var_zeroFrames,
captureFrames: var_captureFrames,
callbacks10Ms: var_callbacks10Ms,
callbacks20Ms: var_callbacks20Ms,
callbacksOther: var_callbacksOther,
sonoraEnabled: var_sonoraEnabled,
platformVoiceProcessingEnabled: var_platformVoiceProcessingEnabled,
);
@@ -2519,6 +2516,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
target: var_target,
);
case 12:
var var_message = sse_decode_String(deserializer);
return BridgeEvent_ServerActivity(message: var_message);
case 13:
var var_route = sse_decode_bridge_audio_route(deserializer);
return BridgeEvent_AudioRouteChanged(route: var_route);
default:
@@ -2917,8 +2917,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(self.id, serializer);
sse_encode_String(self.name, serializer);
sse_encode_String(self.details, serializer);
sse_encode_bool(self.isDefault, serializer);
sse_encode_bool(self.isSelected, serializer);
}
@protected
@@ -2978,6 +2981,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_64(self.outputUnderruns, serializer);
sse_encode_u_64(self.callbackXruns, serializer);
sse_encode_u_64(self.clippedSamples, serializer);
sse_encode_u_64(self.zeroFrames, serializer);
sse_encode_u_64(self.captureFrames, serializer);
sse_encode_u_64(self.callbacks10Ms, serializer);
sse_encode_u_64(self.callbacks20Ms, serializer);
sse_encode_u_64(self.callbacksOther, serializer);
sse_encode_bool(self.sonoraEnabled, serializer);
sse_encode_bool(self.platformVoiceProcessingEnabled, serializer);
}
@@ -3168,8 +3176,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_String(senderName, serializer);
sse_encode_String(message, serializer);
sse_encode_box_autoadd_bridge_message_target(target, serializer);
case BridgeEvent_AudioRouteChanged(route: final route):
case BridgeEvent_ServerActivity(message: final message):
sse_encode_i_32(12, serializer);
sse_encode_String(message, serializer);
case BridgeEvent_AudioRouteChanged(route: final route):
sse_encode_i_32(13, serializer);
sse_encode_bridge_audio_route(route, serializer);
}
}
@@ -108,7 +108,8 @@ class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
s.platformVoiceProcessingEnabled ? 'on' : 'off',
Colors.white,
),
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
if (s.processingBackend == BridgeAudioBackend.sonora)
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
const SizedBox(height: 4),
_row(
'mic in',
@@ -184,7 +185,6 @@ class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
BridgeVadBackend.webrtcVad => 'webrtc',
BridgeVadBackend.sileroOnnx => 'silero',
BridgeVadBackend.tenVad => 'ten',
BridgeVadBackend.energyDebug => 'energy',
BridgeVadBackend.disabled => 'off',
};
@@ -5,8 +5,8 @@ import '../src/rust/api.dart' as rust;
/// Loads available input/output audio devices.
typedef AudioDeviceListLoader = Future<rust.BridgeAudioDeviceList> Function();
/// Persists a selected audio device name.
typedef AudioDeviceSetter = Future<void> Function({String? name});
/// Persists a selected audio device id.
typedef AudioDeviceSetter = Future<void> Function({String? id});
/// Which desktop audio device group this tile manages.
enum AudioDeviceKind {
@@ -52,6 +52,7 @@ class AudioDeviceListTile extends StatefulWidget {
class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
List<rust.BridgeAudioDevice> _devices = [];
String? _selectedDeviceId;
bool _loaded = false;
@override
@@ -68,28 +69,56 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
AudioDeviceKind.input => list.inputDevices,
AudioDeviceKind.output => list.outputDevices,
};
_selectedDeviceId = _devices
.where((device) => device.isSelected)
.firstOrNull
?.id;
_loaded = true;
});
}
Future<void> _selectDevice(rust.BridgeAudioDevice device) async {
Future<void> _selectDevice(String? deviceId) async {
switch (widget.kind) {
case AudioDeviceKind.input:
await widget.setInputDevice(name: device.name);
await widget.setInputDevice(id: deviceId);
case AudioDeviceKind.output:
await widget.setOutputDevice(name: device.name);
await widget.setOutputDevice(id: deviceId);
}
if (!mounted) return;
setState(() {
_selectedDeviceId = deviceId;
});
final selectedName = _selectedDevice?.name ?? 'System default';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${widget.label} set to ${device.name}'),
content: Text('${widget.label} set to $selectedName'),
duration: const Duration(seconds: 2),
),
);
}
rust.BridgeAudioDevice? get _selectedDevice {
if (_selectedDeviceId == null) {
return null;
}
for (final device in _devices) {
if (device.id == _selectedDeviceId) {
return device;
}
}
return null;
}
@override
Widget build(BuildContext context) {
final selectedDevice = _selectedDevice;
final subtitleText = selectedDevice?.name ?? 'System default';
final leadingIcon = switch (widget.kind) {
AudioDeviceKind.input => Icons.mic_none,
AudioDeviceKind.output => Icons.headphones,
};
if (!_loaded) {
return ListTile(
title: Text(widget.label),
@@ -110,17 +139,31 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
}
return ExpansionTile(
title: Text(widget.label),
subtitle: Text('${_devices.length} available'),
leading: const Icon(Icons.headphones, size: 18),
subtitle: Text(subtitleText),
leading: Icon(leadingIcon, size: 18),
children: [
ListTile(
dense: true,
title: const Text('System default', style: TextStyle(fontSize: 13)),
subtitle: const Text('Use the OS default device'),
trailing: _selectedDeviceId == null
? const Icon(Icons.check, size: 16, color: Colors.green)
: null,
onTap: _selectedDeviceId == null ? null : () => _selectDevice(null),
),
for (final device in _devices)
ListTile(
dense: true,
title: Text(device.name, style: const TextStyle(fontSize: 13)),
trailing: device.isDefault
subtitle: device.details.isEmpty ? null : Text(device.details),
trailing: device.id == _selectedDeviceId
? const Icon(Icons.check, size: 16, color: Colors.green)
: device.isDefault
? const Icon(Icons.radio_button_checked, size: 16)
: null,
onTap: device.isDefault ? null : () => _selectDevice(device),
onTap: device.id == _selectedDeviceId
? null
: () => _selectDevice(device.id),
),
],
);
@@ -3,21 +3,32 @@ import 'dart:io' show Platform;
import '../src/rust/api.dart' as rust;
/// Fallback audio-processing config used before the bridge can report one.
const defaultAudioProcessingConfig = rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.sileroOnnx,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.platform,
agc: rust.BridgeEffectOwner.platform,
hpfEnabled: true,
limiterEnabled: true,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: false,
);
rust.BridgeAudioProcessingConfig defaultAudioProcessingConfig() {
final desktopWebrtcApm = Platform.isWindows || Platform.isLinux;
return rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: desktopWebrtcApm
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.sileroOnnx,
aec: desktopWebrtcApm
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.platform,
ns: desktopWebrtcApm
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.platform,
agc: desktopWebrtcApm
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.platform,
hpfEnabled: true,
limiterEnabled: true,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: false,
);
}
/// Mutable UI state for audio-processing controls.
class AudioProcessingConfigState {
@@ -31,7 +42,7 @@ class AudioProcessingConfigState {
debugWavDump = config.debugWavDumpEnabled,
preferHardware = _usesPlatformEffects(config),
vadBackend = normalizedVadBackend(config.vadBackend),
iosMode = config.iosMode;
iosMode = normalizedIosProcessingMode(config.iosMode);
/// Noise suppression toggle.
bool nsEnabled;
@@ -64,9 +75,23 @@ class AudioProcessingConfigState {
rust.BridgeAudioProcessingConfig buildConfig({
required rust.BridgeAudioProcessingConfig base,
bool? isAndroid,
bool? isIos,
bool? isMacOS,
bool? isWindows,
bool? isLinux,
}) {
final android = isAndroid ?? Platform.isAndroid;
final vad = normalizedVadBackend(vadBackend);
final ios = isIos ?? Platform.isIOS;
final macOS = isMacOS ?? Platform.isMacOS;
final windows = isWindows ?? Platform.isWindows;
final linux = isLinux ?? Platform.isLinux;
final appleVoiceProcessing = ios || macOS;
final desktopWebrtcApm = windows || linux;
final vad = normalizedVadBackend(
vadBackend,
isWindows: windows,
isLinux: linux,
);
if (android) {
final owner = preferHardware
@@ -74,7 +99,7 @@ class AudioProcessingConfigState {
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: iosMode,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
@@ -91,38 +116,55 @@ class AudioProcessingConfigState {
);
}
final isSonora =
iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
final aecOwner = isSonora
? (aecEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform;
final nsOwner = isSonora
? (nsEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (agcEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
if (appleVoiceProcessing) {
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vad,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.platform,
agc: rust.BridgeEffectOwner.platform,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
if (desktopWebrtcApm) {
final owner = rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.webrtcApm,
vadBackend: vad,
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
iosMode: normalizedIosProcessingMode(iosMode),
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vad,
aec: aecOwner,
ns: nsOwner,
agc: agcOwner,
aec: rust.BridgeEffectOwner.platform,
ns: nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off,
agc: agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
@@ -133,13 +175,55 @@ class AudioProcessingConfigState {
}
}
bool androidUsesHardwareProcessing(AudioProcessingConfigState state) {
return state.preferHardware;
}
bool androidShowsNsControl(AudioProcessingConfigState state) {
return true;
}
bool androidShowsAecControl(AudioProcessingConfigState state) {
return true;
}
bool androidShowsAgcControl(AudioProcessingConfigState state) {
return true;
}
bool androidShowsHpfControl(AudioProcessingConfigState state) {
return true;
}
bool androidShowsLimiterControl(AudioProcessingConfigState state) {
return false;
}
/// Never leave the UI on the hidden disabled backend.
rust.BridgeVadBackend normalizedVadBackend(rust.BridgeVadBackend backend) {
///
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
/// available internally as a runtime fallback.
rust.BridgeVadBackend normalizedVadBackend(
rust.BridgeVadBackend backend, {
bool? isWindows,
bool? isLinux,
}) {
final desktop =
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
return backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
? rust.BridgeVadBackend.sileroOnnx
: backend;
}
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
rust.BridgeIosVoiceProcessingMode mode,
) {
return mode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
? mode
: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
}
bool _usesPlatformEffects(rust.BridgeAudioProcessingConfig config) {
return config.aec == rust.BridgeEffectOwner.platform ||
config.ns == rust.BridgeEffectOwner.platform ||
+424 -29
View File
@@ -10,6 +10,9 @@ import '../services/ts3_server_link.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
const double _chatSidebarTileExtent = 92;
const Color _chatSidebarSelectedTileColor = Color(0xFF415366);
/// One chat/activity message shown in the chat hub.
class ChatEntry {
/// Construct a chat entry.
@@ -20,6 +23,7 @@ class ChatEntry {
required this.target,
this.isSelf = false,
this.timestamp,
this.countsTowardUnread = true,
});
/// Sender client id.
@@ -40,11 +44,18 @@ class ChatEntry {
/// Local time when this message was received or sent.
final DateTime? timestamp;
/// Whether this entry should increment the message unread badge.
final bool countsTowardUnread;
/// True for direct-message targets.
bool get isPrivate => target is rust.BridgeMessageTarget_Client;
/// True for poke targets.
bool get isPoke => target is rust.BridgeMessageTarget_Poke;
/// True for synthetic or protocol-driven server activity rows.
bool get isServerActivity =>
target is rust.BridgeMessageTarget_Server && !countsTowardUnread;
}
String chatTimeLabel(DateTime? timestamp) {
@@ -69,6 +80,162 @@ String pokeHistoryLine(AppL10n l10n, ChatEntry entry) {
return l10n.pokeHistoryIncomingWithMessage(time, peerName, message);
}
enum _ActivityTone {
positive,
transition,
warning,
plain,
username,
channel,
adminActor,
automationActor,
group,
}
sealed class _ActivitySegment {
const _ActivitySegment(this.text, this.tone, {this.bold = false});
final String text;
final _ActivityTone tone;
final bool bold;
}
class _ActivityTextSegment extends _ActivitySegment {
const _ActivityTextSegment(super.text, super.tone, {super.bold = false});
}
List<_ActivitySegment> _activitySegments(String message) {
final quotedMatches = RegExp(r'"[^"]+"').allMatches(message).toList();
if (quotedMatches.isEmpty) {
return [_ActivityTextSegment(message, _activityActionTone(message))];
}
final segments = <_ActivitySegment>[];
var cursor = 0;
for (var i = 0; i < quotedMatches.length; i++) {
final match = quotedMatches[i];
if (match.start > cursor) {
final plain = message.substring(cursor, match.start);
if (plain.isNotEmpty) {
segments.add(
_ActivityTextSegment(plain, _activityActionTone(plain.trim())),
);
}
}
final quoted = match.group(0)!;
segments.add(_activityEntitySegment(quoted, message, i));
cursor = match.end;
}
if (cursor < message.length) {
final tail = message.substring(cursor);
if (tail.isNotEmpty) {
segments.add(
_ActivityTextSegment(tail, _activityActionTone(tail.trim())),
);
}
}
return segments;
}
_ActivitySegment _activityEntitySegment(
String quoted,
String fullMessage,
int quotedIndex,
) {
final lower = fullMessage.toLowerCase();
final after = fullMessage
.substring(fullMessage.indexOf(quoted) + quoted.length)
.toLowerCase();
final value = quoted.substring(1, quoted.length - 1);
if (lower.contains('channel group ')) {
if (quotedIndex == 0) {
return _ActivityTextSegment(quoted, _ActivityTone.group);
}
return _activityActorSegment(value, quoted);
}
if (lower.contains('server group ')) {
if (quotedIndex == 0) {
return _ActivityTextSegment(quoted, _ActivityTone.group);
}
return _activityActorSegment(value, quoted);
}
if (after.startsWith(' connected to channel') ||
after.startsWith(' switched from channel') ||
after.startsWith(' disconnected') ||
after.startsWith(' was moved from channel') ||
after.startsWith(' is now ')) {
return _activityActorSegment(value, quoted);
}
if (lower.contains('channel ') &&
(after.startsWith('.') ||
after.startsWith(' to') ||
after.startsWith(' from') ||
after.startsWith(' by') ||
after.isEmpty)) {
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
}
if (lower.contains('channel ') &&
(quotedIndex == 1 || quotedIndex == 2 || quotedIndex == 3)) {
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
}
return _activityActorSegment(value, quoted);
}
_ActivitySegment _activityActorSegment(String value, String quoted) {
final lower = value.toLowerCase();
if (lower.contains('auto') ||
lower.contains('automation') ||
lower.contains('bot')) {
return _ActivityTextSegment(
quoted,
_ActivityTone.automationActor,
bold: true,
);
}
if (lower.contains('server') ||
lower.contains('admin') ||
lower.contains('vigorous pro')) {
return _ActivityTextSegment(quoted, _ActivityTone.adminActor, bold: true);
}
return _ActivityTextSegment(quoted, _ActivityTone.username, bold: true);
}
_ActivityTone _activityActionTone(String text) {
final lower = text.toLowerCase();
if (lower.contains('connected')) return _ActivityTone.positive;
if (lower.contains('disconnected') ||
lower.contains('dropped') ||
lower.contains('lost') ||
lower.contains('shutdown')) {
return _ActivityTone.warning;
}
if (lower.contains('switched') ||
lower.contains('moved') ||
lower.contains('created') ||
lower.contains('deleted') ||
lower.contains('renamed') ||
lower.contains('assigned') ||
lower.contains('removed')) {
return _ActivityTone.transition;
}
return _ActivityTone.plain;
}
Color _activityColor(BuildContext context, _ActivityTone tone) {
return switch (tone) {
_ActivityTone.positive => const Color(0xFF2F7D57),
_ActivityTone.transition => const Color(0xFF5A7394),
_ActivityTone.warning => const Color(0xFFC06A2B),
_ActivityTone.plain => const Color(0xFF6C7C8F),
_ActivityTone.username => const Color(0xFFC64A4A),
_ActivityTone.channel => const Color(0xFF3B6EA8),
_ActivityTone.adminActor => const Color(0xFF426B9A),
_ActivityTone.automationActor => const Color(0xFFB64C4C),
_ActivityTone.group => const Color(0xFF8B7A68),
};
}
/// Resolve the best chat target to show when opening the chat page.
///
/// Returns null to show the chat hub.
@@ -76,15 +243,126 @@ rust.BridgeMessageTarget? resolveInitialChatTarget({
required List<ChatEntry> messages,
required BigInt? currentVoiceChannelId,
}) {
bool hasServerActivity = false;
for (final message in messages.reversed) {
if (message.isPrivate) return message.target;
if (message.target is rust.BridgeMessageTarget_Server) {
hasServerActivity = true;
}
}
if (currentVoiceChannelId != null) {
return const rust.BridgeMessageTarget.channel();
}
if (hasServerActivity) {
return const rust.BridgeMessageTarget.server();
}
return null;
}
/// Build server-activity entries from a fresh snapshot diff.
List<ChatEntry> buildServerActivityEntries({
required rust.BridgeSnapshot previous,
required rust.BridgeSnapshot current,
DateTime? timestamp,
}) {
final now = timestamp ?? DateTime.now();
final previousClients = {
for (final client in previous.clients) client.id: client,
};
final currentClients = {
for (final client in current.clients) client.id: client,
};
final previousChannels = {
for (final channel in previous.channels) channel.id: channel,
};
final currentChannels = {
for (final channel in current.channels) channel.id: channel,
};
final entries = <ChatEntry>[];
String clientLabel(rust.BridgeClient client, rust.BridgeSnapshot snapshot) {
if (client.id == snapshot.ownClientId) return 'You';
final name = client.name.isNotEmpty ? client.name : 'Unknown';
return '"$name"';
}
String channelLabel(rust.BridgeSnapshot snapshot, BigInt channelId) {
final name = snapshotChannelName(snapshot, channelId);
if (name.isNotEmpty) return 'channel "$name"';
return 'channel ${channelId.toString()}';
}
void addEntry(String message) {
entries.add(
ChatEntry(
senderId: BigInt.zero,
senderName: 'Server',
message: message,
target: const rust.BridgeMessageTarget.server(),
timestamp: now,
countsTowardUnread: false,
),
);
}
for (final client in current.clients) {
if (client.isServerQuery) continue;
final previousClient = previousClients[client.id];
if (previousClient == null) {
addEntry(
'${clientLabel(client, current)} connected to ${channelLabel(current, client.channel)}',
);
continue;
}
if (previousClient.channel != client.channel) {
final fromChannel = channelLabel(previous, previousClient.channel);
final toChannel = channelLabel(current, client.channel);
addEntry(
'${clientLabel(client, current)} switched from $fromChannel to $toChannel',
);
continue;
}
if (previousClient.name != client.name && client.name.isNotEmpty) {
final previousName = previousClient.id == current.ownClientId
? 'You'
: (previousClient.name.isNotEmpty ? previousClient.name : 'Unknown');
final currentName = clientLabel(client, current);
addEntry('$previousName is now $currentName');
}
}
for (final client in previous.clients) {
if (client.isServerQuery) continue;
if (currentClients.containsKey(client.id)) continue;
addEntry(
'${clientLabel(client, previous)} disconnected from ${channelLabel(previous, client.channel)}',
);
}
for (final channel in current.channels) {
final previousChannel = previousChannels[channel.id];
if (previousChannel == null) {
addEntry('Channel created: ${channelLabel(current, channel.id)}');
continue;
}
if (previousChannel.name != channel.name) {
addEntry('Channel renamed to ${channelLabel(current, channel.id)}');
continue;
}
if (previousChannel.parent != channel.parent ||
previousChannel.order != channel.order) {
addEntry('Channel moved: ${channelLabel(current, channel.id)}');
}
}
for (final channel in previous.channels) {
if (currentChannels.containsKey(channel.id)) continue;
addEntry('Channel deleted: ${channelLabel(previous, channel.id)}');
}
return entries;
}
class ChatClientGroups {
const ChatClientGroups({
required this.clientsByChannel,
@@ -255,6 +533,9 @@ class ChatPage extends StatefulWidget {
super.key,
required this.messages,
required this.snapshot,
this.messagesSource,
this.snapshotSource,
this.refreshListenable,
this.initialTarget,
this.initialClientName = '',
this.onTs3ServerLink,
@@ -266,6 +547,15 @@ class ChatPage extends StatefulWidget {
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Optional live source for messages while this route stays open.
final List<ChatEntry> Function()? messagesSource;
/// Optional live source for snapshots while this route stays open.
final rust.BridgeSnapshot Function()? snapshotSource;
/// Triggers a rebuild when the backing chat state changes.
final Listenable? refreshListenable;
/// Non-null to open a specific target directly; null for hub.
final rust.BridgeMessageTarget? initialTarget;
@@ -284,13 +574,19 @@ class _ChatPageState extends State<ChatPage> {
String _selectedClientName = '';
final Set<BigInt> _closedPrivateChats = {};
List<ChatEntry> get _messages =>
widget.messagesSource?.call() ?? widget.messages;
rust.BridgeSnapshot get _snapshot =>
widget.snapshotSource?.call() ?? widget.snapshot;
@override
void initState() {
super.initState();
_selectedTarget =
widget.initialTarget ??
resolveInitialChatTarget(
messages: widget.messages,
messages: _messages,
currentVoiceChannelId: _currentChannelId,
) ??
const rust.BridgeMessageTarget.server();
@@ -298,16 +594,28 @@ class _ChatPageState extends State<ChatPage> {
}
BigInt? get _currentChannelId {
return ownClientSnapshotState(widget.snapshot)?.channelId;
return ownClientSnapshotState(_snapshot)?.channelId;
}
@override
Widget build(BuildContext context) {
if (widget.refreshListenable != null) {
return ListenableBuilder(
listenable: widget.refreshListenable!,
builder: (context, _) => _buildScaffold(context),
);
}
return _buildScaffold(context);
}
Widget _buildScaffold(BuildContext context) {
final snapshot = _snapshot;
final messages = _messages;
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(widget.snapshot, currentChannelId);
final channelName = snapshotChannelName(snapshot, currentChannelId);
return Scaffold(
appBar: AppBar(
title: Text('Chat — ${widget.snapshot.serverName}'),
title: Text('Chat — ${snapshot.serverName}'),
actions: [
IconButton(
tooltip: 'Close chat',
@@ -334,8 +642,8 @@ class _ChatPageState extends State<ChatPage> {
child: _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: widget.snapshot,
messages: widget.messages,
snapshot: snapshot,
messages: messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
@@ -353,7 +661,7 @@ class _ChatPageState extends State<ChatPage> {
List<_PrivateChatItem> get _privateChats {
final chats = <BigInt, _PrivateChatItem>{};
for (final message in widget.messages) {
for (final message in _messages) {
final target = message.target;
if (target is! rust.BridgeMessageTarget_Client) continue;
final id = target.field0;
@@ -378,7 +686,7 @@ class _ChatPageState extends State<ChatPage> {
}
String _privateChatName(BigInt id, String fallback) {
for (final client in widget.snapshot.clients) {
for (final client in _snapshot.clients) {
if (client.id == id && client.name.isNotEmpty) return client.name;
}
return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct';
@@ -443,7 +751,7 @@ class _ChatSidebar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: 148,
width: _chatSidebarTileExtent,
child: Column(
children: [
_ChatSidebarItem(
@@ -513,30 +821,45 @@ class _ChatSidebarItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = selected
? theme.colorScheme.primaryContainer
: Colors.transparent;
final fg = selected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface;
final fg = selected ? Colors.white : theme.colorScheme.onSurface;
return Material(
color: bg,
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 44,
borderRadius: BorderRadius.circular(8),
child: Ink(
width: _chatSidebarTileExtent,
height: _chatSidebarTileExtent,
decoration: BoxDecoration(
color: selected
? _chatSidebarSelectedTileColor
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: fg),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: fg),
SizedBox(
width: 24,
height: 24,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [Icon(icon, size: 18, color: fg)],
),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
height: 1.1,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
@@ -693,6 +1016,8 @@ class _ChatDetailView extends StatefulWidget {
class _ChatDetailViewState extends State<_ChatDetailView> {
final _textCtl = TextEditingController();
final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1;
rust.BridgeMessageTarget? _lastRenderedTarget;
Iterable<ChatEntry> get _filtered {
if (widget.target is rust.BridgeMessageTarget_Channel) {
@@ -758,10 +1083,22 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
}
}
void _scheduleScrollIfNeeded(int messageCount) {
final targetChanged = _lastRenderedTarget != widget.target;
final countChanged = _lastRenderedMessageCount != messageCount;
_lastRenderedTarget = widget.target;
_lastRenderedMessageCount = messageCount;
if (!targetChanged && !countChanged) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scrollToBottom();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final msgs = _filtered.toList();
_scheduleScrollIfNeeded(msgs.length);
final placeholder = chatInputPlaceholder(
widget.target,
channelName: widget.channelName,
@@ -882,10 +1219,13 @@ class _MessageBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (entry.isPoke) {
return _PokeHistoryRow(entry: entry);
}
if (entry.isServerActivity) {
return _ServerActivityRow(entry: entry);
}
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
@@ -947,6 +1287,61 @@ class _MessageBubble extends StatelessWidget {
}
}
class _ServerActivityRow extends StatelessWidget {
const _ServerActivityRow({required this.entry});
final ChatEntry entry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final timestampStyle = theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF8A98A8),
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w400,
letterSpacing: 0.15,
fontFeatures: const [FontFeature.tabularFigures()],
);
final baseStyle = theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6C7C8F),
fontWeight: FontWeight.w400,
fontSize: 13,
height: 1.45,
letterSpacing: 0.05,
);
final spans = <InlineSpan>[
TextSpan(
text: '<${chatTimeLabel(entry.timestamp)}> ',
style: timestampStyle,
),
];
for (final segment in _activitySegments(entry.message)) {
spans.add(
TextSpan(
text: segment.text,
style: baseStyle?.copyWith(
color: _activityColor(context, segment.tone),
fontWeight: segment.bold ? FontWeight.w700 : FontWeight.w400,
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFF7F9FC),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text.rich(TextSpan(children: spans)),
),
),
);
}
}
class _PokeHistoryRow extends StatelessWidget {
const _PokeHistoryRow({required this.entry});
@@ -26,6 +26,21 @@ import 'voice_settings_controls.dart';
import 'voice_status_summary.dart';
import '../src/rust/api.dart' as rust;
bool get _isIos {
if (kIsWeb) return false;
return Platform.isIOS;
}
bool get _isMacOS {
if (kIsWeb) return false;
return Platform.isMacOS;
}
bool get _isDesktopSileroVadHost {
if (kIsWeb) return false;
return Platform.isWindows || Platform.isLinux;
}
/// Two-line status chip that summarises the current voice state.
/// Tap to open the voice details modal.
class VoiceStatusChip extends StatelessWidget {
@@ -448,17 +463,17 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
widget.initialAudioConfig,
);
// Poll audio stats at 80 ms so TX/RX counters and the level meter
// Poll audio stats at 250 ms so TX/RX counters and the level meter
// update in real time while the sheet is open, independent of the parent.
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() {
_stats = s;
_rateTickCount++;
// Compute rates every ~960 ms (12 × 80 ms).
if (_rateTickCount >= 12) {
// Compute rates every ~1 s (4 × 250 ms).
if (_rateTickCount >= 4) {
_txRate = s.framesSent - _prevSent;
_rxRate = s.framesReceived - _prevReceived;
_prevSent = s.framesSent;
@@ -657,103 +672,99 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_notifyAudioConfig();
},
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
: 'Software mode applies the full WebRTC APM stage set.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: _audioProcessing.nsEnabled,
onChanged: (v) {
setState(() => _audioProcessing.nsEnabled = v);
_notifyAudioConfig();
},
),
AudioProcessingToggleRow(
dense: true,
label: 'Echo cancellation',
subtitle: () {
if (Platform.isAndroid) {
return 'WebRTC AEC3 · adaptive filter';
}
return widget.initialAudioConfig.iosMode ==
rust
.BridgeIosVoiceProcessingMode
.platformVoiceProcessing
? 'Always on · managed by platform VPIO'
: 'AEC3 adaptive filter · 80 ms tail';
}(),
value: () {
if (Platform.isAndroid) return _audioProcessing.aecEnabled;
return widget.initialAudioConfig.iosMode ==
rust
.BridgeIosVoiceProcessingMode
.platformVoiceProcessing
? true
: _audioProcessing.aecEnabled;
}(),
onChanged: () {
if (Platform.isAndroid) {
return (v) {
setState(() => _audioProcessing.aecEnabled = v);
_notifyAudioConfig();
};
}
return widget.initialAudioConfig.iosMode ==
rust
.BridgeIosVoiceProcessingMode
.platformVoiceProcessing
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
],
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: _audioProcessing.nsEnabled,
onChanged: (v) {
setState(() => _audioProcessing.nsEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Echo cancellation',
subtitle: Platform.isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) {
setState(() => _audioProcessing.aecEnabled = v);
_notifyAudioConfig();
};
}(),
),
AudioProcessingToggleRow(
dense: true,
label: 'Auto gain control',
subtitle: 'AGC2 · 18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) {
setState(() => _audioProcessing.agcEnabled = v);
_notifyAudioConfig();
},
),
AudioProcessingToggleRow(
dense: true,
label: 'High-pass filter',
subtitle: '80 Hz · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) {
setState(() => _audioProcessing.hpfEnabled = v);
_notifyAudioConfig();
},
),
AudioProcessingToggleRow(
dense: true,
label: 'Peak limiter',
subtitle: '1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) {
setState(() => _audioProcessing.limiterEnabled = v);
_notifyAudioConfig();
},
),
// iOS mode selector.
if (Platform.isIOS) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
style: voiceSegmentedButtonStyle(theme),
segments: iosProcessingSegments,
selected: {_audioProcessing.iosMode},
onSelectionChanged: (s) {
setState(() => _audioProcessing.iosMode = s.first);
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Auto gain control',
subtitle: 'AGC2 · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) {
setState(() => _audioProcessing.agcEnabled = v);
_notifyAudioConfig();
},
),
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
dense: true,
label: 'High-pass filter',
subtitle: '80 Hz · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) {
setState(() => _audioProcessing.hpfEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) {
setState(() => _audioProcessing.limiterEnabled = v);
_notifyAudioConfig();
},
),
],
// VAD backend.
const SizedBox(height: 8),
@@ -766,7 +777,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegments,
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(() => _audioProcessing.vadBackend = s.first);
@@ -5,7 +5,7 @@
// - Automatic gain control (AGC2)
// - High-pass filter (HPF)
// - VAD backend
// - iOS voice processing mode
// - platform audio-processing mode selection where available
import 'dart:io' show Platform;
@@ -32,6 +32,16 @@ bool get _isIos {
return Platform.isIOS;
}
bool get _isMacOS {
if (kIsWeb) return false;
return Platform.isMacOS;
}
bool get _isDesktopSileroVadHost {
if (kIsWeb) return false;
return Platform.isWindows || Platform.isLinux;
}
/// Result returned by [VoiceSettingsDialog].
class VoiceSettingsResult {
const VoiceSettingsResult({
@@ -104,9 +114,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final platformVpio =
_audioProcessing.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
return AlertDialog(
title: Text(l10n.voiceSettingsTitle),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
@@ -177,19 +184,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const Divider(height: 24),
const VoiceSectionHeader('Audio processing'),
// iOS mode selector (iOS only)
if (_isIos) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
style: voiceSegmentedButtonStyle(theme),
segments: iosProcessingSegments,
selected: {_audioProcessing.iosMode},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.iosMode = s.first),
),
const SizedBox(height: 4),
],
// Android HW/SW selector
if (_isAndroid) ...[
const VoiceSubHeader('Processing backend'),
@@ -201,51 +195,81 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
setState(() => _audioProcessing.preferHardware = s.first),
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
: 'Android software mode applies the full WebRTC APM control set.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
],
// DSP toggles
const VoiceSubHeader('DSP stages'),
AudioProcessingToggleRow(
label: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _audioProcessing.nsEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.nsEnabled = v),
),
AudioProcessingToggleRow(
label: 'Echo cancellation (AEC3)',
subtitle: _isAndroid
? 'WebRTC AEC3 · adaptive filter'
: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _audioProcessing.aecEnabled,
// AEC is always on in VPIO mode — disable the toggle.
onChanged: (_isAndroid || !platformVpio)
? (v) => setState(() => _audioProcessing.aecEnabled = v)
: null,
),
AudioProcessingToggleRow(
label: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · 18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.agcEnabled = v),
),
AudioProcessingToggleRow(
label: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.hpfEnabled = v),
),
AudioProcessingToggleRow(
label: 'Peak limiter',
subtitle: '1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.limiterEnabled = v),
),
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
],
if (!_isIos &&
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _audioProcessing.nsEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.nsEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Echo cancellation (AEC3)',
subtitle: _isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) => setState(() => _audioProcessing.aecEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.agcEnabled = v),
),
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
label: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.hpfEnabled = v),
),
if (!_isIos &&
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.limiterEnabled = v),
),
if (isTalkPowerBlocked(
talkPower: widget.talkPower,
@@ -267,7 +291,9 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegments,
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
@@ -43,20 +43,6 @@ const androidProcessingSegments = [
),
];
/// iOS VPIO/Sonora selector segments.
const iosProcessingSegments = [
ButtonSegment(
value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
label: Text('VPIO'),
icon: Icon(Icons.phone_iphone, size: 14),
),
ButtonSegment(
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
label: Text('Sonora'),
icon: Icon(Icons.science_outlined, size: 14),
),
];
/// Voice activity detector selector segments.
const vadBackendSegments = [
ButtonSegment(
@@ -69,10 +55,17 @@ const vadBackendSegments = [
label: Text('Silero'),
icon: Icon(Icons.psychology, size: 14),
),
];
/// Desktop VAD selector segments.
///
/// Windows and Linux use Silero as the primary VAD. WebRTC remains an
/// internal runtime fallback when the model/runtime is unavailable.
const desktopVadBackendSegments = [
ButtonSegment(
value: rust.BridgeVadBackend.tenVad,
label: Text('TEN'),
icon: Icon(Icons.graphic_eq, size: 14),
value: rust.BridgeVadBackend.sileroOnnx,
label: Text('Silero'),
icon: Icon(Icons.psychology, size: 14),
),
];