feat: 添加唤醒功能
This commit is contained in:
@@ -30,6 +30,10 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<service
|
||||
android:name="com.tsystems.flutter_vosk_wakeword.WakewordService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -66,7 +66,9 @@ void main() async {
|
||||
|
||||
runApp(const MyApp());
|
||||
|
||||
AIChatAssistantManager.instance.setWakeWordDetection(true);
|
||||
// AIChatAssistantManager.instance.setWakeWordDetection(true);
|
||||
AIChatAssistantManager.instance.startVoskWakeword();
|
||||
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_vosk_wakeword/flutter_vosk_wakeword.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:porcupine_flutter/porcupine_error.dart';
|
||||
import 'package:porcupine_flutter/porcupine_manager.dart';
|
||||
@@ -86,6 +87,36 @@ class AIChatAssistantManager {
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Future<void> startVoskWakeword() async {
|
||||
FlutterVoskWakeword.instance.initialize(
|
||||
wakeWords: ['你好众众', '你好', '众众'], // 唤醒词列表
|
||||
);
|
||||
|
||||
debugPrint('Starting Vosk Wakeword detection...');
|
||||
FlutterVoskWakeword.instance.start();
|
||||
|
||||
FlutterVoskWakeword.instance.recognitionStream.listen((event) {
|
||||
debugPrint('Vosk Wakeword detected: ${event.text}');
|
||||
_wakeWordDetected = true;
|
||||
// 通知所有监听者
|
||||
// _wakeWordController.add(null);
|
||||
|
||||
// 可选:一段时间后自动重置状态
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_wakeWordDetected = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stopVoskWakeword() async {
|
||||
if (_wakeWordDetected) {
|
||||
_wakeWordDetected = false;
|
||||
}
|
||||
if (FlutterVoskWakeword.instance != null) {
|
||||
FlutterVoskWakeword.instance.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 开启或关闭唤醒词检测
|
||||
Future<void> setWakeWordDetection(bool enable) async {
|
||||
if (enable == _isWakeWordEnabled && _porcupineManager != null) {
|
||||
@@ -148,5 +179,7 @@ class AIChatAssistantManager {
|
||||
_commandCubit = null;
|
||||
_wakeWordController.close();
|
||||
setWakeWordDetection(false); // 确保 Porcupine 停止并释放
|
||||
FlutterVoskWakeword.instance.dispose();
|
||||
debugPrint('AIChatAssistant 单例销毁');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
91a26295-76d2-4da7-a414-4b2ebf9ddeb0
|
||||
@@ -0,0 +1 @@
|
||||
2d4432c8-7421-492b-93c2-ad728fd1947d
|
||||
@@ -1,3 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.tsystems.flutter_vosk_wakeword">
|
||||
<permission
|
||||
android:name="com.tsystems.flutter_vosk_wakeword.RECORD_AUDIO"
|
||||
android:protectionLevel="signature" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.tsystems.flutter_vosk_wakeword
|
||||
|
||||
import android.Manifest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresPermission
|
||||
import org.vosk.Recognizer
|
||||
import org.vosk.android.RecognitionListener
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class AudioRecorder(private val recognizer: Recognizer,
|
||||
private val listener: RecognitionListener) {
|
||||
companion object {
|
||||
private const val TAG = "AudioRecorder"
|
||||
private const val SAMPLE_RATE = 16000
|
||||
private const val BUFFER_SIZE_FACTOR = 2
|
||||
}
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
fun isRecording(): Boolean = audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING
|
||||
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
fun start() {
|
||||
if (isRecording()) return
|
||||
|
||||
val bufferSize = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
) * BUFFER_SIZE_FACTOR
|
||||
|
||||
// 权限检查应该在调用此方法之前在App层完成
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize
|
||||
)
|
||||
|
||||
if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) {
|
||||
audioRecord?.startRecording()
|
||||
executor.execute {
|
||||
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
|
||||
val buffer = ByteArray(bufferSize)
|
||||
while (isRecording()) {
|
||||
val nread = audioRecord?.read(buffer, 0, buffer.size) ?: 0
|
||||
if (nread > 0) {
|
||||
Log.i(TAG, "Read $nread bytes from AudioRecord")
|
||||
val ac = recognizer.acceptWaveForm(buffer, nread)
|
||||
Log.i(TAG, "Recognizer acceptWaveForm returned: $ac")
|
||||
if (ac) {
|
||||
listener.onResult(recognizer.result)
|
||||
} else {
|
||||
listener.onPartialResult(recognizer.partialResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
listener.onFinalResult(recognizer.finalResult)
|
||||
}
|
||||
Log.i(TAG, "Audio recording started.")
|
||||
} else {
|
||||
Log.e(TAG, "AudioRecord initialization failed.")
|
||||
listener.onError(Exception("AudioRecord initialization failed."))
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (isRecording()) {
|
||||
audioRecord?.stop()
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
Log.i(TAG, "Audio recording stopped.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.tsystems.flutter_vosk_wakeword
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Service 和 Plugin 之间安全地传递事件
|
||||
*/
|
||||
class EventReceiver(private val onEvent: (type: String, text: String?) -> Unit) : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val ACTION_WAKEWORD_EVENT = "com.tsystems.flutter_vosk_wakeword.WAKEWORD_EVENT"
|
||||
private const val EXTRA_EVENT_TYPE = "EXTRA_EVENT_TYPE"
|
||||
private const val EXTRA_EVENT_TEXT = "EXTRA_EVENT_TEXT"
|
||||
|
||||
fun broadcastPartial(context: Context, text: String) = broadcast(context, "partial", text)
|
||||
fun broadcastFinal(context: Context, text: String) = broadcast(context, "finalResult", text)
|
||||
fun broadcastWakeWord(context: Context, text: String) = broadcast(context, "wakeword", text)
|
||||
fun broadcastError(context: Context, text: String) = broadcast(context, "error", text)
|
||||
|
||||
private fun broadcast(context: Context, type: String, text: String?) {
|
||||
val intent = Intent(ACTION_WAKEWORD_EVENT).apply {
|
||||
setPackage(context.packageName)
|
||||
putExtra(EXTRA_EVENT_TYPE, type)
|
||||
putExtra(EXTRA_EVENT_TEXT, text)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val type = intent?.getStringExtra(EXTRA_EVENT_TYPE)
|
||||
val text = intent?.getStringExtra(EXTRA_EVENT_TEXT)
|
||||
if (type != null) {
|
||||
onEvent(type, text)
|
||||
}
|
||||
}
|
||||
|
||||
fun register(context: Context) {
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
this,
|
||||
IntentFilter(ACTION_WAKEWORD_EVENT),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
fun unregister(context: Context) {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,88 @@
|
||||
package com.tsystems.flutter_vosk_wakeword
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.annotation.NonNull
|
||||
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
|
||||
// # vosk
|
||||
// vosk
|
||||
import org.vosk.LibVosk
|
||||
|
||||
/** FlutterVoskWakewordPlugin */
|
||||
class FlutterVoskWakewordPlugin: FlutterPlugin, MethodCallHandler {
|
||||
/// The MethodChannel that will the communication between Flutter and native Android
|
||||
///
|
||||
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
|
||||
/// when the Flutter Engine is detached from the Activity
|
||||
private lateinit var channel : MethodChannel
|
||||
class FlutterVoskWakewordPlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler {
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "flutter_vosk_wakeword")
|
||||
channel.setMethodCallHandler(this)
|
||||
}
|
||||
private lateinit var methodChannel : MethodChannel
|
||||
private lateinit var eventChannel: EventChannel
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
private lateinit var context: android.content.Context
|
||||
private var eventReceiver: EventReceiver? = null
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
if (call.method == "getPlatformVersion") {
|
||||
result.success("Android ${android.os.Build.VERSION.RELEASE}")
|
||||
} else {
|
||||
result.notImplemented()
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = flutterPluginBinding.applicationContext
|
||||
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "flutter_vosk_wakeword/methods")
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
|
||||
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "flutter_vosk_wakeword/events")
|
||||
eventChannel.setStreamHandler(this)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
val callName = call.method
|
||||
if (callName == "initialize") {
|
||||
// val modelPath = call.argument<String>("modelPath")
|
||||
val wakeWords = call.argument<ArrayList<String>>("wakeWords")
|
||||
if (wakeWords == null) {
|
||||
result.error("INVALID_ARGUMENTS", "wakeWords is null", null)
|
||||
return
|
||||
}
|
||||
val intent = Intent(context, WakewordService::class.java).apply {
|
||||
action = WakewordService.ACTION_INITIALIZE
|
||||
putExtra(WakewordService.EXTRA_WAKE_WORDS, wakeWords)
|
||||
}
|
||||
context.startService(intent)
|
||||
} else if (callName == "start") {
|
||||
val intent = Intent(context, WakewordService::class.java).apply {
|
||||
action = WakewordService.ACTION_START
|
||||
}
|
||||
context.startService(intent)
|
||||
result.success(null)
|
||||
} else if (callName == "stop") {
|
||||
val intent = Intent(context, WakewordService::class.java).apply {
|
||||
action = WakewordService.ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
context.stopService(Intent(context, WakewordService::class.java)) }
|
||||
|
||||
|
||||
// --- EventChannel.StreamHandler Callbacks ---
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
eventSink = events
|
||||
// 注册广播接收器以从 Service 接收事件
|
||||
eventReceiver = EventReceiver { eventType, text ->
|
||||
// 将事件发送回 Dart
|
||||
eventSink?.success(mapOf("type" to eventType, "text" to text))
|
||||
}
|
||||
eventReceiver?.register(context)
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
eventReceiver?.unregister(context)
|
||||
eventReceiver = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.tsystems.flutter_vosk_wakeword
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
object VoskRecognizer {
|
||||
fun parsePartialResult(json: String): String {
|
||||
return try {
|
||||
JSONObject(json).getString("partial")
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun parseFinalResult(json: String): String {
|
||||
return try {
|
||||
JSONObject(json).getString("text")
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.tsystems.flutter_vosk_wakeword
|
||||
|
||||
import org.json.JSONArray
|
||||
|
||||
class WakewordDetector(private val wakeWords: ArrayList<String>) {
|
||||
|
||||
/**
|
||||
* 检查部分识别文本中是否包含任何一个唤醒词
|
||||
*/
|
||||
fun checkForWakeWord(partialText: String): Boolean {
|
||||
if (partialText.isBlank()) return false
|
||||
// 简单包含匹配,可以根据需求改为更复杂的匹配逻辑
|
||||
return wakeWords.any { wakeWord -> partialText.contains(wakeWord, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 为Vosk生成语法字符串,这可以提高唤醒词的识别准确率
|
||||
* 例如,对于 ["你好小帅", "你好小美"],生成 "[\"你好小帅\", \"你好小美\", \"[unk]\"]"
|
||||
*/
|
||||
fun getVoskGrammar(): String {
|
||||
val jsonArray = JSONArray()
|
||||
wakeWords.forEach { jsonArray.put(it) }
|
||||
// 添加 [unk] 允许识别唤醒词之外的其他词语
|
||||
// jsonArray.put("[unk]")
|
||||
return jsonArray.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.tsystems.flutter_vosk_wakeword
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresPermission
|
||||
|
||||
|
||||
import org.vosk.Model
|
||||
import org.vosk.Recognizer
|
||||
import org.vosk.android.RecognitionListener
|
||||
import org.vosk.android.StorageService
|
||||
import java.io.IOException
|
||||
import kotlin.collections.contentToString
|
||||
|
||||
class WakewordService: Service(), RecognitionListener {
|
||||
|
||||
companion object {
|
||||
const val ACTION_INITIALIZE = "ACTION_INITIALIZE"
|
||||
const val ACTION_START = "ACTION_START"
|
||||
const val ACTION_STOP = "ACTION_STOP"
|
||||
const val EXTRA_WAKE_WORDS = "EXTRA_WAKE_WORDS"
|
||||
private const val TAG = "WakewordService"
|
||||
private const val ASSET_MODEL_DIR = "model-cn"
|
||||
private const val LOCAL_MODEL_DIR = "model"
|
||||
|
||||
// 冷却时间: 同一唤醒词触发后在此毫秒内不再重复广播
|
||||
private const val DETECTION_COOLDOWN_MS = 2500L
|
||||
}
|
||||
|
||||
private val mainHandle = Handler(Looper.getMainLooper())
|
||||
|
||||
private var model: Model? = null
|
||||
private var recognizer: Recognizer? = null
|
||||
private var audioRecorder: AudioRecorder? = null
|
||||
private var wakeWordDetector: WakewordDetector? = null
|
||||
|
||||
private var pendingStart = false
|
||||
private var modelLoading = false
|
||||
|
||||
// 去重/状态控制
|
||||
private var lastWakeWord: String? = null
|
||||
private var lastDetectTs: Long = 0L
|
||||
private var suppressUntilFinal = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.w(TAG, "onCreate pid=${Process.myPid()} thread=${Thread.currentThread().name}")
|
||||
dumpAssets()
|
||||
}
|
||||
|
||||
private fun dumpAssets() {
|
||||
try {
|
||||
val root = assets.list("")?.joinToString()
|
||||
Log.w(TAG, "assets root=$root")
|
||||
val cn = assets.list("model-cn")?.joinToString()
|
||||
Log.w(TAG, "assets/model-cn=$cn")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "dumpAssets error", e)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action
|
||||
when (action) {
|
||||
ACTION_INITIALIZE -> {
|
||||
val wakeWords = intent.getStringArrayListExtra(EXTRA_WAKE_WORDS)
|
||||
Log.i(TAG, "ACTION_INITIALIZE wakeWords=${wakeWords.toString()}")
|
||||
if (wakeWords != null && wakeWords.isNotEmpty()) {
|
||||
initialize(wakeWords)
|
||||
} else {
|
||||
Log.e(TAG, "No valid wake words found in Intent extras.")
|
||||
}
|
||||
}
|
||||
ACTION_START -> {
|
||||
pendingStart = true
|
||||
Log.i(TAG, "ACTION_START received (modelReady=${model != null}, loading=$modelLoading)")
|
||||
startRecognition()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
Log.i(TAG, "ACTION_STOP received")
|
||||
stopRecognition()
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun initialize(wakeWords: ArrayList<String>) {
|
||||
if (model != null) {
|
||||
Log.i(TAG, "Model already loaded; skip initialize.")
|
||||
return
|
||||
}
|
||||
if (modelLoading) {
|
||||
Log.i(TAG, "Model already loading; skip duplicate initialize.")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "initialize() begin")
|
||||
|
||||
try {
|
||||
stopRecognition()
|
||||
wakeWordDetector = WakewordDetector(wakeWords)
|
||||
modelLoading = true
|
||||
Log.i(TAG, "Submitting StorageService.unpack source=model-cn target=model")
|
||||
StorageService.unpack(this,
|
||||
ASSET_MODEL_DIR,
|
||||
LOCAL_MODEL_DIR,
|
||||
{ loadedModel ->
|
||||
Log.i(TAG, "Vosk model loaded successfully")
|
||||
this.model = loadedModel
|
||||
modelLoading = false
|
||||
if (pendingStart) {
|
||||
mainHandle.post {
|
||||
startRecognition()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ exception ->
|
||||
modelLoading = false
|
||||
val errorMsg = "Failed to unpack the model: ${exception.message}"
|
||||
Log.e(TAG, errorMsg, exception)
|
||||
EventReceiver.broadcastError(this, errorMsg)
|
||||
})
|
||||
} catch (e: IOException) {
|
||||
modelLoading = false
|
||||
Log.e(TAG, "Failed to load Vosk model", e)
|
||||
EventReceiver.broadcastError(this, "Failed to load Vosk model: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
private fun startRecognition() {
|
||||
val modelReady = model != null
|
||||
Log.i(TAG, "startRecognition(modelReady=$modelReady pendingStart=$pendingStart loading=$modelLoading)")
|
||||
if (!pendingStart) {
|
||||
Log.i(TAG, "No pending start request; abort.")
|
||||
return
|
||||
}
|
||||
if (!modelReady) {
|
||||
Log.i(TAG, "Model not ready yet; start deferred.")
|
||||
return
|
||||
}
|
||||
if (audioRecorder?.isRecording() == true) {
|
||||
Log.w(TAG, "Already recording.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val grammar = wakeWordDetector?.getVoskGrammar()
|
||||
val currentRecognizer = Recognizer(model, 16000.0f, grammar)
|
||||
recognizer = currentRecognizer
|
||||
|
||||
audioRecorder = AudioRecorder(currentRecognizer, this)
|
||||
audioRecorder?.start()
|
||||
Log.i(TAG, "Recognition started.")
|
||||
resetDetectionState()
|
||||
} catch (e: Exception) {
|
||||
val errorMsg = "Failed to start recognition: ${e.message}"
|
||||
Log.e(TAG, "Failed to start recognition", e)
|
||||
EventReceiver.broadcastError(this, errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecognition() {
|
||||
Log.i(TAG, "stopRecognition()")
|
||||
audioRecorder?.stop()
|
||||
audioRecorder = null
|
||||
recognizer?.close()
|
||||
recognizer = null
|
||||
Log.i(TAG, "Recognition stopped.")
|
||||
resetDetectionState()
|
||||
}
|
||||
|
||||
private fun resetDetectionState() {
|
||||
suppressUntilFinal = false
|
||||
lastWakeWord = null
|
||||
lastDetectTs = 0L
|
||||
}
|
||||
|
||||
// --- RecognitionListener Callbacks ---
|
||||
override fun onPartialResult(hypothesis: String?) {
|
||||
hypothesis ?: return
|
||||
val partialText = VoskRecognizer.parsePartialResult(hypothesis)
|
||||
if (partialText.isBlank()) return
|
||||
Log.d(TAG, "partial=$partialText suppress=$suppressUntilFinal")
|
||||
|
||||
if (suppressUntilFinal) return
|
||||
|
||||
if (wakeWordDetector?.checkForWakeWord(partialText) == true) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (shouldBroadcast(partialText, now)) {
|
||||
broadcastWake(partialText, now, source = "partial")
|
||||
// 抑制后续重复直到 finalResult 或超时
|
||||
suppressUntilFinal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResult(hypothesis: String?) {
|
||||
hypothesis ?: return
|
||||
val finalText = VoskRecognizer.parseFinalResult(hypothesis)
|
||||
if (finalText.isBlank()) return
|
||||
Log.d(TAG, "result=$finalText suppress=$suppressUntilFinal")
|
||||
|
||||
if (wakeWordDetector?.checkForWakeWord(finalText) == true) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (shouldBroadcast(finalText, now)) {
|
||||
broadcastWake(finalText, now, source = "final")
|
||||
}
|
||||
}
|
||||
// final / result 结束一轮,允许下一次
|
||||
suppressUntilFinal = false
|
||||
}
|
||||
|
||||
override fun onFinalResult(hypothesis: String?) {
|
||||
// 已在 onResult 处理; 这里可以忽略或调用 onResult
|
||||
onResult(hypothesis)
|
||||
}
|
||||
|
||||
private fun shouldBroadcast(word: String, now: Long): Boolean {
|
||||
val cooldownPassed = (now - lastDetectTs) > DETECTION_COOLDOWN_MS
|
||||
val differentWord = word != lastWakeWord
|
||||
return cooldownPassed || differentWord
|
||||
}
|
||||
|
||||
private fun broadcastWake(word: String, now: Long, source: String) {
|
||||
lastWakeWord = word
|
||||
lastDetectTs = now
|
||||
Log.i(TAG, "Wake word detected($source): $word")
|
||||
EventReceiver.broadcastWakeWord(this, word)
|
||||
// 重置识别器状态,为下一次唤醒做准备
|
||||
recognizer?.reset()
|
||||
// 可选:重建 recognizer 减少同一发音尾部抖动:
|
||||
// try {
|
||||
// val grammar = wakeWordDetector?.getVoskGrammar()
|
||||
// recognizer?.close()
|
||||
// recognizer = Recognizer(model, 16000.0f, grammar)
|
||||
// } catch (e: Exception) {
|
||||
// Log.w(TAG, "Recognizer reset failed: ${e.message}")
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onError(e: Exception?) {
|
||||
Log.e(TAG, "Recognition error", e)
|
||||
EventReceiver.broadcastError(this, e?.message ?: "Unknown recognition error")
|
||||
suppressUntilFinal = false
|
||||
}
|
||||
|
||||
override fun onTimeout() {
|
||||
Log.w(TAG, "Recognition timeout.")
|
||||
// 超时视为一轮结束
|
||||
suppressUntilFinal = false
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
stopRecognition()
|
||||
model?.close()
|
||||
model = null
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "WakewordService destroyed.")
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// 导出核心管理类
|
||||
export 'src/vosk_wakeword_manager.dart';
|
||||
|
||||
import 'src/interface/flutter_vosk_wakeword_platform_interface.dart';
|
||||
// 导出错误定义
|
||||
export 'src/vosk_wakeword_error.dart';
|
||||
|
||||
class FlutterVoskWakeword {
|
||||
Future<String?> getPlatformVersion() {
|
||||
return FlutterVoskWakewordPlatform.instance.getPlatformVersion();
|
||||
}
|
||||
}
|
||||
// 导出数据模型
|
||||
export 'src/model/recognition_event.dart';
|
||||
@@ -26,4 +26,7 @@ abstract class FlutterVoskWakewordPlatform extends PlatformInterface {
|
||||
Future<String?> getPlatformVersion() {
|
||||
throw UnimplementedError('platformVersion() has not been implemented.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
// recognition event type
|
||||
enum RecognitionEventType {
|
||||
// partial
|
||||
partial,
|
||||
// final result
|
||||
finalResult,
|
||||
// wakeword
|
||||
wakeword,
|
||||
// error
|
||||
error
|
||||
}
|
||||
|
||||
// recognition event
|
||||
class RecognitionEvent {
|
||||
final RecognitionEventType type;
|
||||
final String? text;
|
||||
|
||||
RecognitionEvent({required this.type, this.text});
|
||||
|
||||
factory RecognitionEvent.fromJson(Map<String, dynamic> json) {
|
||||
return RecognitionEvent(
|
||||
type: RecognitionEventType.values.firstWhere((e) => e.name == json['type']),
|
||||
text: json['text'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.name,
|
||||
'text': text,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RecognitionEvent{type: $type, text: $text}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/// Vosk 插件的基础异常类
|
||||
class VoskWakewordException implements Exception {
|
||||
final String? message;
|
||||
VoskWakewordException([this.message]);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
}
|
||||
|
||||
/// 当原生代码无法分配内存时抛出
|
||||
class VoskWakewordMemoryException extends VoskWakewordException {
|
||||
VoskWakewordMemoryException(super.message);
|
||||
}
|
||||
|
||||
/// 当模型文件或IO操作失败时抛出
|
||||
class VoskWakewordIOException extends VoskWakewordException {
|
||||
VoskWakewordIOException(super.message);
|
||||
}
|
||||
|
||||
/// 当传入无效参数时抛出
|
||||
class VoskWakewordInvalidArgumentException extends VoskWakewordException {
|
||||
VoskWakewordInvalidArgumentException(super.message);
|
||||
}
|
||||
|
||||
/// 当状态不正确(例如,未初始化就调用start)时抛出
|
||||
class VoskWakewordInvalidStateException extends VoskWakewordException {
|
||||
VoskWakewordInvalidStateException(super.message);
|
||||
}
|
||||
|
||||
/// 当发生未预期的运行时错误时抛出
|
||||
class VoskWakewordRuntimeException extends VoskWakewordException {
|
||||
VoskWakewordRuntimeException(super.message);
|
||||
}
|
||||
|
||||
/// 将原生端返回的错误码转换为对应的 Exception
|
||||
Exception voskStatusToException(String code, String? message) {
|
||||
switch (code) {
|
||||
case 'VoskWakewordMemoryException':
|
||||
return VoskWakewordMemoryException(message);
|
||||
case 'VoskWakewordIOException':
|
||||
return VoskWakewordIOException(message);
|
||||
case 'VoskWakewordInvalidArgumentException':
|
||||
return VoskWakewordInvalidArgumentException(message);
|
||||
case 'VoskWakewordInvalidStateException':
|
||||
return VoskWakewordInvalidStateException(message);
|
||||
case 'VoskWakewordRuntimeException':
|
||||
return VoskWakewordRuntimeException(message);
|
||||
default:
|
||||
return VoskWakewordException("Unexpected error: $code, message: $message");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'vosk_wakeword_error.dart';
|
||||
import 'model/recognition_event.dart';
|
||||
|
||||
/// Vosk 唤醒词插件的主接口 (单例模式)
|
||||
///
|
||||
/// 这个类负责向原生代码发送指令,并通过 Stream 接收识别事件。
|
||||
/// 所有的音频采集和处理都在原生端完成。
|
||||
class FlutterVoskWakeword {
|
||||
// 私有构造函数
|
||||
FlutterVoskWakeword._internal();
|
||||
// 获取唯一实例
|
||||
static FlutterVoskWakeword get instance => _instance;
|
||||
// 静态、唯一的实例
|
||||
static final FlutterVoskWakeword _instance = FlutterVoskWakeword._internal();
|
||||
|
||||
// Factory 构造函数返回唯一实例
|
||||
factory FlutterVoskWakeword() => _instance;
|
||||
|
||||
static const MethodChannel _methodChannel = MethodChannel('flutter_vosk_wakeword/methods');
|
||||
static const EventChannel _eventChannel = EventChannel('flutter_vosk_wakeword/events');
|
||||
|
||||
Stream<RecognitionEvent>? _recognitionStream;
|
||||
|
||||
/// 监听识别事件的流
|
||||
Stream<RecognitionEvent> get recognitionStream {
|
||||
_recognitionStream ??= _eventChannel
|
||||
.receiveBroadcastStream()
|
||||
.map((event) => RecognitionEvent.fromJson(Map<String, dynamic>.from(event)));
|
||||
return _recognitionStream!;
|
||||
}
|
||||
|
||||
/// 初始化 Vosk 引擎和模型
|
||||
Future<bool> initialize({
|
||||
// required String modelPath,
|
||||
required List<String> wakeWords,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _methodChannel.invokeMethod<bool>('initialize', {
|
||||
// 'modelPath': modelPath,
|
||||
'wakeWords': wakeWords,
|
||||
});
|
||||
return result ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
throw voskStatusToException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始监听
|
||||
Future<bool> start() async {
|
||||
try {
|
||||
return await _methodChannel.invokeMethod<bool>('start') ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
throw voskStatusToException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止监听
|
||||
Future<bool> stop() async {
|
||||
try {
|
||||
return await _methodChannel.invokeMethod<bool>('stop') ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
throw voskStatusToException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放所有原生资源
|
||||
Future<bool> dispose() async {
|
||||
try {
|
||||
return await _methodChannel.invokeMethod<bool>('dispose') ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
throw voskStatusToException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
pigeon: ^25.5.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
Reference in New Issue
Block a user