481 lines
13 KiB
Plaintext
481 lines
13 KiB
Plaintext
// VERSION: 1.0.13 - Fixed Kotlin reserved word conflict (error -> errorMsg)
|
||
// Plugin ID changed to force recompilation: xwq-speech-to-text-fixed
|
||
// Last updated: 2025-12-05 18:05
|
||
import Context from "android.content.Context"
|
||
import File from "java.io.File"
|
||
import FileOutputStream from "java.io.FileOutputStream"
|
||
import FileInputStream from "java.io.FileInputStream"
|
||
import IOException from "java.io.IOException"
|
||
import InputStream from "java.io.InputStream"
|
||
import AudioFormat from "android.media.AudioFormat"
|
||
import AudioRecord from "android.media.AudioRecord"
|
||
import MediaRecorder from "android.media.MediaRecorder"
|
||
import ZipEntry from "java.util.zip.ZipEntry"// ZIP条目处理
|
||
import ZipInputStream from "java.util.zip.ZipInputStream"// ZIP解压流
|
||
import Activity from 'android.app.Activity';
|
||
import { UTSAndroid } from "io.dcloud.uts";
|
||
import ByteArray from "kotlin.ByteArray";
|
||
import Environment from 'android.os.Environment'
|
||
|
||
import Model from "org.vosk.Model"
|
||
import Recognizer from "org.vosk.Recognizer"
|
||
import RecognitionListener from "org.vosk.android.RecognitionListener"
|
||
import SpeechService from "org.vosk.android.SpeechService"
|
||
import SpeechStreamService from "org.vosk.android.SpeechStreamService"
|
||
import StorageService from "org.vosk.android.StorageService"
|
||
|
||
import { initPerssion } from './getPermission.uts';
|
||
import { CallbackType, InitModelData, SpeechResultCallback, RedultType, DownloadCallback } from '../interface.uts';
|
||
|
||
|
||
const sampleRate : Float = (16000.0).toFloat() // 必须与模型匹配
|
||
|
||
// 语音监听器类
|
||
@UTSJS.keepAlive
|
||
class MyRecognitionListener implements RecognitionListener {
|
||
private TAG = "SpeechListener";
|
||
private activity : Activity | null = null;
|
||
private onSpeekResultCallback : SpeechResultCallback | null = null;
|
||
|
||
constructor(activity : Activity) {
|
||
super();
|
||
this.activity = activity;
|
||
}
|
||
|
||
setSpeekResultCallback(cb : SpeechResultCallback) {
|
||
this.onSpeekResultCallback = cb
|
||
}
|
||
|
||
override onResult(hypothesis : string) : void {
|
||
console.log('[Vosk] onResult:', hypothesis)
|
||
const result = (JSON.parse(hypothesis) as UTSJSONObject).text ?? '';
|
||
if (result != null && (result as string).length > 0) {
|
||
this.onSpeekResultCallback?.({
|
||
code: 0,
|
||
data: {
|
||
text: (result as string).replace(/\s/g, '')
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
override onPartialResult(hypothesis : string) : void {
|
||
console.log('[Vosk] onPartialResult:', hypothesis)
|
||
// 实时识别结果,持续触发
|
||
const result = (JSON.parse(hypothesis) as UTSJSONObject).partial ?? '';
|
||
if (result != null && (result as string).length > 0) {
|
||
this.onSpeekResultCallback?.({
|
||
code: 0,
|
||
data: {
|
||
text: (result as string).replace(/\s/g, '')
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
override onFinalResult(hypothesis : string) : void {
|
||
console.log('[Vosk] onFinalResult:', hypothesis)
|
||
const result = (JSON.parse(hypothesis) as UTSJSONObject).text ?? '';
|
||
if (result != null && (result as string).length > 0) {
|
||
this.onSpeekResultCallback?.({
|
||
code: 0,
|
||
data: {
|
||
text: (result as string).replace(/\s/g, '')
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
override onError(e : Exception) : void {
|
||
// 不抛出异常,避免APP闪退,通过回调通知错误
|
||
// 修复Kotlin保留字冲突 - Updated: 2025-12-05 16:05
|
||
console.error('[Vosk] 识别错误:', e.message)
|
||
this.onSpeekResultCallback?.({
|
||
code: -1,
|
||
data: {
|
||
text: '',
|
||
errorMsg: '识别错误: ' + (e.message ?? '未知错误')
|
||
}
|
||
})
|
||
}
|
||
|
||
override onTimeout() : void {
|
||
// 不抛出异常,避免APP闪退,通过回调通知超时
|
||
console.error('[Vosk] 识别超时')
|
||
this.onSpeekResultCallback?.({
|
||
code: -1,
|
||
data: {
|
||
text: '',
|
||
errorMsg: '识别超时'
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
class ZipExtractor {
|
||
private zis : ZipInputStream | null = null;
|
||
private fos : FileOutputStream | null = null;
|
||
private recognizer : Recognizer | null = null;
|
||
private speechService : SpeechService | null = null;
|
||
private unzipPath : string = '';
|
||
private voskType : string = "1";
|
||
|
||
private extractModel(modelZipFile : File, outputDir : File, cb : () => void) : void {
|
||
UTSAndroid.getDispatcher("io").async(function (_) {
|
||
if (Thread.currentThread().name.contains("DefaultDispatcher")) {
|
||
try {
|
||
// 确保输出目录为空
|
||
if (outputDir.exists()) {
|
||
let files = outputDir.list()
|
||
if (outputDir.isDirectory && files != null && files.isNotEmpty()) {
|
||
// 删除目录及其内容
|
||
outputDir.deleteRecursively()
|
||
}
|
||
} else if (!outputDir.mkdirs()) {
|
||
throw IOException("Directory creation failed: " + outputDir.getAbsolutePath())
|
||
}
|
||
|
||
this.zis = new ZipInputStream(new FileInputStream(modelZipFile));
|
||
let entry = this.zis!.getNextEntry();
|
||
while (entry != null) {
|
||
|
||
let outputFile : File = new File(outputDir, entry.getName());
|
||
//处理目录
|
||
if (entry.isDirectory()) {
|
||
if (!outputFile.exists() && !outputFile.mkdirs()) {
|
||
throw new IOException("Directory creation failed: " + outputFile.getAbsolutePath());
|
||
}
|
||
}
|
||
//处理文件
|
||
else {
|
||
// 确保父目录存在
|
||
let parent = outputFile.getParentFile();
|
||
if (parent != null && !parent.exists()) {
|
||
if (!parent.mkdirs()) {
|
||
throw IOException("Failed to create the parent directory: " + parent);
|
||
}
|
||
}
|
||
|
||
// 如果文件已存在,先删除
|
||
if (outputFile.exists() && !outputFile.delete()) {
|
||
throw IOException("Cannot delete the existing files: " + outputFile.getAbsolutePath())
|
||
}
|
||
// 创建一个空文件
|
||
if (!outputFile.createNewFile()) {
|
||
throw IOException("Unable to create the file: " + outputFile.getAbsolutePath())
|
||
}
|
||
|
||
try {
|
||
if (!outputFile.isDirectory) {
|
||
this.fos = new FileOutputStream(outputFile)
|
||
|
||
let lens : Int = 1024;
|
||
const buffer : ByteArray = new ByteArray(lens);
|
||
let len : Int = this.zis!.read(buffer);
|
||
while (len > 0) {
|
||
this.fos!.write(buffer, 0, len);
|
||
len = this.zis!.read(buffer)
|
||
}
|
||
this.fos!.fd.sync()
|
||
}
|
||
|
||
} catch (e : Error) {
|
||
// console.log('e====', e)
|
||
}
|
||
finally {
|
||
if (this.fos != null) {
|
||
this.fos!.close();
|
||
}
|
||
}
|
||
}
|
||
// 关闭当前条目,准备读取下一个条目
|
||
this.zis?.closeEntry()
|
||
// 获取下一个条目
|
||
entry = this.zis!.getNextEntry()
|
||
}
|
||
//关闭ZipInputStream
|
||
this.zis!.close()
|
||
cb();
|
||
|
||
|
||
} catch (e : Error) {
|
||
console.error('[Vosk] 解压失败:', e.message)
|
||
// 不抛出异常,避免闪退
|
||
} finally {
|
||
if (this.zis != null) {
|
||
this.zis!.close();
|
||
}
|
||
}
|
||
}
|
||
}, null)
|
||
}
|
||
//初始化模型
|
||
initModel(option : InitModelData, cb : (result : CallbackType) => void) {
|
||
if (option.voskType != null && option.modelPath != '') {
|
||
this.voskType = option.voskType!
|
||
}
|
||
//已解压过模型,不需要重复解压
|
||
if (option.modelPath != null && option.modelPath != '') {
|
||
this.unzipPath = option.modelPath!
|
||
cb({
|
||
code: 0,
|
||
data: {
|
||
modelPath: this.unzipPath
|
||
}
|
||
} as CallbackType)
|
||
return
|
||
}
|
||
|
||
const suorceSplit = option.zipModelPath.split('/');
|
||
const zipName = suorceSplit.pop()?.replace('.zip', '');
|
||
|
||
// #ifdef UNI-APP-X
|
||
|
||
const userFilePath = UTSAndroid.convert2AbsFullPath(uni.env.USER_DATA_PATH);
|
||
let cpFilePath = userFilePath + suorceSplit[suorceSplit.length - 1];
|
||
|
||
const fileManager = uni.getFileSystemManager();
|
||
//项目目录地址才复制,网络下载直接就是应用缓存地址
|
||
if (option.zipModelPath.startsWith('/static')) {
|
||
fileManager.copyFileSync(option.zipModelPath, cpFilePath);
|
||
} else {
|
||
cpFilePath = option.zipModelPath;
|
||
}
|
||
|
||
const outZipPath = UTSAndroid.convert2AbsFullPath(uni.env.USER_DATA_PATH + '/vosk-model');
|
||
const modelZip : File = new File(cpFilePath);
|
||
const outputDir : File = new File(outZipPath);
|
||
|
||
this.extractModel(modelZip, outputDir, () => {
|
||
const uuidFile : File = new File(outputDir, `/${zipName}/uuid`);
|
||
uuidFile.createNewFile();
|
||
this.unzipPath = new File(outputDir, `/${zipName}`).getAbsolutePath();
|
||
|
||
cb({
|
||
code: 0,
|
||
data: {
|
||
modelPath: this.unzipPath
|
||
}
|
||
} as CallbackType)
|
||
|
||
});
|
||
|
||
|
||
// #endif
|
||
|
||
// #ifdef APP-PLUS
|
||
const status = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)
|
||
|
||
if (!status) {
|
||
throw IOException("External storage is unavailable")
|
||
}
|
||
|
||
const ExternalPath = this.getExternalPath();
|
||
|
||
const modelZipTemp : File = new File(option.zipModelPath);
|
||
const outputDirTemp : File = new File(`${ExternalPath}`);
|
||
this.extractModel(modelZipTemp, outputDirTemp, () => {
|
||
const uuidFile : File = new File(outputDirTemp, `/${zipName}/uuid`);
|
||
uuidFile.createNewFile();
|
||
this.unzipPath = new File(outputDirTemp, `/${zipName}`).getAbsolutePath();
|
||
|
||
cb({
|
||
code: 0,
|
||
data: {
|
||
modelPath: this.unzipPath
|
||
}
|
||
} as CallbackType)
|
||
|
||
});
|
||
|
||
// #endif
|
||
|
||
}
|
||
|
||
/**
|
||
* plus项目获取外置储存file目录
|
||
*/
|
||
getExternalPath() : string {
|
||
const externalDir = UTSAndroid.getAppContext()!.getExternalFilesDir(null)
|
||
const ExternalPath : string = externalDir!.getAbsolutePath() ?? ""
|
||
return ExternalPath;
|
||
}
|
||
//开始识别
|
||
startSpeechVoice(cb : SpeechResultCallback) {
|
||
UTSAndroid.getDispatcher("io").async(function (_) {
|
||
try {
|
||
// 检查模型路径
|
||
if (this.unzipPath == null || this.unzipPath == '') {
|
||
console.error('[Vosk] 模型路径无效')
|
||
cb({
|
||
code: -1,
|
||
data: {
|
||
text: '',
|
||
errorMsg: '模型未初始化,请重新加载'
|
||
}
|
||
})
|
||
return
|
||
}
|
||
|
||
let model = new Model(this.unzipPath) // 加载模型
|
||
this.recognizer = new Recognizer(model, sampleRate) // 采样率16kHz
|
||
|
||
//实时语音识别
|
||
if (this.voskType === '1') {
|
||
this.speechService = new SpeechService(this.recognizer!, sampleRate);
|
||
const recognitionListenerClass:MyRecognitionListener=new MyRecognitionListener(UTSAndroid.getUniActivity()!);
|
||
recognitionListenerClass.setSpeekResultCallback(cb);
|
||
this.speechService!.startListening(recognitionListenerClass);
|
||
} else {
|
||
// 读取音频文件(需先转换为 PCM 格式)
|
||
const audioFile = new File(UTSAndroid.convert2AbsFullPath('/static/test01.wav'))
|
||
const inputStream = new FileInputStream(audioFile)
|
||
const buffer = new ByteArray(4096)
|
||
while (inputStream.read(buffer) != -1) {
|
||
if (this.recognizer!.acceptWaveForm(buffer, buffer.size)) {
|
||
// console.log(this.recognizer!.result)
|
||
}
|
||
}
|
||
}
|
||
} catch (e : Exception) {
|
||
console.error('[Vosk] 语音识别启动失败:', e.message)
|
||
cb({
|
||
code: -1,
|
||
data: {
|
||
text: '',
|
||
errorMsg: '语音识别启动失败: ' + (e.message ?? '未知错误')
|
||
}
|
||
})
|
||
}
|
||
}, null)
|
||
}
|
||
//停止识别
|
||
stopSpeechVoice() {
|
||
try {
|
||
this.speechService?.stop()
|
||
this.speechService?.reset()
|
||
} catch (e : Exception) {
|
||
console.error('[Vosk] 停止识别时出错:', e.message)
|
||
// 不抛出异常,避免闪退
|
||
}
|
||
}
|
||
}
|
||
|
||
const voskTool : ZipExtractor = ZipExtractor();
|
||
|
||
/**
|
||
* 初始化模型
|
||
*/
|
||
export function initVoskModel(option : InitModelData, cb : (result : CallbackType) => void) {
|
||
initPerssion(['android.permission.RECORD_AUDIO', 'android.permission.WRITE_EXTERNAL_STORAGE', 'android.permission.READ_EXTERNAL_STORAGE']).then(res => {
|
||
if (res.isPass) {
|
||
voskTool.initModel(option, cb);
|
||
} else {
|
||
initVoskModel(option, cb)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 开始识别
|
||
*/
|
||
@UTSJS.keepAlive
|
||
export function startSpeechVoice(cb : SpeechResultCallback) {
|
||
voskTool.startSpeechVoice(cb)
|
||
}
|
||
|
||
/**
|
||
* 停止识别
|
||
*/
|
||
export function stopSpeechVoice() {
|
||
voskTool.stopSpeechVoice()
|
||
}
|
||
|
||
/**
|
||
* 下载模型
|
||
*/
|
||
export function downloadModel(url : string, cb : DownloadCallback) {
|
||
// #ifdef UNI-APP-X
|
||
const userFilePath = UTSAndroid.convert2AbsFullPath(uni.env.USER_DATA_PATH);
|
||
const suorceSplit = url.split('/');
|
||
const cpFilePath = userFilePath + suorceSplit.pop();
|
||
|
||
const isfileExist = new File(cpFilePath);
|
||
if (isfileExist.exists()) {
|
||
cb({
|
||
code: 0,
|
||
filePath: cpFilePath
|
||
})
|
||
return
|
||
}
|
||
|
||
UTSAndroid.getDispatcher("io").async(function (_) {
|
||
try {
|
||
const loadTask = uni.downloadFile({
|
||
url: url,
|
||
filePath: cpFilePath,
|
||
success: (result : DownloadFileSuccess) => {
|
||
cb({
|
||
code: 0,
|
||
filePath: result.tempFilePath
|
||
})
|
||
},
|
||
fail: (result : DownloadFileFail) => {
|
||
cb({
|
||
code: -1,
|
||
filePath: ''
|
||
})
|
||
throw IOException(`Model download failed: ${result.errCode}`, result.cause);
|
||
}
|
||
});
|
||
|
||
loadTask?.onProgressUpdate((update : OnProgressDownloadResult) => {
|
||
console.log("模型下载进度: ", update.progress + '%');
|
||
})
|
||
} catch (e : Error) {
|
||
console.log('模型下载失败===', e)
|
||
}
|
||
|
||
}, null)
|
||
|
||
// #endif
|
||
|
||
|
||
// #ifdef APP-PLUS
|
||
|
||
const userFilePathTemp = voskTool.getExternalPath();
|
||
const suorceSplitTemp = url.split('/');
|
||
const cpFilePathTemp = `${userFilePathTemp}/${suorceSplitTemp.pop()}`;
|
||
|
||
UTSAndroid.getDispatcher("io").async(function (_) {
|
||
try {
|
||
const loadTask = uni.downloadFile({
|
||
url: url,
|
||
filePath: cpFilePathTemp,
|
||
success: (result : DownloadFileSuccess) => {
|
||
cb({
|
||
code: 0,
|
||
filePath: result.tempFilePath
|
||
})
|
||
},
|
||
fail: (result : DownloadFileFail) => {
|
||
cb({
|
||
code: -1,
|
||
filePath: ''
|
||
})
|
||
throw IOException(`Model download failed: ${result.errCode}`, result.cause);
|
||
}
|
||
});
|
||
|
||
loadTask?.onProgressUpdate((update : OnProgressDownloadResult) => {
|
||
console.log("模型下载进度: ", update.progress + '%');
|
||
})
|
||
} catch (e : Error) {
|
||
console.log('模型下载失败===', e)
|
||
}
|
||
|
||
}, null)
|
||
|
||
// #endif
|
||
}
|