feat: stabilize voice activity and audio routing
This commit is contained in:
@@ -29,4 +29,94 @@ void main() {
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('audio device tile shows selected device and details', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AudioDeviceListTile(
|
||||
label: 'Output',
|
||||
kind: AudioDeviceKind.output,
|
||||
loadDevices: () async => const rust.BridgeAudioDeviceList(
|
||||
inputDevices: [],
|
||||
outputDevices: [
|
||||
rust.BridgeAudioDevice(
|
||||
id: 'default-speakers',
|
||||
name: 'Speakers',
|
||||
details: 'Realtek · Speaker · id=1111',
|
||||
isDefault: true,
|
||||
isSelected: false,
|
||||
),
|
||||
rust.BridgeAudioDevice(
|
||||
id: 'usb-headset',
|
||||
name: 'USB Headset',
|
||||
details: 'SteelSeries · Headset · id=2222',
|
||||
isDefault: false,
|
||||
isSelected: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('USB Headset'), findsOneWidget);
|
||||
expect(find.text('SteelSeries · Headset · id=2222'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Output'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('SteelSeries · Headset · id=2222'), findsOneWidget);
|
||||
expect(find.text('System default'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('audio device tile selects by id', (tester) async {
|
||||
String? selectedId = 'device-a';
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AudioDeviceListTile(
|
||||
label: 'Input',
|
||||
kind: AudioDeviceKind.input,
|
||||
loadDevices: () async => const rust.BridgeAudioDeviceList(
|
||||
inputDevices: [
|
||||
rust.BridgeAudioDevice(
|
||||
id: 'device-a',
|
||||
name: 'Built-in Mic',
|
||||
details: 'Realtek · Microphone · id=aaaa',
|
||||
isDefault: true,
|
||||
isSelected: true,
|
||||
),
|
||||
rust.BridgeAudioDevice(
|
||||
id: 'device-b',
|
||||
name: 'USB Mic',
|
||||
details: 'Shure · Microphone · id=bbbb',
|
||||
isDefault: false,
|
||||
isSelected: false,
|
||||
),
|
||||
],
|
||||
outputDevices: [],
|
||||
),
|
||||
setInputDevice: ({String? id}) async {
|
||||
selectedId = id;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Input'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('USB Mic'));
|
||||
await tester.pump();
|
||||
|
||||
expect(selectedId, 'device-b');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/widgets/audio_processing_config_state.dart';
|
||||
@@ -20,28 +21,64 @@ void main() {
|
||||
debugWavDumpEnabled: true,
|
||||
);
|
||||
|
||||
test('normalizes hidden disabled VAD backend for UI state', () {
|
||||
test('normalizes hidden disabled VAD backend to Silero for UI state', () {
|
||||
final state = AudioProcessingConfigState.fromConfig(baseConfig);
|
||||
|
||||
expect(state.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||
expect(state.vadBackend, rust.BridgeVadBackend.sileroOnnx);
|
||||
expect(state.preferHardware, isTrue);
|
||||
expect(state.nsEnabled, isFalse);
|
||||
expect(state.aecEnabled, isTrue);
|
||||
expect(state.agcEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('default config uses platform processing and Silero VAD', () {
|
||||
test('normalizes desktop VAD backend to Silero', () {
|
||||
expect(
|
||||
defaultAudioProcessingConfig.processingBackend,
|
||||
rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
);
|
||||
expect(
|
||||
defaultAudioProcessingConfig.vadBackend,
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
),
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
expect(defaultAudioProcessingConfig.aec, rust.BridgeEffectOwner.platform);
|
||||
expect(defaultAudioProcessingConfig.ns, rust.BridgeEffectOwner.platform);
|
||||
expect(defaultAudioProcessingConfig.agc, rust.BridgeEffectOwner.platform);
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
),
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
});
|
||||
|
||||
test('default config matches the current platform fallback', () {
|
||||
final fallback = defaultAudioProcessingConfig();
|
||||
final desktop = Platform.isWindows || Platform.isLinux;
|
||||
|
||||
expect(
|
||||
fallback.processingBackend,
|
||||
desktop
|
||||
? rust.BridgeAudioBackend.webrtcApm
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
);
|
||||
expect(fallback.vadBackend, rust.BridgeVadBackend.sileroOnnx);
|
||||
expect(
|
||||
fallback.aec,
|
||||
desktop
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.platform,
|
||||
);
|
||||
expect(
|
||||
fallback.ns,
|
||||
desktop
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.platform,
|
||||
);
|
||||
expect(
|
||||
fallback.agc,
|
||||
desktop
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.platform,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds Android hardware config consistently', () {
|
||||
@@ -57,25 +94,113 @@ void main() {
|
||||
config.processingBackend,
|
||||
rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx);
|
||||
expect(config.aec, rust.BridgeEffectOwner.off);
|
||||
expect(config.ns, rust.BridgeEffectOwner.platform);
|
||||
expect(config.agc, rust.BridgeEffectOwner.platform);
|
||||
expect(config.debugWavDumpEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('builds Sonora config with WebRTC-owned enabled effects', () {
|
||||
test('builds Windows/Linux software WebRTC APM config consistently', () {
|
||||
final state = AudioProcessingConfigState.fromConfig(baseConfig)
|
||||
..iosMode = rust.BridgeIosVoiceProcessingMode.sonoraExperimental
|
||||
..nsEnabled = true
|
||||
..aecEnabled = false
|
||||
..agcEnabled = true;
|
||||
|
||||
final config = state.buildConfig(base: baseConfig, isAndroid: false);
|
||||
final windowsConfig = state.buildConfig(
|
||||
base: baseConfig,
|
||||
isAndroid: false,
|
||||
isIos: false,
|
||||
isMacOS: false,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
);
|
||||
|
||||
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
|
||||
expect(config.aec, rust.BridgeEffectOwner.off);
|
||||
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
|
||||
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
|
||||
final linuxConfig = state.buildConfig(
|
||||
base: baseConfig,
|
||||
isAndroid: false,
|
||||
isIos: false,
|
||||
isMacOS: false,
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
);
|
||||
|
||||
for (final config in [windowsConfig, linuxConfig]) {
|
||||
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx);
|
||||
expect(config.aec, rust.BridgeEffectOwner.off);
|
||||
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
|
||||
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
|
||||
expect(config.debugWavDumpEnabled, isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizes hidden iOS sonora config back to platform VPIO', () {
|
||||
const sonoraConfig = rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
processingBackend: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: rust.BridgeVadBackend.disabled,
|
||||
aec: rust.BridgeEffectOwner.webrtcApm,
|
||||
ns: rust.BridgeEffectOwner.webrtcApm,
|
||||
agc: rust.BridgeEffectOwner.webrtcApm,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: false,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: true,
|
||||
);
|
||||
|
||||
final state = AudioProcessingConfigState.fromConfig(sonoraConfig);
|
||||
final config = state.buildConfig(
|
||||
base: sonoraConfig,
|
||||
isAndroid: false,
|
||||
isIos: true,
|
||||
isMacOS: false,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
);
|
||||
|
||||
expect(
|
||||
state.iosMode,
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
);
|
||||
expect(
|
||||
config.iosMode,
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
);
|
||||
expect(
|
||||
config.processingBackend,
|
||||
rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
);
|
||||
expect(config.aec, rust.BridgeEffectOwner.platform);
|
||||
expect(config.ns, rust.BridgeEffectOwner.platform);
|
||||
expect(config.agc, rust.BridgeEffectOwner.platform);
|
||||
});
|
||||
|
||||
test(
|
||||
'Android hardware mode keeps controls visible because stages can fall back to WebRTC',
|
||||
() {
|
||||
final state = AudioProcessingConfigState.fromConfig(baseConfig)
|
||||
..preferHardware = true;
|
||||
|
||||
expect(androidShowsNsControl(state), isTrue);
|
||||
expect(androidShowsAecControl(state), isTrue);
|
||||
expect(androidShowsAgcControl(state), isTrue);
|
||||
expect(androidShowsHpfControl(state), isTrue);
|
||||
expect(androidShowsLimiterControl(state), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('Android software mode shows effective WebRTC APM controls', () {
|
||||
final state = AudioProcessingConfigState.fromConfig(baseConfig)
|
||||
..preferHardware = false;
|
||||
|
||||
expect(androidShowsNsControl(state), isTrue);
|
||||
expect(androidShowsAecControl(state), isTrue);
|
||||
expect(androidShowsAgcControl(state), isTrue);
|
||||
expect(androidShowsHpfControl(state), isTrue);
|
||||
expect(androidShowsLimiterControl(state), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +113,64 @@ void main() {
|
||||
expect(target, isNull);
|
||||
});
|
||||
|
||||
test('initial chat target falls back to server activity when present', () {
|
||||
final target = resolveInitialChatTarget(
|
||||
messages: [
|
||||
entry(const rust.BridgeMessageTarget.server()),
|
||||
entry(const rust.BridgeMessageTarget.channel()),
|
||||
],
|
||||
currentVoiceChannelId: null,
|
||||
);
|
||||
|
||||
expect(target, const rust.BridgeMessageTarget.server());
|
||||
});
|
||||
|
||||
test('builds server activity entries for join, move, and disconnect', () {
|
||||
final lobbyId = BigInt.from(10);
|
||||
final quietId = BigInt.from(20);
|
||||
final previous = snapshot(
|
||||
channels: [
|
||||
channel(lobbyId, 'Default Channel'),
|
||||
channel(quietId, 'Quiet Zone'),
|
||||
],
|
||||
clients: [
|
||||
client(id: BigInt.one, name: 'Me', channelId: quietId),
|
||||
client(id: BigInt.from(2), name: 'Alice', channelId: lobbyId),
|
||||
client(id: BigInt.from(3), name: 'Bob', channelId: lobbyId),
|
||||
],
|
||||
);
|
||||
final current = snapshot(
|
||||
channels: [
|
||||
channel(lobbyId, 'Default Channel'),
|
||||
channel(quietId, 'Quiet Zone'),
|
||||
],
|
||||
clients: [
|
||||
client(id: BigInt.one, name: 'Me', channelId: quietId),
|
||||
client(id: BigInt.from(2), name: 'Alice', channelId: quietId),
|
||||
client(id: BigInt.from(4), name: 'Carol', channelId: lobbyId),
|
||||
],
|
||||
);
|
||||
|
||||
final entries = buildServerActivityEntries(
|
||||
previous: previous,
|
||||
current: current,
|
||||
timestamp: DateTime(2026, 5, 24, 11, 40, 39),
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.message), [
|
||||
'"Alice" switched from channel "Default Channel" to channel "Quiet Zone"',
|
||||
'"Carol" connected to channel "Default Channel"',
|
||||
'"Bob" disconnected from channel "Default Channel"',
|
||||
]);
|
||||
expect(entries.every((entry) => !entry.countsTowardUnread), isTrue);
|
||||
expect(
|
||||
entries.every(
|
||||
(entry) => entry.target == const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'groups chat picker clients by channel and excludes own/query clients',
|
||||
() {
|
||||
@@ -244,6 +302,76 @@ void main() {
|
||||
expect(groups.single.channelName, 'Lobby');
|
||||
});
|
||||
|
||||
test('builds server activity entries from snapshot deltas', () {
|
||||
final before = snapshot(
|
||||
channels: [
|
||||
channel(BigInt.from(10), 'Lobby'),
|
||||
channel(BigInt.from(20), 'AFK'),
|
||||
],
|
||||
clients: [
|
||||
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
|
||||
client(id: BigInt.from(2), name: 'Alice', channelId: BigInt.from(10)),
|
||||
client(id: BigInt.from(3), name: 'Bob', channelId: BigInt.from(20)),
|
||||
],
|
||||
);
|
||||
final after = snapshot(
|
||||
channels: [
|
||||
channel(BigInt.from(10), 'Lobby'),
|
||||
channel(BigInt.from(20), 'AFK'),
|
||||
],
|
||||
clients: [
|
||||
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
|
||||
client(id: BigInt.from(2), name: 'Alice', channelId: BigInt.from(20)),
|
||||
client(id: BigInt.from(4), name: 'Carol', channelId: BigInt.from(10)),
|
||||
],
|
||||
);
|
||||
|
||||
final entries = buildServerActivityEntries(
|
||||
previous: before,
|
||||
current: after,
|
||||
timestamp: DateTime(2026, 1, 2, 3, 4, 5),
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.message), [
|
||||
'"Alice" switched from channel "Lobby" to channel "AFK"',
|
||||
'"Carol" connected to channel "Lobby"',
|
||||
'"Bob" disconnected from channel "AFK"',
|
||||
]);
|
||||
expect(
|
||||
entries.every(
|
||||
(entry) => entry.target == const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores ServerQuery clients in snapshot-driven server activity', () {
|
||||
final before = snapshot(
|
||||
channels: [channel(BigInt.from(10), 'Lobby')],
|
||||
clients: [
|
||||
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
|
||||
client(
|
||||
id: BigInt.from(2),
|
||||
name: 'Query',
|
||||
channelId: BigInt.from(10),
|
||||
isServerQuery: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
final after = snapshot(
|
||||
channels: [channel(BigInt.from(10), 'Lobby')],
|
||||
clients: [client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10))],
|
||||
);
|
||||
|
||||
final entries = buildServerActivityEntries(
|
||||
previous: before,
|
||||
current: after,
|
||||
timestamp: DateTime(2026, 1, 2, 3, 4, 5),
|
||||
);
|
||||
|
||||
expect(entries, isEmpty);
|
||||
});
|
||||
|
||||
test('formats chat target titles', () {
|
||||
expect(
|
||||
chatTargetTitle(
|
||||
@@ -366,9 +494,16 @@ void main() {
|
||||
final serverTop = tester.getTopLeft(find.text('Server').first).dy;
|
||||
final channelTop = tester.getTopLeft(find.text('Channel')).dy;
|
||||
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
|
||||
final serverTileSize = tester.getSize(
|
||||
find
|
||||
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
|
||||
.first,
|
||||
);
|
||||
|
||||
expect(serverTop, lessThan(channelTop));
|
||||
expect(channelTop, lessThan(privateTop));
|
||||
expect(serverTileSize.width, 92);
|
||||
expect(serverTileSize.height, 92);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -476,6 +611,80 @@ void main() {
|
||||
expect(find.text('<05:10:37> “EdisonJwa”戳了你一下'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('server activity renders as timestamped styled row', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
senderId: BigInt.zero,
|
||||
senderName: 'Server',
|
||||
message: '"Alice" connected to channel "Lobby"',
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
timestamp: DateTime(2026, 5, 24, 11, 40, 39),
|
||||
countsTowardUnread: false,
|
||||
),
|
||||
],
|
||||
snapshot: snapshot(channels: const [], clients: const []),
|
||||
initialTarget: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('<11:40:39>'), findsOneWidget);
|
||||
expect(find.textContaining('"Alice"'), findsOneWidget);
|
||||
expect(find.textContaining('"Lobby"'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
expect(find.byType(RichText), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('chat page updates while open when backing messages change', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = <ChatEntry>[
|
||||
ChatEntry(
|
||||
senderId: BigInt.one,
|
||||
senderName: 'Sender',
|
||||
message: 'First',
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
];
|
||||
var currentSnapshot = snapshot(channels: const [], clients: const []);
|
||||
final refresh = ValueNotifier<int>(0);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ChatPage(
|
||||
messages: messages,
|
||||
snapshot: currentSnapshot,
|
||||
messagesSource: () => messages,
|
||||
snapshotSource: () => currentSnapshot,
|
||||
refreshListenable: refresh,
|
||||
initialTarget: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('First'), findsOneWidget);
|
||||
expect(find.text('Second'), findsNothing);
|
||||
|
||||
messages.add(
|
||||
ChatEntry(
|
||||
senderId: BigInt.from(2),
|
||||
senderName: 'Other',
|
||||
message: 'Second',
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
);
|
||||
refresh.value++;
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Second'), findsOneWidget);
|
||||
refresh.dispose();
|
||||
});
|
||||
|
||||
test('blocks channel chat when no voice channel is joined', () {
|
||||
expect(
|
||||
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
|
||||
|
||||
@@ -17,18 +17,16 @@ void main() {
|
||||
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
|
||||
});
|
||||
|
||||
test('shared iOS processing segments expose VPIO and Sonora', () {
|
||||
expect(iosProcessingSegments.map((s) => s.value), [
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
]);
|
||||
});
|
||||
|
||||
test('shared VAD segments expose supported non-disabled backends', () {
|
||||
expect(vadBackendSegments.map((s) => s.value), [
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
rust.BridgeVadBackend.tenVad,
|
||||
]);
|
||||
});
|
||||
|
||||
test('desktop VAD segments expose Silero as the primary backend', () {
|
||||
expect(desktopVadBackendSegments.map((s) => s.value), [
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user