diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index a2f47b6..d052df5 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1,4 @@ + + diff --git a/assets/porcupine_params_zh.pv b/assets/porcupine_params_zh.pv new file mode 100644 index 0000000..c20d6cd Binary files /dev/null and b/assets/porcupine_params_zh.pv differ diff --git a/assets/zhongzhong_zh_android.ppn b/assets/zhongzhong_zh_android.ppn new file mode 100644 index 0000000..70e69a6 Binary files /dev/null and b/assets/zhongzhong_zh_android.ppn differ diff --git a/example/lib/main.dart b/example/lib/main.dart index ad9b26c..a8af8e7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,6 +3,8 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:ai_chat_assistant/ai_chat_assistant.dart'; +import 'pages/home.dart'; + class VehicleCommandClent extends VehicleCommandHandler { @override AIChatCommandCubit? get commandCubit => super.commandCubit; @@ -13,7 +15,6 @@ class VehicleCommandClent extends VehicleCommandHandler { print('执行车控命令: ${command.type}, 参数: ${command.params}'); if (commandCubit != null) { - commandCubit?.emit(AIChatCommandState( commandId: command.commandId, commandType: command.type, @@ -64,6 +65,8 @@ void main() async { AIChatAssistantManager.instance.setupCommandHandle(commandHandler: VehicleCommandClent()); runApp(const MyApp()); + + AIChatAssistantManager.instance.setWakeWordDetection(true); } class MyApp extends StatelessWidget { @@ -77,18 +80,7 @@ class MyApp extends StatelessWidget { primarySwatch: Colors.blue, useMaterial3: true, ), - home: const ExampleHomePage(), - ); - } -} - -class ExampleHomePage extends StatelessWidget { - const ExampleHomePage({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: ChatAssistantApp(), + home: HomePage(), ); } } diff --git a/example/lib/pages/home.dart b/example/lib/pages/home.dart new file mode 100644 index 0000000..d126557 --- /dev/null +++ b/example/lib/pages/home.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:ai_chat_assistant/ai_chat_assistant.dart'; + +import 'setting.dart'; + +class ChatAssistantPage extends StatelessWidget { + const ChatAssistantPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ChatAssistantApp(), + ); + } +} + +class HomePage extends StatefulWidget { + HomePage({super.key}); + + final items = [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings), + label: 'Settings', + ), + ]; + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + int _selectedIndex = 0; + + final List _pages = [ + ChatAssistantPage(), + SettingPage(), + ]; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: _pages.elementAt(_selectedIndex), + bottomNavigationBar: BottomNavigationBar( + items: widget.items, + selectedItemColor: Colors.blue, + unselectedItemColor: Colors.grey, + currentIndex: _selectedIndex, + onTap: _onItemTapped, + ), + ); + } +} diff --git a/example/lib/pages/setting.dart b/example/lib/pages/setting.dart new file mode 100644 index 0000000..32c3ea7 --- /dev/null +++ b/example/lib/pages/setting.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class SettingPage extends StatefulWidget { + const SettingPage({super.key}); + + @override + State createState() => _SettingPageState(); +} + +class _SettingPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('设置'), + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('设置页面'), + SizedBox(height: 20), + Text('这里可以添加更多设置选项'), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/example/pubspec.lock b/example/pubspec.lock index 9340850..892b6ed 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -201,6 +201,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.2.3" + flutter_voice_processor: + dependency: transitive + description: + name: flutter_voice_processor + sha256: "8ae3fc196d6060a13392e4ca557f7a2f4a6b87898ae519787f6929f986538572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" flutter_web_plugins: dependency: transitive description: flutter @@ -430,6 +438,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + porcupine_flutter: + dependency: transitive + description: + name: porcupine_flutter + sha256: "5618b20a00c44aab80763a70cab993037dd73d924dd2a24154c36dc13bdf5c8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.5" provider: dependency: transitive description: diff --git a/lib/manager.dart b/lib/manager.dart index e913c61..cda3f8a 100644 --- a/lib/manager.dart +++ b/lib/manager.dart @@ -1,5 +1,11 @@ import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:porcupine_flutter/porcupine_error.dart'; +import 'package:porcupine_flutter/porcupine_manager.dart'; import 'bloc/ai_chat_cubit.dart'; import 'bloc/command_state.dart'; @@ -44,9 +50,103 @@ class AIChatAssistantManager { return _commandCubit!.stream; } + // --- Porcupine 唤醒词属性 --- + + PorcupineManager? _porcupineManager; + final StreamController _wakeWordController = StreamController.broadcast(); + bool _isWakeWordEnabled = false; + bool _wakeWordDetected = false; + + // PicoVoice AccessKey + final String _picoAccessKey = "CRkyWZ3DRFXVogXEhE2eK2/ZN1q9YEYweTnjVWlve86nKAid5i1zsQ=="; + + // 资产逻辑路径 (用于 rootBundle.load) + static const String _keywordAssetPath = "packages/ai_chat_assistant/assets/zhongzhong_zh_android.ppn"; + static const String _modelAssetPath = "packages/ai_chat_assistant/assets/porcupine_params_zh.pv"; + + // 真实文件系统路径 (Porcupine 使用) + String? _keywordFilePath; + String? _modelFilePath; + + /// 监听唤醒词事件的 Stream + Stream get onWakeWordDetected => _wakeWordController.stream; + + /// 当前是否检测到了唤醒词 + bool get isWakeWordDetected => _wakeWordDetected; + + /// 将 Flutter asset 写入设备文件系统,返回其真实路径 + Future _extractAsset(String assetPath, String fileName) async { + final dir = await getApplicationSupportDirectory(); + final file = File('${dir.path}/$fileName'); + if (!file.existsSync()) { + final data = await rootBundle.load(assetPath); + await file.writeAsBytes(data.buffer.asUint8List(), flush: true); + debugPrint('Asset "$assetPath" extracted to "${file.path}"'); + } + return file.path; + } + + /// 开启或关闭唤醒词检测 + Future setWakeWordDetection(bool enable) async { + if (enable == _isWakeWordEnabled && _porcupineManager != null) { + return; // 状态未改变 + } + _isWakeWordEnabled = enable; + + if (enable) { + try { + // 确保关键词和模型文件已提取到文件系统 + _keywordFilePath ??= await _extractAsset(_keywordAssetPath, "zhongzhong_zh_android.ppn"); + _modelFilePath ??= await _extractAsset(_modelAssetPath, "porcupine_params_zh.pv"); + + _porcupineManager = await PorcupineManager.fromKeywordPaths( + _picoAccessKey, + [_keywordFilePath!], + _wakeWordDetectedCallback, + modelPath: _modelFilePath, // 中文模型必须指定 modelPath + errorCallback: _porcupineErrorCallback, + sensitivities: [0.6], // 可以调整灵敏度 + ); + await _porcupineManager?.start(); + debugPrint("Porcupine manager started for wake-word detection."); + } on PorcupineException catch (e) { + debugPrint("Failed to init Porcupine: ${e.message}"); + _isWakeWordEnabled = false; + } + } else { + await _porcupineManager?.stop(); + await _porcupineManager?.delete(); + _porcupineManager = null; + _wakeWordDetected = false; // 关闭时重置状态 + debugPrint("Porcupine manager stopped and deleted."); + } + } + + // 唤醒词检测回调 + void _wakeWordDetectedCallback(int keywordIndex) { + // 我们只用了一个关键词,所以索引总是 0 + if (keywordIndex == 0) { + _wakeWordDetected = true; + debugPrint("Wake-word '众众' detected!"); + // 通知所有监听者 + _wakeWordController.add(null); + + // 可选:一段时间后自动重置状态 + Future.delayed(const Duration(seconds: 2), () { + _wakeWordDetected = false; + }); + } + } + + void _porcupineErrorCallback(PorcupineException error) { + debugPrint("Porcupine error: ${error.message}"); + } + /// 释放资源 void dispose() { _commandCubit?.close(); _commandCubit = null; + _wakeWordController.close(); + setWakeWordDetection(false); // 确保 Porcupine 停止并释放 } } diff --git a/lib/widgets/chat_popup.dart b/lib/widgets/chat_popup.dart index e0f89e8..bfd6082 100644 --- a/lib/widgets/chat_popup.dart +++ b/lib/widgets/chat_popup.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:ui'; +import 'package:ai_chat_assistant/manager.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../pages/full_screen.dart'; @@ -30,6 +32,7 @@ class _ChatPopupState extends State with SingleTickerProviderStateMix final double _iconSizeDefault = 80.0; bool _isShowingPopup = false; late AnimationController _partScreenAnimationController; + StreamSubscription? _wakeWordSubscription; @override void initState() { @@ -38,11 +41,21 @@ class _ChatPopupState extends State with SingleTickerProviderStateMix duration: const Duration(milliseconds: 250), vsync: this, ); + + // 订阅唤醒词事件 + _wakeWordSubscription = AIChatAssistantManager.instance.onWakeWordDetected.listen((_) async { + // 在这里处理唤醒事件,例如显示聊天窗口 + debugPrint("唤醒词被检测到,执行UI操作!"); + final messageService = MessageService.instance; + _insertOverlay(); + await messageService.startVoiceInput(); + }); } @override void dispose() { _partScreenAnimationController.dispose(); + _wakeWordSubscription?.cancel(); super.dispose(); } diff --git a/pubspec.lock b/pubspec.lock index 204a954..8da4e02 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -157,6 +157,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.2.3" + flutter_voice_processor: + dependency: transitive + description: + name: flutter_voice_processor + sha256: "8ae3fc196d6060a13392e4ca557f7a2f4a6b87898ae519787f6929f986538572" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" flutter_web_plugins: dependency: transitive description: flutter @@ -354,6 +362,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + porcupine_flutter: + dependency: "direct main" + description: + name: porcupine_flutter + sha256: "5618b20a00c44aab80763a70cab993037dd73d924dd2a24154c36dc13bdf5c8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.5" provider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ad238e2..2d78a38 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,8 @@ dependencies: permission_handler: ^12.0.0 provider: ^6.1.5 flutter_tts: ^4.2.0 + # 语音唤醒 + porcupine_flutter: ^3.0.5 # basic_intl: 0.2.0 t_basic_intl: path: packages/basic_intl @@ -33,6 +35,8 @@ flutter: uses-material-design: true assets: - assets/images/ + - assets/zhongzhong_zh_android.ppn + - assets/porcupine_params_zh.pv plugin: platforms: android: