feat: stabilize voice activity and audio routing
This commit is contained in:
@@ -108,7 +108,8 @@ class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
|
||||
s.platformVoiceProcessingEnabled ? 'on' : 'off',
|
||||
Colors.white,
|
||||
),
|
||||
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
|
||||
if (s.processingBackend == BridgeAudioBackend.sonora)
|
||||
_row('sonora', s.sonoraEnabled ? 'on' : 'off', Colors.white),
|
||||
const SizedBox(height: 4),
|
||||
_row(
|
||||
'mic in',
|
||||
@@ -184,7 +185,6 @@ class _AudioDebugStatsPanelState extends State<AudioDebugStatsPanel> {
|
||||
String _vadBackendLabel(BridgeVadBackend backend) => switch (backend) {
|
||||
BridgeVadBackend.webrtcVad => 'webrtc',
|
||||
BridgeVadBackend.sileroOnnx => 'silero',
|
||||
BridgeVadBackend.tenVad => 'ten',
|
||||
BridgeVadBackend.energyDebug => 'energy',
|
||||
BridgeVadBackend.disabled => 'off',
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import '../src/rust/api.dart' as rust;
|
||||
/// Loads available input/output audio devices.
|
||||
typedef AudioDeviceListLoader = Future<rust.BridgeAudioDeviceList> Function();
|
||||
|
||||
/// Persists a selected audio device name.
|
||||
typedef AudioDeviceSetter = Future<void> Function({String? name});
|
||||
/// Persists a selected audio device id.
|
||||
typedef AudioDeviceSetter = Future<void> Function({String? id});
|
||||
|
||||
/// Which desktop audio device group this tile manages.
|
||||
enum AudioDeviceKind {
|
||||
@@ -52,6 +52,7 @@ class AudioDeviceListTile extends StatefulWidget {
|
||||
|
||||
class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
||||
List<rust.BridgeAudioDevice> _devices = [];
|
||||
String? _selectedDeviceId;
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
@@ -68,28 +69,56 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
||||
AudioDeviceKind.input => list.inputDevices,
|
||||
AudioDeviceKind.output => list.outputDevices,
|
||||
};
|
||||
_selectedDeviceId = _devices
|
||||
.where((device) => device.isSelected)
|
||||
.firstOrNull
|
||||
?.id;
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectDevice(rust.BridgeAudioDevice device) async {
|
||||
Future<void> _selectDevice(String? deviceId) async {
|
||||
switch (widget.kind) {
|
||||
case AudioDeviceKind.input:
|
||||
await widget.setInputDevice(name: device.name);
|
||||
await widget.setInputDevice(id: deviceId);
|
||||
case AudioDeviceKind.output:
|
||||
await widget.setOutputDevice(name: device.name);
|
||||
await widget.setOutputDevice(id: deviceId);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedDeviceId = deviceId;
|
||||
});
|
||||
|
||||
final selectedName = _selectedDevice?.name ?? 'System default';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${widget.label} set to ${device.name}'),
|
||||
content: Text('${widget.label} set to $selectedName'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
rust.BridgeAudioDevice? get _selectedDevice {
|
||||
if (_selectedDeviceId == null) {
|
||||
return null;
|
||||
}
|
||||
for (final device in _devices) {
|
||||
if (device.id == _selectedDeviceId) {
|
||||
return device;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedDevice = _selectedDevice;
|
||||
final subtitleText = selectedDevice?.name ?? 'System default';
|
||||
final leadingIcon = switch (widget.kind) {
|
||||
AudioDeviceKind.input => Icons.mic_none,
|
||||
AudioDeviceKind.output => Icons.headphones,
|
||||
};
|
||||
|
||||
if (!_loaded) {
|
||||
return ListTile(
|
||||
title: Text(widget.label),
|
||||
@@ -110,17 +139,31 @@ class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
||||
}
|
||||
return ExpansionTile(
|
||||
title: Text(widget.label),
|
||||
subtitle: Text('${_devices.length} available'),
|
||||
leading: const Icon(Icons.headphones, size: 18),
|
||||
subtitle: Text(subtitleText),
|
||||
leading: Icon(leadingIcon, size: 18),
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: const Text('System default', style: TextStyle(fontSize: 13)),
|
||||
subtitle: const Text('Use the OS default device'),
|
||||
trailing: _selectedDeviceId == null
|
||||
? const Icon(Icons.check, size: 16, color: Colors.green)
|
||||
: null,
|
||||
onTap: _selectedDeviceId == null ? null : () => _selectDevice(null),
|
||||
),
|
||||
for (final device in _devices)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(device.name, style: const TextStyle(fontSize: 13)),
|
||||
trailing: device.isDefault
|
||||
subtitle: device.details.isEmpty ? null : Text(device.details),
|
||||
trailing: device.id == _selectedDeviceId
|
||||
? const Icon(Icons.check, size: 16, color: Colors.green)
|
||||
: device.isDefault
|
||||
? const Icon(Icons.radio_button_checked, size: 16)
|
||||
: null,
|
||||
onTap: device.isDefault ? null : () => _selectDevice(device),
|
||||
onTap: device.id == _selectedDeviceId
|
||||
? null
|
||||
: () => _selectDevice(device.id),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,21 +3,32 @@ import 'dart:io' show Platform;
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Fallback audio-processing config used before the bridge can report one.
|
||||
const defaultAudioProcessingConfig = rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
rust.BridgeAudioProcessingConfig defaultAudioProcessingConfig() {
|
||||
final desktopWebrtcApm = Platform.isWindows || Platform.isLinux;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: desktopWebrtcApm
|
||||
? rust.BridgeAudioBackend.webrtcApm
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: desktopWebrtcApm
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.platform,
|
||||
ns: desktopWebrtcApm
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.platform,
|
||||
agc: desktopWebrtcApm
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mutable UI state for audio-processing controls.
|
||||
class AudioProcessingConfigState {
|
||||
@@ -31,7 +42,7 @@ class AudioProcessingConfigState {
|
||||
debugWavDump = config.debugWavDumpEnabled,
|
||||
preferHardware = _usesPlatformEffects(config),
|
||||
vadBackend = normalizedVadBackend(config.vadBackend),
|
||||
iosMode = config.iosMode;
|
||||
iosMode = normalizedIosProcessingMode(config.iosMode);
|
||||
|
||||
/// Noise suppression toggle.
|
||||
bool nsEnabled;
|
||||
@@ -64,9 +75,23 @@ class AudioProcessingConfigState {
|
||||
rust.BridgeAudioProcessingConfig buildConfig({
|
||||
required rust.BridgeAudioProcessingConfig base,
|
||||
bool? isAndroid,
|
||||
bool? isIos,
|
||||
bool? isMacOS,
|
||||
bool? isWindows,
|
||||
bool? isLinux,
|
||||
}) {
|
||||
final android = isAndroid ?? Platform.isAndroid;
|
||||
final vad = normalizedVadBackend(vadBackend);
|
||||
final ios = isIos ?? Platform.isIOS;
|
||||
final macOS = isMacOS ?? Platform.isMacOS;
|
||||
final windows = isWindows ?? Platform.isWindows;
|
||||
final linux = isLinux ?? Platform.isLinux;
|
||||
final appleVoiceProcessing = ios || macOS;
|
||||
final desktopWebrtcApm = windows || linux;
|
||||
final vad = normalizedVadBackend(
|
||||
vadBackend,
|
||||
isWindows: windows,
|
||||
isLinux: linux,
|
||||
);
|
||||
|
||||
if (android) {
|
||||
final owner = preferHardware
|
||||
@@ -74,7 +99,7 @@ class AudioProcessingConfigState {
|
||||
: rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: iosMode,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm,
|
||||
@@ -91,38 +116,55 @@ class AudioProcessingConfigState {
|
||||
);
|
||||
}
|
||||
|
||||
final isSonora =
|
||||
iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
final aecOwner = isSonora
|
||||
? (aecEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform;
|
||||
final nsOwner = isSonora
|
||||
? (nsEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (agcEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
if (appleVoiceProcessing) {
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vad,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
vadPreRollMs: base.vadPreRollMs,
|
||||
vadMinTxMs: base.vadMinTxMs,
|
||||
debugWavDumpEnabled: debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
if (desktopWebrtcApm) {
|
||||
final owner = rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: vad,
|
||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
vadPreRollMs: base.vadPreRollMs,
|
||||
vadMinTxMs: base.vadMinTxMs,
|
||||
debugWavDumpEnabled: debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: iosMode,
|
||||
processingBackend: isSonora
|
||||
? rust.BridgeAudioBackend.webrtcApm
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
iosMode: normalizedIosProcessingMode(iosMode),
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vad,
|
||||
aec: aecOwner,
|
||||
ns: nsOwner,
|
||||
agc: agcOwner,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off,
|
||||
agc: agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
@@ -133,13 +175,55 @@ class AudioProcessingConfigState {
|
||||
}
|
||||
}
|
||||
|
||||
bool androidUsesHardwareProcessing(AudioProcessingConfigState state) {
|
||||
return state.preferHardware;
|
||||
}
|
||||
|
||||
bool androidShowsNsControl(AudioProcessingConfigState state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool androidShowsAecControl(AudioProcessingConfigState state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool androidShowsAgcControl(AudioProcessingConfigState state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool androidShowsHpfControl(AudioProcessingConfigState state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool androidShowsLimiterControl(AudioProcessingConfigState state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Never leave the UI on the hidden disabled backend.
|
||||
rust.BridgeVadBackend normalizedVadBackend(rust.BridgeVadBackend backend) {
|
||||
///
|
||||
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
|
||||
/// available internally as a runtime fallback.
|
||||
rust.BridgeVadBackend normalizedVadBackend(
|
||||
rust.BridgeVadBackend backend, {
|
||||
bool? isWindows,
|
||||
bool? isLinux,
|
||||
}) {
|
||||
final desktop =
|
||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
|
||||
return backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
? rust.BridgeVadBackend.sileroOnnx
|
||||
: backend;
|
||||
}
|
||||
|
||||
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
|
||||
rust.BridgeIosVoiceProcessingMode mode,
|
||||
) {
|
||||
return mode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||
? mode
|
||||
: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
}
|
||||
|
||||
bool _usesPlatformEffects(rust.BridgeAudioProcessingConfig config) {
|
||||
return config.aec == rust.BridgeEffectOwner.platform ||
|
||||
config.ns == rust.BridgeEffectOwner.platform ||
|
||||
|
||||
@@ -10,6 +10,9 @@ import '../services/ts3_server_link.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
|
||||
const double _chatSidebarTileExtent = 92;
|
||||
const Color _chatSidebarSelectedTileColor = Color(0xFF415366);
|
||||
|
||||
/// One chat/activity message shown in the chat hub.
|
||||
class ChatEntry {
|
||||
/// Construct a chat entry.
|
||||
@@ -20,6 +23,7 @@ class ChatEntry {
|
||||
required this.target,
|
||||
this.isSelf = false,
|
||||
this.timestamp,
|
||||
this.countsTowardUnread = true,
|
||||
});
|
||||
|
||||
/// Sender client id.
|
||||
@@ -40,11 +44,18 @@ class ChatEntry {
|
||||
/// Local time when this message was received or sent.
|
||||
final DateTime? timestamp;
|
||||
|
||||
/// Whether this entry should increment the message unread badge.
|
||||
final bool countsTowardUnread;
|
||||
|
||||
/// True for direct-message targets.
|
||||
bool get isPrivate => target is rust.BridgeMessageTarget_Client;
|
||||
|
||||
/// True for poke targets.
|
||||
bool get isPoke => target is rust.BridgeMessageTarget_Poke;
|
||||
|
||||
/// True for synthetic or protocol-driven server activity rows.
|
||||
bool get isServerActivity =>
|
||||
target is rust.BridgeMessageTarget_Server && !countsTowardUnread;
|
||||
}
|
||||
|
||||
String chatTimeLabel(DateTime? timestamp) {
|
||||
@@ -69,6 +80,162 @@ String pokeHistoryLine(AppL10n l10n, ChatEntry entry) {
|
||||
return l10n.pokeHistoryIncomingWithMessage(time, peerName, message);
|
||||
}
|
||||
|
||||
enum _ActivityTone {
|
||||
positive,
|
||||
transition,
|
||||
warning,
|
||||
plain,
|
||||
username,
|
||||
channel,
|
||||
adminActor,
|
||||
automationActor,
|
||||
group,
|
||||
}
|
||||
|
||||
sealed class _ActivitySegment {
|
||||
const _ActivitySegment(this.text, this.tone, {this.bold = false});
|
||||
|
||||
final String text;
|
||||
final _ActivityTone tone;
|
||||
final bool bold;
|
||||
}
|
||||
|
||||
class _ActivityTextSegment extends _ActivitySegment {
|
||||
const _ActivityTextSegment(super.text, super.tone, {super.bold = false});
|
||||
}
|
||||
|
||||
List<_ActivitySegment> _activitySegments(String message) {
|
||||
final quotedMatches = RegExp(r'"[^"]+"').allMatches(message).toList();
|
||||
if (quotedMatches.isEmpty) {
|
||||
return [_ActivityTextSegment(message, _activityActionTone(message))];
|
||||
}
|
||||
|
||||
final segments = <_ActivitySegment>[];
|
||||
var cursor = 0;
|
||||
for (var i = 0; i < quotedMatches.length; i++) {
|
||||
final match = quotedMatches[i];
|
||||
if (match.start > cursor) {
|
||||
final plain = message.substring(cursor, match.start);
|
||||
if (plain.isNotEmpty) {
|
||||
segments.add(
|
||||
_ActivityTextSegment(plain, _activityActionTone(plain.trim())),
|
||||
);
|
||||
}
|
||||
}
|
||||
final quoted = match.group(0)!;
|
||||
segments.add(_activityEntitySegment(quoted, message, i));
|
||||
cursor = match.end;
|
||||
}
|
||||
if (cursor < message.length) {
|
||||
final tail = message.substring(cursor);
|
||||
if (tail.isNotEmpty) {
|
||||
segments.add(
|
||||
_ActivityTextSegment(tail, _activityActionTone(tail.trim())),
|
||||
);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
_ActivitySegment _activityEntitySegment(
|
||||
String quoted,
|
||||
String fullMessage,
|
||||
int quotedIndex,
|
||||
) {
|
||||
final lower = fullMessage.toLowerCase();
|
||||
final after = fullMessage
|
||||
.substring(fullMessage.indexOf(quoted) + quoted.length)
|
||||
.toLowerCase();
|
||||
final value = quoted.substring(1, quoted.length - 1);
|
||||
|
||||
if (lower.contains('channel group ')) {
|
||||
if (quotedIndex == 0) {
|
||||
return _ActivityTextSegment(quoted, _ActivityTone.group);
|
||||
}
|
||||
return _activityActorSegment(value, quoted);
|
||||
}
|
||||
if (lower.contains('server group ')) {
|
||||
if (quotedIndex == 0) {
|
||||
return _ActivityTextSegment(quoted, _ActivityTone.group);
|
||||
}
|
||||
return _activityActorSegment(value, quoted);
|
||||
}
|
||||
if (after.startsWith(' connected to channel') ||
|
||||
after.startsWith(' switched from channel') ||
|
||||
after.startsWith(' disconnected') ||
|
||||
after.startsWith(' was moved from channel') ||
|
||||
after.startsWith(' is now ')) {
|
||||
return _activityActorSegment(value, quoted);
|
||||
}
|
||||
if (lower.contains('channel ') &&
|
||||
(after.startsWith('.') ||
|
||||
after.startsWith(' to') ||
|
||||
after.startsWith(' from') ||
|
||||
after.startsWith(' by') ||
|
||||
after.isEmpty)) {
|
||||
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
|
||||
}
|
||||
if (lower.contains('channel ') &&
|
||||
(quotedIndex == 1 || quotedIndex == 2 || quotedIndex == 3)) {
|
||||
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
|
||||
}
|
||||
return _activityActorSegment(value, quoted);
|
||||
}
|
||||
|
||||
_ActivitySegment _activityActorSegment(String value, String quoted) {
|
||||
final lower = value.toLowerCase();
|
||||
if (lower.contains('auto') ||
|
||||
lower.contains('automation') ||
|
||||
lower.contains('bot')) {
|
||||
return _ActivityTextSegment(
|
||||
quoted,
|
||||
_ActivityTone.automationActor,
|
||||
bold: true,
|
||||
);
|
||||
}
|
||||
if (lower.contains('server') ||
|
||||
lower.contains('admin') ||
|
||||
lower.contains('vigorous pro')) {
|
||||
return _ActivityTextSegment(quoted, _ActivityTone.adminActor, bold: true);
|
||||
}
|
||||
return _ActivityTextSegment(quoted, _ActivityTone.username, bold: true);
|
||||
}
|
||||
|
||||
_ActivityTone _activityActionTone(String text) {
|
||||
final lower = text.toLowerCase();
|
||||
if (lower.contains('connected')) return _ActivityTone.positive;
|
||||
if (lower.contains('disconnected') ||
|
||||
lower.contains('dropped') ||
|
||||
lower.contains('lost') ||
|
||||
lower.contains('shutdown')) {
|
||||
return _ActivityTone.warning;
|
||||
}
|
||||
if (lower.contains('switched') ||
|
||||
lower.contains('moved') ||
|
||||
lower.contains('created') ||
|
||||
lower.contains('deleted') ||
|
||||
lower.contains('renamed') ||
|
||||
lower.contains('assigned') ||
|
||||
lower.contains('removed')) {
|
||||
return _ActivityTone.transition;
|
||||
}
|
||||
return _ActivityTone.plain;
|
||||
}
|
||||
|
||||
Color _activityColor(BuildContext context, _ActivityTone tone) {
|
||||
return switch (tone) {
|
||||
_ActivityTone.positive => const Color(0xFF2F7D57),
|
||||
_ActivityTone.transition => const Color(0xFF5A7394),
|
||||
_ActivityTone.warning => const Color(0xFFC06A2B),
|
||||
_ActivityTone.plain => const Color(0xFF6C7C8F),
|
||||
_ActivityTone.username => const Color(0xFFC64A4A),
|
||||
_ActivityTone.channel => const Color(0xFF3B6EA8),
|
||||
_ActivityTone.adminActor => const Color(0xFF426B9A),
|
||||
_ActivityTone.automationActor => const Color(0xFFB64C4C),
|
||||
_ActivityTone.group => const Color(0xFF8B7A68),
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolve the best chat target to show when opening the chat page.
|
||||
///
|
||||
/// Returns null to show the chat hub.
|
||||
@@ -76,15 +243,126 @@ rust.BridgeMessageTarget? resolveInitialChatTarget({
|
||||
required List<ChatEntry> messages,
|
||||
required BigInt? currentVoiceChannelId,
|
||||
}) {
|
||||
bool hasServerActivity = false;
|
||||
for (final message in messages.reversed) {
|
||||
if (message.isPrivate) return message.target;
|
||||
if (message.target is rust.BridgeMessageTarget_Server) {
|
||||
hasServerActivity = true;
|
||||
}
|
||||
}
|
||||
if (currentVoiceChannelId != null) {
|
||||
return const rust.BridgeMessageTarget.channel();
|
||||
}
|
||||
if (hasServerActivity) {
|
||||
return const rust.BridgeMessageTarget.server();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build server-activity entries from a fresh snapshot diff.
|
||||
List<ChatEntry> buildServerActivityEntries({
|
||||
required rust.BridgeSnapshot previous,
|
||||
required rust.BridgeSnapshot current,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
final now = timestamp ?? DateTime.now();
|
||||
final previousClients = {
|
||||
for (final client in previous.clients) client.id: client,
|
||||
};
|
||||
final currentClients = {
|
||||
for (final client in current.clients) client.id: client,
|
||||
};
|
||||
final previousChannels = {
|
||||
for (final channel in previous.channels) channel.id: channel,
|
||||
};
|
||||
final currentChannels = {
|
||||
for (final channel in current.channels) channel.id: channel,
|
||||
};
|
||||
final entries = <ChatEntry>[];
|
||||
|
||||
String clientLabel(rust.BridgeClient client, rust.BridgeSnapshot snapshot) {
|
||||
if (client.id == snapshot.ownClientId) return 'You';
|
||||
final name = client.name.isNotEmpty ? client.name : 'Unknown';
|
||||
return '"$name"';
|
||||
}
|
||||
|
||||
String channelLabel(rust.BridgeSnapshot snapshot, BigInt channelId) {
|
||||
final name = snapshotChannelName(snapshot, channelId);
|
||||
if (name.isNotEmpty) return 'channel "$name"';
|
||||
return 'channel ${channelId.toString()}';
|
||||
}
|
||||
|
||||
void addEntry(String message) {
|
||||
entries.add(
|
||||
ChatEntry(
|
||||
senderId: BigInt.zero,
|
||||
senderName: 'Server',
|
||||
message: message,
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
timestamp: now,
|
||||
countsTowardUnread: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final client in current.clients) {
|
||||
if (client.isServerQuery) continue;
|
||||
final previousClient = previousClients[client.id];
|
||||
if (previousClient == null) {
|
||||
addEntry(
|
||||
'${clientLabel(client, current)} connected to ${channelLabel(current, client.channel)}',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (previousClient.channel != client.channel) {
|
||||
final fromChannel = channelLabel(previous, previousClient.channel);
|
||||
final toChannel = channelLabel(current, client.channel);
|
||||
addEntry(
|
||||
'${clientLabel(client, current)} switched from $fromChannel to $toChannel',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (previousClient.name != client.name && client.name.isNotEmpty) {
|
||||
final previousName = previousClient.id == current.ownClientId
|
||||
? 'You'
|
||||
: (previousClient.name.isNotEmpty ? previousClient.name : 'Unknown');
|
||||
final currentName = clientLabel(client, current);
|
||||
addEntry('$previousName is now $currentName');
|
||||
}
|
||||
}
|
||||
|
||||
for (final client in previous.clients) {
|
||||
if (client.isServerQuery) continue;
|
||||
if (currentClients.containsKey(client.id)) continue;
|
||||
addEntry(
|
||||
'${clientLabel(client, previous)} disconnected from ${channelLabel(previous, client.channel)}',
|
||||
);
|
||||
}
|
||||
|
||||
for (final channel in current.channels) {
|
||||
final previousChannel = previousChannels[channel.id];
|
||||
if (previousChannel == null) {
|
||||
addEntry('Channel created: ${channelLabel(current, channel.id)}');
|
||||
continue;
|
||||
}
|
||||
if (previousChannel.name != channel.name) {
|
||||
addEntry('Channel renamed to ${channelLabel(current, channel.id)}');
|
||||
continue;
|
||||
}
|
||||
if (previousChannel.parent != channel.parent ||
|
||||
previousChannel.order != channel.order) {
|
||||
addEntry('Channel moved: ${channelLabel(current, channel.id)}');
|
||||
}
|
||||
}
|
||||
|
||||
for (final channel in previous.channels) {
|
||||
if (currentChannels.containsKey(channel.id)) continue;
|
||||
addEntry('Channel deleted: ${channelLabel(previous, channel.id)}');
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
class ChatClientGroups {
|
||||
const ChatClientGroups({
|
||||
required this.clientsByChannel,
|
||||
@@ -255,6 +533,9 @@ class ChatPage extends StatefulWidget {
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.snapshot,
|
||||
this.messagesSource,
|
||||
this.snapshotSource,
|
||||
this.refreshListenable,
|
||||
this.initialTarget,
|
||||
this.initialClientName = '',
|
||||
this.onTs3ServerLink,
|
||||
@@ -266,6 +547,15 @@ class ChatPage extends StatefulWidget {
|
||||
/// Latest TeamSpeak snapshot.
|
||||
final rust.BridgeSnapshot snapshot;
|
||||
|
||||
/// Optional live source for messages while this route stays open.
|
||||
final List<ChatEntry> Function()? messagesSource;
|
||||
|
||||
/// Optional live source for snapshots while this route stays open.
|
||||
final rust.BridgeSnapshot Function()? snapshotSource;
|
||||
|
||||
/// Triggers a rebuild when the backing chat state changes.
|
||||
final Listenable? refreshListenable;
|
||||
|
||||
/// Non-null to open a specific target directly; null for hub.
|
||||
final rust.BridgeMessageTarget? initialTarget;
|
||||
|
||||
@@ -284,13 +574,19 @@ class _ChatPageState extends State<ChatPage> {
|
||||
String _selectedClientName = '';
|
||||
final Set<BigInt> _closedPrivateChats = {};
|
||||
|
||||
List<ChatEntry> get _messages =>
|
||||
widget.messagesSource?.call() ?? widget.messages;
|
||||
|
||||
rust.BridgeSnapshot get _snapshot =>
|
||||
widget.snapshotSource?.call() ?? widget.snapshot;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedTarget =
|
||||
widget.initialTarget ??
|
||||
resolveInitialChatTarget(
|
||||
messages: widget.messages,
|
||||
messages: _messages,
|
||||
currentVoiceChannelId: _currentChannelId,
|
||||
) ??
|
||||
const rust.BridgeMessageTarget.server();
|
||||
@@ -298,16 +594,28 @@ class _ChatPageState extends State<ChatPage> {
|
||||
}
|
||||
|
||||
BigInt? get _currentChannelId {
|
||||
return ownClientSnapshotState(widget.snapshot)?.channelId;
|
||||
return ownClientSnapshotState(_snapshot)?.channelId;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.refreshListenable != null) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.refreshListenable!,
|
||||
builder: (context, _) => _buildScaffold(context),
|
||||
);
|
||||
}
|
||||
return _buildScaffold(context);
|
||||
}
|
||||
|
||||
Widget _buildScaffold(BuildContext context) {
|
||||
final snapshot = _snapshot;
|
||||
final messages = _messages;
|
||||
final currentChannelId = _currentChannelId;
|
||||
final channelName = snapshotChannelName(widget.snapshot, currentChannelId);
|
||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat — ${widget.snapshot.serverName}'),
|
||||
title: Text('Chat — ${snapshot.serverName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close chat',
|
||||
@@ -334,8 +642,8 @@ class _ChatPageState extends State<ChatPage> {
|
||||
child: _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: widget.snapshot,
|
||||
messages: widget.messages,
|
||||
snapshot: snapshot,
|
||||
messages: messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
@@ -353,7 +661,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
|
||||
List<_PrivateChatItem> get _privateChats {
|
||||
final chats = <BigInt, _PrivateChatItem>{};
|
||||
for (final message in widget.messages) {
|
||||
for (final message in _messages) {
|
||||
final target = message.target;
|
||||
if (target is! rust.BridgeMessageTarget_Client) continue;
|
||||
final id = target.field0;
|
||||
@@ -378,7 +686,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
}
|
||||
|
||||
String _privateChatName(BigInt id, String fallback) {
|
||||
for (final client in widget.snapshot.clients) {
|
||||
for (final client in _snapshot.clients) {
|
||||
if (client.id == id && client.name.isNotEmpty) return client.name;
|
||||
}
|
||||
return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct';
|
||||
@@ -443,7 +751,7 @@ class _ChatSidebar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 148,
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Column(
|
||||
children: [
|
||||
_ChatSidebarItem(
|
||||
@@ -513,30 +821,45 @@ class _ChatSidebarItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bg = selected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: Colors.transparent;
|
||||
final fg = selected
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface;
|
||||
final fg = selected ? Colors.white : theme.colorScheme.onSurface;
|
||||
return Material(
|
||||
color: bg,
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
width: _chatSidebarTileExtent,
|
||||
height: _chatSidebarTileExtent,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? _chatSidebarSelectedTileColor
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: fg),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: fg),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [Icon(icon, size: 18, color: fg)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
height: 1.1,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -693,6 +1016,8 @@ class _ChatDetailView extends StatefulWidget {
|
||||
class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
final _textCtl = TextEditingController();
|
||||
final _scrollCtl = ScrollController();
|
||||
int _lastRenderedMessageCount = -1;
|
||||
rust.BridgeMessageTarget? _lastRenderedTarget;
|
||||
|
||||
Iterable<ChatEntry> get _filtered {
|
||||
if (widget.target is rust.BridgeMessageTarget_Channel) {
|
||||
@@ -758,10 +1083,22 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleScrollIfNeeded(int messageCount) {
|
||||
final targetChanged = _lastRenderedTarget != widget.target;
|
||||
final countChanged = _lastRenderedMessageCount != messageCount;
|
||||
_lastRenderedTarget = widget.target;
|
||||
_lastRenderedMessageCount = messageCount;
|
||||
if (!targetChanged && !countChanged) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final msgs = _filtered.toList();
|
||||
_scheduleScrollIfNeeded(msgs.length);
|
||||
final placeholder = chatInputPlaceholder(
|
||||
widget.target,
|
||||
channelName: widget.channelName,
|
||||
@@ -882,10 +1219,13 @@ class _MessageBubble extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
if (entry.isPoke) {
|
||||
return _PokeHistoryRow(entry: entry);
|
||||
}
|
||||
if (entry.isServerActivity) {
|
||||
return _ServerActivityRow(entry: entry);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
@@ -947,6 +1287,61 @@ class _MessageBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerActivityRow extends StatelessWidget {
|
||||
const _ServerActivityRow({required this.entry});
|
||||
|
||||
final ChatEntry entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final timestampStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: const Color(0xFF8A98A8),
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.15,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
final baseStyle = theme.textTheme.bodySmall?.copyWith(
|
||||
color: const Color(0xFF6C7C8F),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
letterSpacing: 0.05,
|
||||
);
|
||||
final spans = <InlineSpan>[
|
||||
TextSpan(
|
||||
text: '<${chatTimeLabel(entry.timestamp)}> ',
|
||||
style: timestampStyle,
|
||||
),
|
||||
];
|
||||
for (final segment in _activitySegments(entry.message)) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: segment.text,
|
||||
style: baseStyle?.copyWith(
|
||||
color: _activityColor(context, segment.tone),
|
||||
fontWeight: segment.bold ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F9FC),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text.rich(TextSpan(children: spans)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PokeHistoryRow extends StatelessWidget {
|
||||
const _PokeHistoryRow({required this.entry});
|
||||
|
||||
|
||||
@@ -26,6 +26,21 @@ import 'voice_settings_controls.dart';
|
||||
import 'voice_status_summary.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
bool get _isIos {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
bool get _isMacOS {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isMacOS;
|
||||
}
|
||||
|
||||
bool get _isDesktopSileroVadHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isWindows || Platform.isLinux;
|
||||
}
|
||||
|
||||
/// Two-line status chip that summarises the current voice state.
|
||||
/// Tap to open the voice details modal.
|
||||
class VoiceStatusChip extends StatelessWidget {
|
||||
@@ -448,17 +463,17 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
|
||||
// Poll audio stats at 80 ms so TX/RX counters and the level meter
|
||||
// Poll audio stats at 250 ms so TX/RX counters and the level meter
|
||||
// update in real time while the sheet is open, independent of the parent.
|
||||
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
|
||||
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
||||
try {
|
||||
final s = await rust.audioStats();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stats = s;
|
||||
_rateTickCount++;
|
||||
// Compute rates every ~960 ms (12 × 80 ms).
|
||||
if (_rateTickCount >= 12) {
|
||||
// Compute rates every ~1 s (4 × 250 ms).
|
||||
if (_rateTickCount >= 4) {
|
||||
_txRate = s.framesSent - _prevSent;
|
||||
_rxRate = s.framesReceived - _prevReceived;
|
||||
_prevSent = s.framesSent;
|
||||
@@ -657,103 +672,99 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_audioProcessing.preferHardware
|
||||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||||
: 'Software mode applies the full WebRTC APM stage set.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Noise suppression',
|
||||
subtitle: 'Wiener filter',
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.nsEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Echo cancellation',
|
||||
subtitle: () {
|
||||
if (Platform.isAndroid) {
|
||||
return 'WebRTC AEC3 · adaptive filter';
|
||||
}
|
||||
return widget.initialAudioConfig.iosMode ==
|
||||
rust
|
||||
.BridgeIosVoiceProcessingMode
|
||||
.platformVoiceProcessing
|
||||
? 'Always on · managed by platform VPIO'
|
||||
: 'AEC3 adaptive filter · 80 ms tail';
|
||||
}(),
|
||||
value: () {
|
||||
if (Platform.isAndroid) return _audioProcessing.aecEnabled;
|
||||
return widget.initialAudioConfig.iosMode ==
|
||||
rust
|
||||
.BridgeIosVoiceProcessingMode
|
||||
.platformVoiceProcessing
|
||||
? true
|
||||
: _audioProcessing.aecEnabled;
|
||||
}(),
|
||||
onChanged: () {
|
||||
if (Platform.isAndroid) {
|
||||
return (v) {
|
||||
setState(() => _audioProcessing.aecEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
};
|
||||
}
|
||||
return widget.initialAudioConfig.iosMode ==
|
||||
rust
|
||||
.BridgeIosVoiceProcessingMode
|
||||
.platformVoiceProcessing
|
||||
if (_isIos) ...[
|
||||
Text(
|
||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||
'hidden here; only settings that still affect the shipping '
|
||||
'iOS path are shown.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsNsControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Noise suppression',
|
||||
subtitle: 'Wiener filter',
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.nsEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsAecControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Echo cancellation',
|
||||
subtitle: Platform.isAndroid
|
||||
? (_audioProcessing.preferHardware
|
||||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||||
: 'WebRTC AEC3 · adaptive filter')
|
||||
: (_isMacOS
|
||||
? 'Managed by platform VPIO'
|
||||
: 'WebRTC AEC3 · adaptive filter'),
|
||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
||||
onChanged: _isMacOS
|
||||
? null
|
||||
: (v) {
|
||||
setState(() => _audioProcessing.aecEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
};
|
||||
}(),
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Auto gain control',
|
||||
subtitle: 'AGC2 · −18 dBFS target',
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.agcEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'High-pass filter',
|
||||
subtitle: '80 Hz · DC removal',
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.hpfEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Peak limiter',
|
||||
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.limiterEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
|
||||
// iOS mode selector.
|
||||
if (Platform.isIOS) ...[
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: iosProcessingSegments,
|
||||
selected: {_audioProcessing.iosMode},
|
||||
onSelectionChanged: (s) {
|
||||
setState(() => _audioProcessing.iosMode = s.first);
|
||||
},
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsAgcControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Auto gain control',
|
||||
subtitle: 'AGC2 · -18 dBFS target',
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.agcEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'High-pass filter',
|
||||
subtitle: '80 Hz · DC removal',
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.hpfEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!Platform.isAndroid ||
|
||||
androidShowsLimiterControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
dense: true,
|
||||
label: 'Peak limiter',
|
||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) {
|
||||
setState(() => _audioProcessing.limiterEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// VAD backend.
|
||||
const SizedBox(height: 8),
|
||||
@@ -766,7 +777,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
const SizedBox(height: 2),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegments,
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) {
|
||||
setState(() => _audioProcessing.vadBackend = s.first);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Automatic gain control (AGC2)
|
||||
// - High-pass filter (HPF)
|
||||
// - VAD backend
|
||||
// - iOS voice processing mode
|
||||
// - platform audio-processing mode selection where available
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
@@ -32,6 +32,16 @@ bool get _isIos {
|
||||
return Platform.isIOS;
|
||||
}
|
||||
|
||||
bool get _isMacOS {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isMacOS;
|
||||
}
|
||||
|
||||
bool get _isDesktopSileroVadHost {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isWindows || Platform.isLinux;
|
||||
}
|
||||
|
||||
/// Result returned by [VoiceSettingsDialog].
|
||||
class VoiceSettingsResult {
|
||||
const VoiceSettingsResult({
|
||||
@@ -104,9 +114,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final platformVpio =
|
||||
_audioProcessing.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
return AlertDialog(
|
||||
title: Text(l10n.voiceSettingsTitle),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||
@@ -177,19 +184,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio processing'),
|
||||
|
||||
// iOS mode selector (iOS only)
|
||||
if (_isIos) ...[
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: iosProcessingSegments,
|
||||
selected: {_audioProcessing.iosMode},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.iosMode = s.first),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// Android HW/SW selector
|
||||
if (_isAndroid) ...[
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
@@ -201,51 +195,81 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
setState(() => _audioProcessing.preferHardware = s.first),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_audioProcessing.preferHardware
|
||||
? 'Android hardware mode still falls back to WebRTC APM per stage when device effects are missing, so these controls remain available.'
|
||||
: 'Android software mode applies the full WebRTC APM control set.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
const VoiceSubHeader('DSP stages'),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.nsEnabled = v),
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Echo cancellation (AEC3)',
|
||||
subtitle: _isAndroid
|
||||
? 'WebRTC AEC3 · adaptive filter'
|
||||
: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _audioProcessing.aecEnabled,
|
||||
// AEC is always on in VPIO mode — disable the toggle.
|
||||
onChanged: (_isAndroid || !platformVpio)
|
||||
? (v) => setState(() => _audioProcessing.aecEnabled = v)
|
||||
: null,
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · −18 dBFS target',
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.agcEnabled = v),
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.hpfEnabled = v),
|
||||
),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Peak limiter',
|
||||
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.limiterEnabled = v),
|
||||
),
|
||||
if (_isIos) ...[
|
||||
Text(
|
||||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||||
'hidden here; only settings that still affect the shipping '
|
||||
'iOS path are shown.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsNsControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.nsEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsAecControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Echo cancellation (AEC3)',
|
||||
subtitle: _isAndroid
|
||||
? (_audioProcessing.preferHardware
|
||||
? 'Prefers device/OS effect; WebRTC AEC3 fallback when binding is unavailable'
|
||||
: 'WebRTC AEC3 · adaptive filter')
|
||||
: (_isMacOS
|
||||
? 'Managed by platform VPIO'
|
||||
: 'WebRTC AEC3 · adaptive filter'),
|
||||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
||||
onChanged: _isMacOS
|
||||
? null
|
||||
: (v) => setState(() => _audioProcessing.aecEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsAgcControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · -18 dBFS target',
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.agcEnabled = v),
|
||||
),
|
||||
if (!_isAndroid || androidShowsHpfControl(_audioProcessing))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.hpfEnabled = v),
|
||||
),
|
||||
if (!_isIos &&
|
||||
(!_isAndroid || androidShowsLimiterControl(_audioProcessing)))
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Peak limiter',
|
||||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.limiterEnabled = v),
|
||||
),
|
||||
|
||||
if (isTalkPowerBlocked(
|
||||
talkPower: widget.talkPower,
|
||||
@@ -267,7 +291,9 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
const VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegments,
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
|
||||
@@ -43,20 +43,6 @@ const androidProcessingSegments = [
|
||||
),
|
||||
];
|
||||
|
||||
/// iOS VPIO/Sonora selector segments.
|
||||
const iosProcessingSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
label: Text('VPIO'),
|
||||
icon: Icon(Icons.phone_iphone, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
label: Text('Sonora'),
|
||||
icon: Icon(Icons.science_outlined, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// Voice activity detector selector segments.
|
||||
const vadBackendSegments = [
|
||||
ButtonSegment(
|
||||
@@ -69,10 +55,17 @@ const vadBackendSegments = [
|
||||
label: Text('Silero'),
|
||||
icon: Icon(Icons.psychology, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// Desktop VAD selector segments.
|
||||
///
|
||||
/// Windows and Linux use Silero as the primary VAD. WebRTC remains an
|
||||
/// internal runtime fallback when the model/runtime is unavailable.
|
||||
const desktopVadBackendSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeVadBackend.tenVad,
|
||||
label: Text('TEN'),
|
||||
icon: Icon(Icons.graphic_eq, size: 14),
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
label: Text('Silero'),
|
||||
icon: Icon(Icons.psychology, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user