基于 Sherpa-ONNX + SenseVoice int8 实现 APK 端侧语音识别: - VoiceBridge: AudioRecord 录音 + 整段识别 + JS Bridge 回调 - ModelManager: 模型下载管理(自有服务器),支持断点续传/校验/删除 - 前端:语音按钮仅 APK 环境显示,识别结果回填 ProseMirror 编辑器 - 调试:文件日志 + /api/voice_debug 接收路由 - demo/sense_voice_demo.py: Python 端测 Demo versionCode 24→36, versionName 1.0.22→1.0.34
195 lines
7.0 KiB
Kotlin
195 lines
7.0 KiB
Kotlin
package com.cyjai.agent
|
||
|
||
import android.content.Context
|
||
import android.util.Log
|
||
import kotlinx.coroutines.*
|
||
import java.io.*
|
||
import java.net.HttpURLConnection
|
||
import java.net.URL
|
||
|
||
/**
|
||
* 语音模型下载管理器
|
||
* 首次启动时自动下载 SenseVoice + Silero VAD 模型到内部存储
|
||
*
|
||
* 模型来源:HuggingFace (csukuangfj)
|
||
* 总大小:约 230MB(SenseVoice int8 228MB + tokens 308KB + Silero VAD 1.5MB)
|
||
*/
|
||
object ModelManager {
|
||
private const val TAG = "ModelManager"
|
||
|
||
// 模型下载地址(从自有服务器下载,3 个文件约 230MB)
|
||
// 需提前上传到服务器静态目录:/static/voice_models/
|
||
private const val BASE_URL = "https://agent.cyjai.com/static/voice_models"
|
||
private const val SENSEVOICE_MODEL_URL = "$BASE_URL/model.int8.onnx"
|
||
private const val SENSEVOICE_TOKENS_URL = "$BASE_URL/tokens.txt"
|
||
private const val SILERO_VAD_URL = "$BASE_URL/silero_vad.onnx"
|
||
|
||
private var _modelDir: File? = null
|
||
|
||
fun init(context: Context) {
|
||
_modelDir = File(context.filesDir, "voice_models")
|
||
}
|
||
|
||
private fun getModelDir(context: Context): File {
|
||
return _modelDir ?: File(context.filesDir, "voice_models").also { _modelDir = it }
|
||
}
|
||
|
||
// 模型文件预期大小(字节),用于校验下载完整性(±5% 容差)
|
||
private const val EXPECTED_MODEL_SIZE = 239_233_841L // model.int8.onnx ~228MB
|
||
private const val EXPECTED_TOKENS_SIZE = 316_000L // tokens.txt ~308KB
|
||
private const val EXPECTED_VAD_SIZE = 644_000L // silero_vad.onnx ~629KB
|
||
private const val SIZE_TOLERANCE = 0.05 // 5% 容差
|
||
|
||
private fun isSizeValid(file: File, expected: Long): Boolean {
|
||
if (!file.exists()) return false
|
||
val size = file.length()
|
||
val lower = (expected * (1.0 - SIZE_TOLERANCE)).toLong()
|
||
val upper = (expected * (1.0 + SIZE_TOLERANCE)).toLong()
|
||
return size in lower..upper
|
||
}
|
||
|
||
/**
|
||
* 检查模型是否已下载完毕且文件完整
|
||
*/
|
||
fun isModelReady(context: Context): Boolean {
|
||
val dir = getModelDir(context)
|
||
val senseVoiceModel = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/model.int8.onnx")
|
||
val senseVoiceTokens = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/tokens.txt")
|
||
val sileroVad = File(dir, "silero_vad.onnx")
|
||
return isSizeValid(senseVoiceModel, EXPECTED_MODEL_SIZE)
|
||
&& isSizeValid(senseVoiceTokens, EXPECTED_TOKENS_SIZE)
|
||
&& isSizeValid(sileroVad, EXPECTED_VAD_SIZE)
|
||
}
|
||
|
||
/**
|
||
* 删除所有已下载的模型文件
|
||
*/
|
||
fun deleteModels(context: Context): Boolean {
|
||
val dir = getModelDir(context)
|
||
return try {
|
||
dir.deleteRecursively()
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "删除模型文件失败", e)
|
||
false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载所有模型(应在后台线程调用)
|
||
* @param onProgress 进度回调 (总百分比: Int, 阶段描述: String)
|
||
* @return 是否全部成功
|
||
*/
|
||
suspend fun downloadModels(
|
||
context: Context,
|
||
onProgress: (Int, String) -> Unit = { _, _ -> }
|
||
): Boolean = withContext(Dispatchers.IO) {
|
||
val dir = getModelDir(context)
|
||
val senseVoiceDir = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17")
|
||
senseVoiceDir.mkdirs()
|
||
|
||
try {
|
||
// 1. Silero VAD (~1.5MB)
|
||
val sileroFile = File(dir, "silero_vad.onnx")
|
||
if (!sileroFile.exists()) {
|
||
onProgress(0, "下载 VAD 模型...")
|
||
downloadFile(SILERO_VAD_URL, sileroFile) { pct ->
|
||
onProgress(pct * 5 / 100, "下载 VAD 模型... ${pct}%")
|
||
}
|
||
}
|
||
|
||
// 2. tokens.txt (~308KB)
|
||
val tokensFile = File(senseVoiceDir, "tokens.txt")
|
||
if (!tokensFile.exists()) {
|
||
onProgress(5, "下载 tokens...")
|
||
downloadFile(SENSEVOICE_TOKENS_URL, tokensFile) { pct ->
|
||
onProgress(5 + pct * 2 / 100, "下载 tokens... ${pct}%")
|
||
}
|
||
}
|
||
|
||
// 3. SenseVoice int8 (~228MB)
|
||
val modelFile = File(senseVoiceDir, "model.int8.onnx")
|
||
if (!modelFile.exists()) {
|
||
onProgress(7, "下载 SenseVoice 模型(约 228MB)...")
|
||
downloadFile(SENSEVOICE_MODEL_URL, modelFile) { pct ->
|
||
onProgress(7 + pct * 93 / 100, "下载 SenseVoice 模型... ${pct}%")
|
||
}
|
||
}
|
||
|
||
onProgress(100, "模型就绪")
|
||
Log.i(TAG, "模型下载完成")
|
||
true
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "模型下载失败", e)
|
||
false
|
||
}
|
||
}
|
||
|
||
private fun downloadFile(urlStr: String, dest: File, onProgress: (Int) -> Unit) {
|
||
var attempt = 0
|
||
val maxAttempts = 3
|
||
|
||
while (attempt < maxAttempts) {
|
||
try {
|
||
attempt++
|
||
doDownload(urlStr, dest, onProgress)
|
||
return
|
||
} catch (e: Exception) {
|
||
Log.w(TAG, "下载失败 (第${attempt}次): ${dest.name}", e)
|
||
if (attempt >= maxAttempts) throw e
|
||
Thread.sleep(2000) // 重试前等待
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun doDownload(urlStr: String, dest: File, onProgress: (Int) -> Unit) {
|
||
val url = URL(urlStr)
|
||
val conn = url.openConnection() as HttpURLConnection
|
||
conn.requestMethod = "GET"
|
||
conn.connectTimeout = 15000
|
||
conn.readTimeout = 120000
|
||
|
||
// 断点续传
|
||
var downloaded = if (dest.exists()) dest.length() else 0L
|
||
if (downloaded > 0) {
|
||
conn.setRequestProperty("Range", "bytes=$downloaded-")
|
||
}
|
||
|
||
conn.connect()
|
||
|
||
val totalSize = if (downloaded > 0) {
|
||
conn.getHeaderField("Content-Range")?.substringAfter("/")?.toLongOrNull()
|
||
?: (conn.contentLengthLong + downloaded)
|
||
} else {
|
||
conn.contentLengthLong
|
||
}
|
||
|
||
val inputStream = conn.inputStream
|
||
val outputStream = FileOutputStream(dest, downloaded > 0)
|
||
val buffer = ByteArray(65536)
|
||
var bytesRead: Int
|
||
var totalRead = downloaded
|
||
var lastProgressReport = System.currentTimeMillis()
|
||
|
||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||
outputStream.write(buffer, 0, bytesRead)
|
||
totalRead += bytesRead
|
||
|
||
// 限流进度回调(每 200ms 最多报一次)
|
||
val now = System.currentTimeMillis()
|
||
if (totalSize > 0 && now - lastProgressReport > 200) {
|
||
val pct = (totalRead * 100 / totalSize).toInt()
|
||
onProgress(pct.coerceIn(0, 100))
|
||
lastProgressReport = now
|
||
}
|
||
}
|
||
|
||
if (totalSize > 0 && totalRead < totalSize) {
|
||
throw IOException("下载不完整: 期望 ${totalSize} 字节,实际 ${totalRead} 字节")
|
||
}
|
||
|
||
inputStream.close()
|
||
outputStream.close()
|
||
conn.disconnect()
|
||
}
|
||
}
|