agent-Specialization/android-webview-app/app/src/main/java/com/cyjai/agent/ModelManager.kt
JOJO d24e00b9b3 feat(voice): Android 端侧离线语音识别集成
基于 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
2026-06-10 00:50:16 +08:00

195 lines
7.0 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
* 总大小:约 230MBSenseVoice 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()
}
}