guoyu/fronted_uniapp/uni_modules/xwq-speech-to-text/utssdk/app-android/index.uts

481 lines
13 KiB
Plaintext
Raw Normal View History

// 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
2025-12-03 18:58:36 +08:00
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)
2025-12-03 18:58:36 +08:00
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, '')
}
})
}
2025-12-03 18:58:36 +08:00
}
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, '')
}
})
}
2025-12-03 18:58:36 +08:00
}
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, '')
}
})
}
2025-12-03 18:58:36 +08:00
}
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 ?? '未知错误')
}
})
2025-12-03 18:58:36 +08:00
}
override onTimeout() : void {
// 不抛出异常避免APP闪退通过回调通知超时
console.error('[Vosk] 识别超时')
this.onSpeekResultCallback?.({
code: -1,
data: {
text: '',
errorMsg: '识别超时'
}
})
2025-12-03 18:58:36 +08:00
}
}
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)
// 不抛出异常,避免闪退
2025-12-03 18:58:36 +08:00
} 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}`);
2025-12-03 18:58:36 +08:00
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)
}
2025-12-03 18:58:36 +08:00
}
}
} catch (e : Exception) {
console.error('[Vosk] 语音识别启动失败:', e.message)
cb({
code: -1,
data: {
text: '',
errorMsg: '语音识别启动失败: ' + (e.message ?? '未知错误')
}
})
2025-12-03 18:58:36 +08:00
}
}, null)
}
//停止识别
stopSpeechVoice() {
try {
this.speechService?.stop()
this.speechService?.reset()
} catch (e : Exception) {
console.error('[Vosk] 停止识别时出错:', e.message)
// 不抛出异常,避免闪退
}
2025-12-03 18:58:36 +08:00
}
}
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
}