feat: 新增语音唤醒

唤醒的逻辑跟长按的逻辑不一样,不能像之前那样长按图标来录音,结束长按来结束录音
This commit is contained in:
2025-09-26 16:23:48 +08:00
parent 4a41c25502
commit f5699fd144
11 changed files with 247 additions and 13 deletions

View File

@@ -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<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() {
_commandCubit?.close();
_commandCubit = null;
_wakeWordController.close();
setWakeWordDetection(false); // 确保 Porcupine 停止并释放
}
}

View File

@@ -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<ChatPopup> 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<ChatPopup> 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();
}