feat: 新增语音唤醒
唤醒的逻辑跟长按的逻辑不一样,不能像之前那样长按图标来录音,结束长按来结束录音
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
BIN
assets/porcupine_params_zh.pv
Normal file
BIN
assets/porcupine_params_zh.pv
Normal file
Binary file not shown.
BIN
assets/zhongzhong_zh_android.ppn
Normal file
BIN
assets/zhongzhong_zh_android.ppn
Normal file
Binary file not shown.
@@ -3,6 +3,8 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:ai_chat_assistant/ai_chat_assistant.dart';
|
import 'package:ai_chat_assistant/ai_chat_assistant.dart';
|
||||||
|
|
||||||
|
import 'pages/home.dart';
|
||||||
|
|
||||||
class VehicleCommandClent extends VehicleCommandHandler {
|
class VehicleCommandClent extends VehicleCommandHandler {
|
||||||
@override
|
@override
|
||||||
AIChatCommandCubit? get commandCubit => super.commandCubit;
|
AIChatCommandCubit? get commandCubit => super.commandCubit;
|
||||||
@@ -13,7 +15,6 @@ class VehicleCommandClent extends VehicleCommandHandler {
|
|||||||
print('执行车控命令: ${command.type}, 参数: ${command.params}');
|
print('执行车控命令: ${command.type}, 参数: ${command.params}');
|
||||||
|
|
||||||
if (commandCubit != null) {
|
if (commandCubit != null) {
|
||||||
|
|
||||||
commandCubit?.emit(AIChatCommandState(
|
commandCubit?.emit(AIChatCommandState(
|
||||||
commandId: command.commandId,
|
commandId: command.commandId,
|
||||||
commandType: command.type,
|
commandType: command.type,
|
||||||
@@ -64,6 +65,8 @@ void main() async {
|
|||||||
AIChatAssistantManager.instance.setupCommandHandle(commandHandler: VehicleCommandClent());
|
AIChatAssistantManager.instance.setupCommandHandle(commandHandler: VehicleCommandClent());
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
|
|
||||||
|
AIChatAssistantManager.instance.setWakeWordDetection(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatelessWidget {
|
||||||
@@ -77,18 +80,7 @@ class MyApp extends StatelessWidget {
|
|||||||
primarySwatch: Colors.blue,
|
primarySwatch: Colors.blue,
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: const ExampleHomePage(),
|
home: HomePage(),
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExampleHomePage extends StatelessWidget {
|
|
||||||
const ExampleHomePage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Scaffold(
|
|
||||||
body: ChatAssistantApp(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
example/lib/pages/home.dart
Normal file
62
example/lib/pages/home.dart
Normal file
@@ -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<HomePage> createState() => _HomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePageState extends State<HomePage> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
final List<Widget> _pages = <Widget>[
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
example/lib/pages/setting.dart
Normal file
29
example/lib/pages/setting.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SettingPage extends StatefulWidget {
|
||||||
|
const SettingPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingPage> createState() => _SettingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingPageState extends State<SettingPage> {
|
||||||
|
@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('这里可以添加更多设置选项'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,6 +201,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.3"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -430,6 +438,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
provider:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
100
lib/manager.dart
100
lib/manager.dart
@@ -1,5 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.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/ai_chat_cubit.dart';
|
||||||
import 'bloc/command_state.dart';
|
import 'bloc/command_state.dart';
|
||||||
@@ -44,9 +50,103 @@ class AIChatAssistantManager {
|
|||||||
return _commandCubit!.stream;
|
return _commandCubit!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Porcupine 唤醒词属性 ---
|
||||||
|
|
||||||
|
PorcupineManager? _porcupineManager;
|
||||||
|
final StreamController<void> _wakeWordController = StreamController<void>.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<void> get onWakeWordDetected => _wakeWordController.stream;
|
||||||
|
|
||||||
|
/// 当前是否检测到了唤醒词
|
||||||
|
bool get isWakeWordDetected => _wakeWordDetected;
|
||||||
|
|
||||||
|
/// 将 Flutter asset 写入设备文件系统,返回其真实路径
|
||||||
|
Future<String> _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<void> 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() {
|
void dispose() {
|
||||||
_commandCubit?.close();
|
_commandCubit?.close();
|
||||||
_commandCubit = null;
|
_commandCubit = null;
|
||||||
|
_wakeWordController.close();
|
||||||
|
setWakeWordDetection(false); // 确保 Porcupine 停止并释放
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
import 'package:ai_chat_assistant/manager.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../pages/full_screen.dart';
|
import '../pages/full_screen.dart';
|
||||||
@@ -30,6 +32,7 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
|
|||||||
final double _iconSizeDefault = 80.0;
|
final double _iconSizeDefault = 80.0;
|
||||||
bool _isShowingPopup = false;
|
bool _isShowingPopup = false;
|
||||||
late AnimationController _partScreenAnimationController;
|
late AnimationController _partScreenAnimationController;
|
||||||
|
StreamSubscription? _wakeWordSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -38,11 +41,21 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
|
|||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 订阅唤醒词事件
|
||||||
|
_wakeWordSubscription = AIChatAssistantManager.instance.onWakeWordDetected.listen((_) async {
|
||||||
|
// 在这里处理唤醒事件,例如显示聊天窗口
|
||||||
|
debugPrint("唤醒词被检测到,执行UI操作!");
|
||||||
|
final messageService = MessageService.instance;
|
||||||
|
_insertOverlay();
|
||||||
|
await messageService.startVoiceInput();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_partScreenAnimationController.dispose();
|
_partScreenAnimationController.dispose();
|
||||||
|
_wakeWordSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -157,6 +157,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.3"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -354,6 +362,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dependencies:
|
|||||||
permission_handler: ^12.0.0
|
permission_handler: ^12.0.0
|
||||||
provider: ^6.1.5
|
provider: ^6.1.5
|
||||||
flutter_tts: ^4.2.0
|
flutter_tts: ^4.2.0
|
||||||
|
# 语音唤醒
|
||||||
|
porcupine_flutter: ^3.0.5
|
||||||
# basic_intl: 0.2.0
|
# basic_intl: 0.2.0
|
||||||
t_basic_intl:
|
t_basic_intl:
|
||||||
path: packages/basic_intl
|
path: packages/basic_intl
|
||||||
@@ -33,6 +35,8 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
- assets/zhongzhong_zh_android.ppn
|
||||||
|
- assets/porcupine_params_zh.pv
|
||||||
plugin:
|
plugin:
|
||||||
platforms:
|
platforms:
|
||||||
android:
|
android:
|
||||||
|
|||||||
Reference in New Issue
Block a user