feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task
Audio engine (Rust): - Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD - Hardware effects (JNI) with software fallback per-effect - Render reference buffer for AEC between output/capture callbacks - Voice activity gate: suppress transmission when speaker muted (all platforms) - Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI - ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17) - VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix) - TEN VAD default backend (was Silero) - Platform→WebrtcApm resolution after hardware binding - oboe-rs edisonjwa fork with get_raw_session_id() Android Kotlin: - AndroidAudioFocusController + AndroidBluetoothScoController - AndroidAudioLifecycleController (route changes to Flutter) - ProGuard rules for new controllers Flutter UI: - VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM) - VoiceStatusChip: mute warning border + Speaker muted label - BBCode welcome message parser (BbCodeText, case-insensitive) - Welcome message foldable (expanded by default) - Link trust dialog (domain wildcards, SharedPreferences) - HapticFeedback on voice sheet opener - Server name in AppBar, version v0.1.0 - Default channel (id=1) visible, serverquery clients hidden - flutter_foreground_task integration Config: - ort load-dynamic on all non-iOS (Android/Linux/Windows) - ONNX Runtime AAR 1.26.0 - ndarray moved to common deps (was Apple-only)
This commit is contained in:
@@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
import 'l10n/generated/app_localizations.dart';
|
||||
import 'services/android_permissions_service.dart';
|
||||
@@ -30,6 +31,8 @@ import 'widgets/voice_platform.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
import 'widgets/voice_compact.dart';
|
||||
import 'widgets/voice_settings.dart';
|
||||
import 'widgets/bbcode_text.dart';
|
||||
import 'services/link_trust_service.dart';
|
||||
|
||||
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||
|
||||
@@ -62,11 +65,12 @@ Future<void> _configureBundledVadModels() async {
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
await _copyBundledAssetToDocuments(
|
||||
final ten = await _copyBundledAssetToDocuments(
|
||||
assetPath: _tenVadAsset,
|
||||
fileName: 'ten_vad.onnx',
|
||||
);
|
||||
await rust.setVadModelPath(path: silero.path);
|
||||
await rust.setTenVadModelPath(path: ten.path);
|
||||
}
|
||||
|
||||
/// Top padding for macOS to clear traffic-light buttons.
|
||||
@@ -136,7 +140,7 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
|
||||
/// build-counter suffix changes automatically on every pubspec
|
||||
/// `+<n>` bump because Flutter writes it into Info.plist.
|
||||
const String _kSemverBaseline = 'v1.0.0-rc.8';
|
||||
const String _kSemverBaseline = 'v0.1.0';
|
||||
String _kAppVersion = _kSemverBaseline;
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -146,6 +150,8 @@ Future<void> main() async {
|
||||
unawaited(_wireStorage());
|
||||
unawaited(_wireConnectivity());
|
||||
_wireIosAudioLifecycle();
|
||||
_wireAndroidAudioLifecycle();
|
||||
await _configureBundledVadModels();
|
||||
runApp(const ChanoraApp());
|
||||
}
|
||||
|
||||
@@ -214,6 +220,37 @@ void _wireIosAudioLifecycle() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Wire the Android audio lifecycle MethodChannel.
|
||||
///
|
||||
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change
|
||||
/// events through `FlutterMethodChannel` named
|
||||
/// `"chanora/android_audio_lifecycle"`. This handler dispatches them to
|
||||
/// the FRB bridge functions on the Rust side, mirroring the iOS pattern.
|
||||
void _wireAndroidAudioLifecycle() {
|
||||
if (!Platform.isAndroid) return;
|
||||
const channel = MethodChannel('chanora/android_audio_lifecycle');
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleRouteChange':
|
||||
final args = call.arguments;
|
||||
final routeStr = args is Map
|
||||
? (args['routeType'] as String? ?? 'Unknown')
|
||||
: (args as String? ?? 'Unknown');
|
||||
final route = _parseBridgeAudioRoute(routeStr);
|
||||
rust.handleRouteChange(route: route);
|
||||
break;
|
||||
default:
|
||||
// Unknown method — ignore gracefully rather than crashing.
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Errors from the Rust side are already logged there;
|
||||
// don't propagate exceptions to the Android framework.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Populate `_kAppVersion` by suffixing the platform-canonical
|
||||
/// build number to `_kSemverBaseline`. Format:
|
||||
/// `v1.0.0-rc.8+<build>` (e.g. `v1.0.0-rc.8+64`). The build
|
||||
@@ -728,6 +765,15 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_phase = _Phase.connected;
|
||||
_applySnapshot(snap);
|
||||
});
|
||||
try {
|
||||
await FlutterForegroundTask.startService(
|
||||
notificationTitle: 'Chanora',
|
||||
notificationText: 'Connected to ${snap.serverName}',
|
||||
notificationButtons: const [
|
||||
NotificationButton(id: 'disconnect', text: 'Disconnect'),
|
||||
],
|
||||
);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final errorStr = e.toString();
|
||||
@@ -1186,6 +1232,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
try {
|
||||
await rust.disconnect();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await FlutterForegroundTask.stopService();
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_phase = _Phase.idle;
|
||||
@@ -1506,6 +1555,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
|
||||
isSelected: _hardMute,
|
||||
selectedIcon: const Icon(Icons.mic_off),
|
||||
color: _hardMute ? theme.colorScheme.error : null,
|
||||
onPressed: _onToggleHardMute,
|
||||
),
|
||||
IconButton(
|
||||
@@ -1513,6 +1563,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
|
||||
isSelected: _outputMuted,
|
||||
selectedIcon: const Icon(Icons.headset_off),
|
||||
color: _outputMuted ? theme.colorScheme.error : null,
|
||||
onPressed: _toggleOutputMute,
|
||||
),
|
||||
],
|
||||
@@ -1532,6 +1583,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
const headerTitle = SizedBox.shrink();
|
||||
|
||||
final appBarTitle = _phase == _Phase.connected
|
||||
? Text(_snapshot?.serverName ?? l10n.appTitle, style: theme.textTheme.titleMedium)
|
||||
: headerTitle;
|
||||
|
||||
final bodyContent = LayoutBuilder(
|
||||
builder: (ctx, bodyConstraints) {
|
||||
const wideBreakpoint = 600.0;
|
||||
@@ -1554,7 +1609,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_phase != _Phase.connected)
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -1707,6 +1763,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
inputMuted: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
if (_inChannel &&
|
||||
@@ -1756,7 +1814,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: headerTitle, actions: headerActions),
|
||||
appBar: AppBar(title: appBarTitle, actions: headerActions),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
|
||||
@@ -2168,6 +2226,75 @@ class _BookmarkList extends StatelessWidget {
|
||||
///
|
||||
/// Driven by the `BridgeEvent::PttCapability` stream published by
|
||||
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
|
||||
class _WelcomeMessageTile extends StatefulWidget {
|
||||
const _WelcomeMessageTile({required this.welcomeMessage});
|
||||
|
||||
final String welcomeMessage;
|
||||
|
||||
@override
|
||||
State<_WelcomeMessageTile> createState() => _WelcomeMessageTileState();
|
||||
}
|
||||
|
||||
class _WelcomeMessageTileState extends State<_WelcomeMessageTile> {
|
||||
bool _expanded = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _expanded = !_expanded);
|
||||
},
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Server welcome message',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: BbCodeText(
|
||||
widget.welcomeMessage,
|
||||
linkTrust: LinkTrustService.instance,
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox(width: double.infinity),
|
||||
crossFadeState: _expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// updates the props on each transition.
|
||||
class _SnapshotView extends StatelessWidget {
|
||||
const _SnapshotView({
|
||||
@@ -2242,17 +2369,7 @@ class _SnapshotView extends StatelessWidget {
|
||||
),
|
||||
if (snapshot.welcomeMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
snapshot.welcomeMessage,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
_WelcomeMessageTile(welcomeMessage: snapshot.welcomeMessage),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
|
||||
@@ -2271,7 +2388,6 @@ class _SnapshotView extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
title: Text(ch.name),
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
selected: ch.id == currentVoiceChannelId,
|
||||
onTap:
|
||||
hasJoinPending ||
|
||||
@@ -2284,7 +2400,8 @@ class _SnapshotView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
|
||||
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
|
||||
if (!cl.isServerQuery)
|
||||
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user