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
+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,
),