442 lines
13 KiB
Plaintext
442 lines
13 KiB
Plaintext
|
|
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('hypothesis==', hypothesis)
|
|
const result = (JSON.parse(hypothesis) as UTSJSONObject).text ?? '';
|
|
this.onSpeekResultCallback?.({
|
|
code: 0,
|
|
data: {
|
|
text: (result as string).replace(/\s/g, '')
|
|
}
|
|
})
|
|
}
|
|
|
|
override onPartialResult(hypothesis : string) : void {
|
|
// console.log("临时结果: " + hypothesis);
|
|
}
|
|
|
|
override onFinalResult(hypothesis : string) : void {
|
|
// console.log("最终确认结果: " + hypothesis);
|
|
}
|
|
|
|
override onError(e : Exception) : void {
|
|
throw IOException(`识别错误: ${e.message}`, e);
|
|
}
|
|
|
|
override onTimeout() : void {
|
|
throw IOException("识别超时")
|
|
}
|
|
}
|
|
|
|
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()
|
|
// console.log('outputDir.isDirectory====', outputDir.isDirectory)
|
|
// console.log('files is null====', files != null)
|
|
// console.log('isNotEmpty====', files.isNotEmpty())
|
|
if (outputDir.isDirectory && files != null && files.isNotEmpty()) {
|
|
// 删除目录及其内容
|
|
outputDir.deleteRecursively()
|
|
} else {
|
|
// // console.log('是否为目录===',outputDir.isDirectory)
|
|
// 如果输出路径存在但不是目录,抛出异常
|
|
// throw IOException("输出路径存在且不是目录: " + outputDir.getAbsolutePath())
|
|
}
|
|
} else if (!outputDir.mkdirs()) {
|
|
throw IOException("Directory creation failed: " + outputDir.getAbsolutePath())
|
|
}
|
|
|
|
// console.log('pass----')
|
|
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());
|
|
}
|
|
|
|
// // console.log('创建的目录地址---',outputFile)
|
|
}
|
|
//处理文件
|
|
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) {
|
|
// // console.log('只有非目录时写入')
|
|
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) // 更新 len
|
|
}
|
|
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.log('e====',e)
|
|
throw IOException(`解压失败: ${e.message}`, e);
|
|
} 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];
|
|
|
|
// //判断是否存在目录
|
|
// let file : File = new File(cpFilePath);
|
|
// if (!file.getParentFile().exists()) {
|
|
// file.getParentFile().mkdirs(); // 创建父目录
|
|
// // console.log('没有父级目录')
|
|
// }
|
|
|
|
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 externalDir = UTSAndroid.getAppContext()!.getExternalFilesDir(null)
|
|
// const ExternalPath:string=externalDir!.getAbsolutePath()?? ""
|
|
const status = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)
|
|
|
|
if (!status) {
|
|
throw IOException("External storage is unavailable")
|
|
}
|
|
|
|
const ExternalPath = this.getExternalPath();
|
|
|
|
// let outputDirName = option.zipModelPath.split('/').pop()?.replace('.zip', '');
|
|
// console.log('模型名称===', zipName)
|
|
const modelZipTemp : File = new File(option.zipModelPath);
|
|
const outputDirTemp : File = new File(`${ExternalPath}/${zipName}`);
|
|
// console.log('modelZipTemp--path', modelZipTemp.getAbsolutePath())
|
|
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 (_) {
|
|
let model = new Model(this.unzipPath) // 加载模型
|
|
this.recognizer = new Recognizer(model, sampleRate) // 采样率16kHz
|
|
|
|
//实时语音识别
|
|
if (this.voskType === '1') {
|
|
// this.recognizer!.setMaxAlternatives(5) // 设置最大候选结果数
|
|
// this.recognizer!.setWords(true) // 启用词语级时间戳
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 获取最终结果
|
|
// console.log(this.recognizer)
|
|
}
|
|
}, null)
|
|
}
|
|
//停止识别
|
|
stopSpeechVoice() {
|
|
// this.recognizer!.close();
|
|
this.speechService?.stop()
|
|
this.speechService?.reset() // 重置识别器以备下次使用
|
|
}
|
|
}
|
|
|
|
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
|
|
} |