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
@@ -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,
]);
});