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
This commit is contained in:
JOJO 2026-06-10 00:50:16 +08:00
parent 62e2c08f5a
commit d24e00b9b3
13 changed files with 1625 additions and 11 deletions

View File

@ -1,5 +1,50 @@
# App 更新说明
# 1.0.32
- 识别引擎改为预初始化模型下载完成后立即在后台加载用户点击时直接可用不再每次点击等3-5秒初始化
- startRecordingInternal 增加详细日志,便于排查录音问题
# 1.0.31
- 修复语音识别无结果:去掉多余的 decode() 调用,对齐 sherpa-onnx Android 官方 APIgetResult 内部已含 decode
- recognizeSegment 增加识别结果详细日志
# 1.0.30
- 修复语音识别无声问题:前端处理 initializing 状态,防止初始化期间重复触发 startListening 导致录音永不停止
- Android 端增加启动取消机制,初始化期间按停止不会意外开始录音
- 完善录音链路日志,便于后续调试
# 1.0.29
- 暂时禁用 VADAndroid 端 Silero VAD 存在 Native 兼容性问题),改为整段识别模式
- 录音结束后一次性识别,效果无差异,彻底消除闪退
# 1.0.28
- 修复 VAD 初始化导致闪退maxSpeechDuration 30f→5.0f默认值VAD 失败时降级为整段识别不崩溃
# 1.0.27
- 语音调试日志改为直接上传服务器(/api/voice_debug无需手动找文件
# 1.0.26
- 修复点击麦克风后闪退问题initEngine 增加文件完整性前置校验numThreads 降为 1 降低内存压力
- 新增「保存日志」按钮,调试日志写入手机 Download/voice_debug.log
- 下载/删除前自动释放引擎资源,避免文件锁定导致异常
# 1.0.25
- 修复下载进度一直显示 0% 的问题(整数除法 bug
- 新增模型文件完整性校验(文件大小 ±5% 容差),避免下载中断后显示“已下载”
- 新增「删除模型」按钮,可清理不完整的模型文件
- 模型文件移至 agents 代码目录外(/opt/agent/voice_models/),避免部署时被覆盖
# 1.0.24
- 修复语音按钮在首次启动模型未下载时不显示的问题VoiceBridge 改为随 App 启动即注册,不再等待模型下载完成
- 修复个人空间中「下载语音模型」按钮误提示「仅在 App 内支持」的问题
# 1.0.23
- 新增端侧语音输入功能:集成 sherpa-onnx SenseVoice int8 离线语音识别
- 支持中英混说 + 自动标点,完全离线运行,无需网络
- 语音模型约 228MB首次使用自动下载可在「个人空间 → 语音模型」手动下载)
- 输入框语音按钮仅在 App 端显示,桌面端隐藏
- 新增录音权限请求RECORD_AUDIO
# 1.0.22
- 修复 Android App 内从本地发送图片/视频无效的问题:补充 Android 13+ 所需的媒体读取权限(`READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO`
- 修复 Android App 文件选择器在 Android 13+ 上回调丢失的问题:将已废弃的 `startActivityForResult` 升级为 `registerForActivityResult`Activity Result API

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.cyjai.agent"
minSdk = 24
targetSdk = 35
versionCode = 24
versionName = "1.0.22"
versionCode = 36
versionName = "1.0.34"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -63,4 +63,11 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.activity:activity-ktx:1.9.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// sherpa-onnx 语音识别库(需手动下载 AAR 放到 app/libs/
// 下载地址https://huggingface.co/csukuangfj/sherpa-onnx-libs/tree/main/android/aar
// 下载最新 sherpa-onnx-*.aar 放到 app/libs/ 目录即可
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
}

Binary file not shown.

View File

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

View File

@ -1,11 +1,14 @@
package com.cyjai.agent
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import android.view.View
import android.webkit.CookieManager
@ -21,15 +24,29 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var filePathCallback: ValueCallback<Array<Uri>>? = null
private var voiceBridge: VoiceBridge? = null
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_RECORD_AUDIO = 300
private const val HOME_URL = "https://agent.cyjai.com"
private const val WEB_ASSET_VERSION = "20260406_4"
}
private val fileChooserLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
@ -84,6 +101,51 @@ class MainActivity : AppCompatActivity() {
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
voiceBridge = VoiceBridge(this@MainActivity, webView)
webView.addJavascriptInterface(voiceBridge!!, "AndroidVoiceBridge")
// 后台检查并下载模型
lifecycleScope.launch(Dispatchers.IO) {
ModelManager.init(this@MainActivity)
if (!ModelManager.isModelReady(this@MainActivity)) {
Log.i(TAG, "首次启动,开始下载语音模型...")
withContext(Dispatchers.Main) {
injectVoiceStatus("downloading")
}
val success = ModelManager.downloadModels(this@MainActivity) { pct, msg ->
Log.i(TAG, "模型下载: ${pct}% - $msg")
runOnUiThread {
injectVoiceDownloadProgress(pct, msg)
}
}
if (!success) {
Log.e(TAG, "模型下载失败")
withContext(Dispatchers.Main) {
injectVoiceStatus("error")
}
return@launch
}
}
Log.i(TAG, "模型就绪")
withContext(Dispatchers.Main) {
injectVoiceStatus("ready")
// 模型就绪后立即预初始化识别引擎,用户点击时直接可用
voiceBridge?.ensureEngine()
}
}
// 请求录音权限
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
REQUEST_RECORD_AUDIO
)
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
@ -152,6 +214,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onDestroy() {
voiceBridge?.destroy()
webView.destroy()
super.onDestroy()
}
@ -278,6 +341,32 @@ class MainActivity : AppCompatActivity() {
webView.evaluateJavascript(script, null)
}
// ═══════════════════ 语音状态通知JS 注入) ═══════════════════
private fun injectVoiceStatus(status: String) {
val script = """
(function() {
if (window.__onVoiceStatus) window.__onVoiceStatus('$status');
if (window.dispatchEvent) {
window.dispatchEvent(new CustomEvent('voicebridge:status', { detail: '$status' }));
}
})();
""".trimIndent()
webView.evaluateJavascript(script, null)
}
private fun injectVoiceDownloadProgress(pct: Int, msg: String) {
val safeMsg = msg.replace("'", "\\'")
val script = """
(function() {
if (window.__onVoiceDownloadProgress) {
window.__onVoiceDownloadProgress($pct, '$safeMsg');
}
})();
""".trimIndent()
webView.evaluateJavascript(script, null)
}
inner class ThemeBridge {
@JavascriptInterface
fun onThemeChanged(theme: String?) {
@ -297,11 +386,6 @@ class MainActivity : AppCompatActivity() {
}
}
companion object {
private const val HOME_URL = "https://agent.cyjai.com"
private const val WEB_ASSET_VERSION = "20260406_4"
}
private fun buildHomeUrl(): String {
val vc = getInstalledVersionCode()
val vn = Uri.encode(getInstalledVersionName())

View File

@ -0,0 +1,194 @@
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()
}
}

View File

@ -0,0 +1,501 @@
package com.cyjai.agent
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.core.app.ActivityCompat
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
import com.k2fsa.sherpa.onnx.OfflineSenseVoiceModelConfig
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.FeatureConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.SpeechSegment
import android.os.Environment
import kotlinx.coroutines.*
import java.io.File
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
/**
* 语音识别桥接 通过 JS Bridge 暴露给 WebView
*
* 前端调用
* window.AndroidVoiceBridge.startListening() // 开始录音
* window.AndroidVoiceBridge.stopListening() // 停止录音
* window.AndroidVoiceBridge.isSupported() // 是否支持 → true
*
* 结果通过全局回调传回前端
* window.__onVoiceResult(text) // 识别结果
* window.__onVoiceStatus(status) // 状态变化: "idle"|"listening"|"processing"
* window.__onVoiceError(error) // 错误
*/
class VoiceBridge(
private val context: Context,
private val webView: WebView
) {
companion object {
private const val TAG = "VoiceBridge"
private const val SAMPLE_RATE = 16000
private const val CHUNK_SIZE = 512 // 每次读取的采样数
}
// ── 状态 ──
private val isRecording = AtomicBoolean(false)
private var audioRecord: AudioRecord? = null
private var recordingJob: Job? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var recordingStartTime = 0L // 录音开始时间戳(用于防抖)
private val MIN_RECORDING_MS = 400L // 最小录音时长(防抖)
// ── sherpa-onnx 引擎 ──
private var recognizer: OfflineRecognizer? = null
private var vad: Vad? = null
private var initialized = false
private var engineInitJob: Job? = null // 预初始化任务
// ── 调试日志 ──
private val logFile: File
get() = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "voice_debug.log")
private val dateFmt = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.getDefault())
private fun logToFile(msg: String) {
try {
val ts = dateFmt.format(Date())
logFile.parentFile?.mkdirs()
FileWriter(logFile, true).use { it.write("[$ts] $msg\n") }
} catch (_: Exception) {}
}
// ── 模型路径 ──
private val modelDir: File
get() = File(context.filesDir, "voice_models")
private val senseVoiceModel: File
get() = File(modelDir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/model.int8.onnx")
private val senseVoiceTokens: File
get() = File(modelDir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/tokens.txt")
private val sileroVadModel: File
get() = File(modelDir, "silero_vad.onnx")
// ═══════════════════ JS Bridge 接口 ═══════════════════
@JavascriptInterface
fun isSupported(): Boolean = true
/** 模型是否已就绪(含文件大小校验) */
@JavascriptInterface
fun isModelReady(): Boolean {
return ModelManager.isModelReady(context)
}
/** 是否有模型文件残留(存在但不完整,需清理) */
@JavascriptInterface
fun isModelPartial(): Boolean {
val filesExist = senseVoiceModel.exists() || senseVoiceTokens.exists() || sileroVadModel.exists()
return filesExist && !ModelManager.isModelReady(context)
}
/** 删除已下载的模型文件 */
@JavascriptInterface
fun deleteModel(): Boolean {
releaseEngine()
return ModelManager.deleteModels(context)
}
/** 触发模型下载(供个人空间手动下载),下载前先释放引擎并清理旧文件 */
@JavascriptInterface
fun downloadModel() {
scope.launch {
try {
// 先释放引擎(避免文件被 onnxruntime 锁定导致删除失败)
releaseEngine()
ModelManager.deleteModels(context)
postToJs("window.__onVoiceDownloadProgress(0, '开始下载...')")
val success = ModelManager.downloadModels(context) { pct, msg ->
postToJs("window.__onVoiceDownloadProgress($pct, '${escapeJs(msg)}')")
}
if (success) {
postToJs("window.__onVoiceDownloadProgress(100, '下载完成')")
// 下载完成后立即预初始化引擎,这样用户点击时直接可用
ensureEngine()
} else {
postToJs("window.__onVoiceError('模型下载失败,请检查网络后重试')")
}
} catch (e: Exception) {
Log.e(TAG, "下载流程异常", e)
postToJs("window.__onVoiceError('${escapeJs(e.message ?: "未知错误")}')")
}
}
}
/** 释放识别引擎资源 */
private fun releaseEngine() {
initialized = false
engineInitJob?.cancel()
engineInitJob = null
try { recognizer?.release() } catch (_: Exception) {}
recognizer = null
try { vad?.release() } catch (_: Exception) {}
vad = null
}
/** 预初始化引擎(后台,不阻塞调用者)。下载完成后或 App 启动时调用 */
fun ensureEngine() {
if (initialized) return
if (!isModelReady()) {
logToFile("ensureEngine: 模型未就绪,跳过")
return
}
if (engineInitJob?.isActive == true) {
logToFile("ensureEngine: 已有初始化任务运行中")
return
}
logToFile("ensureEngine: 开始后台预初始化引擎")
engineInitJob = scope.launch(Dispatchers.IO) {
val ok = initEngine()
logToFile("ensureEngine: initEngine 返回 $ok")
if (ok) {
withContext(Dispatchers.Main) {
postToJs("window.__onVoiceModelReady()")
}
}
}
}
/** 获取模型大小(用于 UI 展示) */
@JavascriptInterface
fun getModelSizeMB(): Double {
return 228.0
}
/** 收集调试日志并上传到服务器 */
@JavascriptInterface
fun debugLog(msg: String) {
logToFile("[JS] $msg")
}
@JavascriptInterface
fun collectDebugLog(): String {
val sb = StringBuilder()
sb.appendLine("=== 语音调试日志 ===")
sb.appendLine("时间: ${dateFmt.format(Date())}")
sb.appendLine("模型目录: ${modelDir.absolutePath}")
sb.appendLine("senseVoice model: 存在=${senseVoiceModel.exists()}, 大小=${if (senseVoiceModel.exists()) senseVoiceModel.length() else -1}")
sb.appendLine("senseVoice tokens: 存在=${senseVoiceTokens.exists()}, 大小=${if (senseVoiceTokens.exists()) senseVoiceTokens.length() else -1}")
sb.appendLine("sileroVad: 存在=${sileroVadModel.exists()}, 大小=${if (sileroVadModel.exists()) sileroVadModel.length() else -1}")
sb.appendLine("isModelReady: ${ModelManager.isModelReady(context)}")
sb.appendLine("引擎已初始化: $initialized")
sb.appendLine("recognizer: ${recognizer != null}, vad: ${vad != null}")
sb.appendLine("录音权限: ${hasRecordPermission()}")
sb.appendLine("isRecording: ${isRecording.get()}")
sb.appendLine("")
sb.appendLine("=== 文件日志 ===")
if (logFile.exists()) {
try { sb.append(logFile.readText()) } catch (e: Exception) { sb.appendLine("(读取日志失败: ${e.message})") }
} else {
sb.appendLine("(无文件日志)")
}
return sb.toString()
}
@JavascriptInterface
fun startListening() {
logToFile("startListening 被调用, initialized=$initialized")
if (!hasRecordPermission()) {
logToFile("startListening: 缺少录音权限")
postToJs("window.__onVoiceError('缺少录音权限')")
Log.e(TAG, "缺少录音权限")
return
}
// 检查模型是否就绪
if (!isModelReady()) {
logToFile("startListening: 模型未就绪")
postToJs("window.__onVoiceStatus('model_not_ready')")
Log.w(TAG, "模型未就绪")
return
}
if (isRecording.get()) {
logToFile("startListening: 已在录音中,忽略")
Log.w(TAG, "已经在录音中")
return
}
if (!initialized) {
// 触发后台初始化并等待,完成后自动开始录音
logToFile("startListening: 引擎未初始化,触发后台初始化并等待")
postToJs("window.__onVoiceStatus('initializing')")
pendingStartJob?.cancel()
pendingStartJob = scope.launch(Dispatchers.IO) {
val ok = initEngine()
logToFile("startListening: initEngine 返回 $ok")
withContext(Dispatchers.Main) {
if (pendingStartJob == null || !pendingStartJob!!.isActive) {
logToFile("startListening: 启动请求在初始化期间被取消")
return@withContext
}
pendingStartJob = null
if (!ok) {
postToJs("window.__onVoiceError('模型初始化失败')")
return@withContext
}
startRecordingInternal()
}
}
} else {
startRecordingInternal()
}
}
@JavascriptInterface
fun stopListening() {
logToFile("stopListening 被调用, isRecording=${isRecording.get()}")
// 取消待处理的启动(初始化期间的取消)
pendingStartJob?.cancel()
pendingStartJob = null
// 如果还没开始录音(初始化中),直接通知前端停止
if (!isRecording.get()) {
logToFile("stopListening: 录音尚未开始,取消启动请求")
postToJs("window.__onVoiceStatus('idle')")
return
}
// 防抖:录音开始后 MIN_RECORDING_MS 内不允许停止
val elapsed = System.currentTimeMillis() - recordingStartTime
if (elapsed < MIN_RECORDING_MS) {
logToFile("stopListening: 录音时长不足 ${MIN_RECORDING_MS}ms忽略 (已录 ${elapsed}ms)")
Log.w(TAG, "录音时长不足 ${MIN_RECORDING_MS}ms忽略停止请求 (已录 ${elapsed}ms)")
return
}
stopRecordingInternal()
}
// ═══════════════════ 引擎初始化 ═══════════════════
private suspend fun initEngine(): Boolean = withContext(Dispatchers.IO) {
try {
// 先做文件大小校验,避免用残缺文件初始化导致 Native crash
if (!ModelManager.isModelReady(context)) {
logToFile("initEngine: 模型文件不完整,拒绝初始化")
Log.e(TAG, "模型文件不完整,拒绝初始化")
return@withContext false
}
logToFile("initEngine: 模型文件完整,开始加载 SenseVoice...")
Log.i(TAG, "初始化 SenseVoice 识别器... model=${senseVoiceModel.length()} tokens=${senseVoiceTokens.length()}")
// SenseVoice 模型配置
val senseVoiceConfig = OfflineSenseVoiceModelConfig(
model = senseVoiceModel.absolutePath,
useInverseTextNormalization = true
)
val featConfig = FeatureConfig(
sampleRate = SAMPLE_RATE,
featureDim = 80
)
val modelConfig = OfflineModelConfig()
modelConfig.senseVoice = senseVoiceConfig
modelConfig.tokens = senseVoiceTokens.absolutePath
modelConfig.numThreads = 1 // 单线程降低内存压力
modelConfig.provider = "cpu"
val config = OfflineRecognizerConfig(
featConfig = featConfig,
modelConfig = modelConfig
)
Log.i(TAG, "开始创建 OfflineRecognizer...")
logToFile("initEngine: 开始创建 OfflineRecognizer (numThreads=1)...")
recognizer = OfflineRecognizer(
assetManager = null,
config = config
)
logToFile("initEngine: OfflineRecognizer 创建完成")
Log.i(TAG, "SenseVoice 识别器初始化完成")
// VAD 暂时禁用Android 端 Silero VAD 存在兼容性问题,改用整段识别)
logToFile("initEngine: 跳过 VAD使用整段识别模式")
vad = null
initialized = true
logToFile("initEngine: 全部初始化完成")
true
} catch (e: Exception) {
logToFile("initEngine: 异常 ${e.javaClass.simpleName}: ${e.message}")
false
}
}
// ═══════════════════ 录音 ═══════════════════
// ── 待处理的启动任务(用于在初始化期间取消)──
private var pendingStartJob: Job? = null
// ── 录音数据缓冲(无 VAD 模式,整段识别)──
private val audioBuffer = mutableListOf<Short>()
private fun startRecordingInternal() {
logToFile("startRecordingInternal: 开始, isRecording=${isRecording.get()}")
if (isRecording.getAndSet(true)) {
logToFile("startRecordingInternal: 已在录音中,跳过")
return
}
val bufferSize = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
logToFile("startRecordingInternal: bufferSize=$bufferSize")
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize * 2
)
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
logToFile("startRecordingInternal: AudioRecord 初始化失败 state=${audioRecord?.state}")
Log.e(TAG, "AudioRecord 初始化失败")
isRecording.set(false)
postToJs("window.__onVoiceError('麦克风初始化失败')")
return
}
audioRecord?.startRecording()
recordingStartTime = System.currentTimeMillis()
audioBuffer.clear()
postToJs("window.__onVoiceStatus('listening')")
logToFile("startRecordingInternal: 录音已开始")
Log.i(TAG, "开始录音")
recordingJob = scope.launch {
processAudioLoop()
}
}
private suspend fun processAudioLoop() {
val buffer = ShortArray(CHUNK_SIZE)
while (isRecording.get()) {
val readCount = audioRecord?.read(buffer, 0, buffer.size) ?: -1
if (readCount <= 0) continue
for (i in 0 until readCount) {
audioBuffer.add(buffer[i])
}
}
}
private fun recognizeFullAudio() {
logToFile("recognizeFullAudio: buffer size=${audioBuffer.size}")
if (audioBuffer.isEmpty()) {
logToFile("recognizeFullAudio: buffer 为空")
postToJs("window.__onVoiceError('未检测到语音')")
return
}
scope.launch {
postToJs("window.__onVoiceStatus('processing')")
val samples = FloatArray(audioBuffer.size) { audioBuffer[it] / 32768.0f }
logToFile("recognizeFullAudio: 开始识别 ${samples.size} 采样 (${samples.size / SAMPLE_RATE}s)")
val text = withContext(Dispatchers.IO) { recognizeSegment(samples) }
logToFile("recognizeFullAudio: 识别结果 text='$text' length=${text.length}")
if (text.isNotBlank()) {
Log.i(TAG, "识别结果: $text")
logToFile("recognizeFullAudio: 准备调用 postToJs __onVoiceResult")
postToJs("window.__onVoiceResult('${escapeJs(text)}')")
logToFile("recognizeFullAudio: postToJs __onVoiceResult 已提交")
} else {
logToFile("recognizeFullAudio: 识别结果为空")
postToJs("window.__onVoiceError('未识别到语音内容')")
}
postToJs("window.__onVoiceStatus('idle')")
}
}
private fun recognizeSegment(samples: FloatArray): String {
val rec = recognizer ?: return ""
return try {
val stream = rec.createStream()
stream.acceptWaveform(samples, SAMPLE_RATE)
rec.decode(stream)
val result = rec.getResult(stream)
stream.release()
val text = result.text?.trim() ?: ""
logToFile("recognizeSegment: text='$text' lang='${result.lang}' emotion='${result.emotion}' event='${result.event}'")
text
} catch (e: Exception) {
logToFile("recognizeSegment: 异常 ${e.javaClass.simpleName}: ${e.message}")
Log.e(TAG, "识别错误", e)
""
}
}
private fun stopRecordingInternal() {
logToFile("stopRecordingInternal 被调用, isRecording=${isRecording.get()}")
if (!isRecording.getAndSet(false)) {
logToFile("stopRecordingInternal: isRecording 已为 false跳过")
return
}
recordingJob?.cancel()
recordingJob = null
try {
audioRecord?.stop()
audioRecord?.release()
} catch (e: Exception) {
Log.w(TAG, "AudioRecord 释放异常", e)
logToFile("stopRecordingInternal: AudioRecord 释放异常 ${e.message}")
}
audioRecord = null
logToFile("stopRecordingInternal: 录音已停止,共 ${audioBuffer.size} 采样")
Log.i(TAG, "录音已停止,共 ${audioBuffer.size} 采样")
recognizeFullAudio()
}
// ═══════════════════ 工具方法 ═══════════════════
private fun hasRecordPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
}
private fun postToJs(script: String) {
webView.post {
webView.evaluateJavascript(script, null)
}
}
private fun escapeJs(text: String): String {
return text
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
}
fun destroy() {
stopRecordingInternal()
scope.cancel()
releaseEngine()
}
}

467
demo/sense_voice_demo.py Normal file
View File

@ -0,0 +1,467 @@
#!/usr/bin/env python3
"""
SenseVoice int8 语音识别 Demo
================================
功能麦克风录音 Silero VAD 语音分段 SenseVoice 识别带标点 实时显示
同时监控并输出 CPU / 内存占用
用法
python3 demo/sense_voice_demo.py
首次运行会自动下载模型 228MB请耐心等待
Ctrl+C 退出时会输出性能统计
"""
import sys
import os
import time
import argparse
import threading
import queue
import signal
from pathlib import Path
import numpy as np
# ── ANSI 颜色 ─────────────────────────────────────────────────
C = {
"reset": "\033[0m",
"bold": "\033[1m",
"dim": "\033[2m",
"green": "\033[32m",
"cyan": "\033[36m",
"yellow": "\033[33m",
"red": "\033[31m",
"magenta":"\033[35m",
"blue": "\033[94m",
}
def color(s, c):
return f"{c}{s}{C['reset']}"
# ── 模型下载 ─────────────────────────────────────────────────
MODEL_DIR = Path(__file__).parent / "models"
SENSEVOICE_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2"
)
SILERO_VAD_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"silero_vad.onnx"
)
SENSEVOICE_DIR = MODEL_DIR / "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17"
SENSEVOICE_MODEL = SENSEVOICE_DIR / "model.int8.onnx"
SENSEVOICE_TOKENS = SENSEVOICE_DIR / "tokens.txt"
SILERO_VAD_MODEL = MODEL_DIR / "silero_vad.onnx"
def download_with_progress(url, dest):
"""下载文件并显示进度条"""
import urllib.request
import shutil
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"\n 📥 下载 {Path(dest).name} ...")
print(f" {url}")
try:
with urllib.request.urlopen(url) as resp:
total = int(resp.headers.get("Content-Length", 0))
downloaded = 0
block_size = 8192
with open(dest, "wb") as f:
while True:
chunk = resp.read(block_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total > 0:
pct = downloaded / total * 100
bar_len = 30
filled = int(bar_len * downloaded / total)
bar = "" * filled + "" * (bar_len - filled)
size_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
print(
f"\r [{bar}] {pct:5.1f}% {size_mb:.0f}/{total_mb:.0f}MB",
end="", flush=True,
)
print()
except Exception as e:
if dest.exists():
dest.unlink()
raise e
def ensure_models():
"""确保模型文件存在,不存在则下载"""
import tarfile
MODEL_DIR.mkdir(parents=True, exist_ok=True)
# SenseVoice
if not SENSEVOICE_MODEL.exists():
archive = MODEL_DIR / "sense_voice_int8.tar.bz2"
if not archive.exists():
download_with_progress(SENSEVOICE_URL, archive)
print(f" 📦 解压模型...")
with tarfile.open(archive, "r:bz2") as tar:
tar.extractall(path=MODEL_DIR)
archive.unlink()
print(f" ✅ SenseVoice 模型就绪 ({SENSEVOICE_MODEL.stat().st_size / 1024 / 1024:.0f}MB)")
# Silero VAD
if not SILERO_VAD_MODEL.exists():
download_with_progress(SILERO_VAD_URL, SILERO_VAD_MODEL)
print(f" ✅ Silero VAD 模型就绪")
print()
# ── 性能监控 ─────────────────────────────────────────────────
class PerfMonitor:
"""后台线程采集 CPU / 内存"""
def __init__(self, interval=1.0):
self.interval = interval
self._stop = threading.Event()
self._thread = None
self._lock = threading.Lock()
# 累计统计
self.cpu_percent_samples = []
self.rss_mb_samples = []
self.last_cpu = 0.0
self.last_rss_mb = 0.0
try:
import psutil
self._psutil = psutil
self._proc = psutil.Process()
self._available = True
except ImportError:
self._available = False
@property
def available(self):
return self._available
def start(self):
if not self._available:
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=2)
def _run(self):
while not self._stop.is_set():
try:
cpu = self._proc.cpu_percent(interval=None)
rss_mb = self._proc.memory_info().rss / 1024 / 1024
except Exception:
cpu, rss_mb = 0, 0
with self._lock:
self.last_cpu = cpu
self.last_rss_mb = rss_mb
self.cpu_percent_samples.append(cpu)
self.rss_mb_samples.append(rss_mb)
self._stop.wait(self.interval)
def current(self):
with self._lock:
return self.last_cpu, self.last_rss_mb
def summary(self):
with self._lock:
if not self.cpu_percent_samples:
return "无数据"
cpu_avg = np.mean(self.cpu_percent_samples)
cpu_max = np.max(self.cpu_percent_samples)
rss_avg = np.mean(self.rss_mb_samples)
rss_max = np.max(self.rss_mb_samples)
return {
"cpu_avg": cpu_avg,
"cpu_max": cpu_max,
"rss_avg_mb": rss_avg,
"rss_max_mb": rss_max,
"samples": len(self.cpu_percent_samples),
}
# ── 识别统计 ─────────────────────────────────────────────────
class ASRStats:
"""记录每次识别的性能"""
def __init__(self):
self._lock = threading.Lock()
self.segments = [] # list of (audio_duration, asr_time, text)
def record(self, audio_duration, asr_time, text):
with self._lock:
self.segments.append((audio_duration, asr_time, text))
def summary(self):
with self._lock:
if not self.segments:
return None
total_audio = sum(s[0] for s in self.segments)
total_asr = sum(s[1] for s in self.segments)
total_text = sum(len(s[2]) for s in self.segments)
rtf = total_asr / total_audio if total_audio > 0 else 0
return {
"segments": len(self.segments),
"total_audio_s": total_audio,
"total_asr_s": total_asr,
"total_chars": total_text,
"rtf": rtf,
"speedup": 1 / rtf if rtf > 0 else float("inf"),
}
# ── 主程序 ────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="SenseVoice int8 语音识别 Demo")
parser.add_argument("--device", type=int, default=None,
help="音频输入设备编号(不指定则用默认)")
parser.add_argument("--list-devices", action="store_true",
help="列出所有音频设备后退出")
parser.add_argument("--save-audio", type=str, default=None,
help="保存最后一次识别的音频到指定文件(用于调试)")
parser.add_argument("--num-threads", type=int, default=2,
help="ONNX 推理线程数(默认 2")
parser.add_argument("--vad-threshold", type=float, default=0.5,
help="VAD 灵敏度 0~1默认 0.5,越小越敏感)")
parser.add_argument("--silence-duration", type=float, default=0.8,
help="判定为句尾的静音时长秒数(默认 0.8")
parser.add_argument("--max-speech", type=float, default=30,
help="单段最长语音秒数(默认 30")
args = parser.parse_args()
# ── 列出设备 ──
import sounddevice as sd
if args.list_devices:
print("\n🎤 可用音频设备:\n")
devices = sd.query_devices()
for i, d in enumerate(devices):
in_ch = d["max_input_channels"]
if in_ch > 0:
print(f" [{i}] {d['name']} (输入通道: {in_ch}, 默认采样率: {d['default_samplerate']:.0f})")
print()
return
# ── 下载模型 ──
print(color("\n🔧 检查模型文件...", C["cyan"]))
ensure_models()
# ── 初始化 sherpa-onnx ──
print(color("🚀 初始化识别引擎...", C["cyan"]))
import sherpa_onnx
# 通过工厂方法创建 SenseVoice 识别器
recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
model=str(SENSEVOICE_MODEL),
tokens=str(SENSEVOICE_TOKENS),
num_threads=args.num_threads,
provider="cpu",
language="auto",
use_itn=True, # 启用标点 + 逆文本正则化
)
# Silero VAD 配置
silero_vad_config = sherpa_onnx.SileroVadModelConfig(
model=str(SILERO_VAD_MODEL),
threshold=args.vad_threshold,
min_silence_duration=args.silence_duration,
min_speech_duration=0.25,
max_speech_duration=args.max_speech,
)
vad_config = sherpa_onnx.VadModelConfig(
silero_vad=silero_vad_config,
sample_rate=16000,
num_threads=1,
)
vad = sherpa_onnx.VoiceActivityDetector(vad_config, buffer_size_in_seconds=60)
print(color("✅ 引擎就绪!", C["green"]))
# ── 启动性能监控 ──
perf = PerfMonitor(interval=1.0)
perf.start()
asr_stats = ASRStats()
# ── 音频参数 ──
SAMPLE_RATE = 16000
BLOCK_SIZE = 1024 # 每次读取的采样数
# ── 优雅退出 ──
running = True
def on_sigint(sig, frame):
nonlocal running
running = False
print(f"\n{color('⏹ 正在退出...', C['yellow'])}")
signal.signal(signal.SIGINT, on_sigint)
# ── 打印标题 ──
print()
print(color("╔════════════════════════════════════════════════╗", C["bold"]))
print(color("║ 🎙️ SenseVoice 语音识别 Demo ║", C["bold"]))
print(color("║ 中英混说 · 自带标点 · 低资源运行 ║", C["bold"]))
print(color("╚════════════════════════════════════════════════╝", C["bold"]))
print()
print(f" {color('🎤', C['cyan'])} 对着麦克风说话,说完停顿即可看到结果")
print(f" {color('', C['cyan'])}{color('Ctrl+C', C['yellow'])} 退出并查看统计")
print(f" {color('📊', C['cyan'])} VAD 阈值: {args.vad_threshold} | 静音判定: {args.silence_duration}s")
print()
# 状态指示
STATUS_IDLE = f" {color('', C['dim'])} 等待语音..."
STATUS_LISTENING = f" {color('🔴', C['red'])} 正在听..."
STATUS_PROCESSING = f" {color('⚙️', C['yellow'])} 识别中..."
sys.stdout.write(STATUS_IDLE)
sys.stdout.flush()
def audio_callback(indata, frames, time_info, status):
"""麦克风回调:将音频送入 VAD"""
if status:
print(f"\n ⚠️ 音频状态: {status}")
# 转为 float32 并归一化到 [-1, 1]
if indata.dtype == np.int16:
samples = indata[:, 0].astype(np.float32) / 32768.0
else:
samples = indata[:, 0].astype(np.float32)
vad.accept_waveform(samples)
try:
with sd.InputStream(
samplerate=SAMPLE_RATE,
device=args.device,
channels=1,
dtype="float32",
blocksize=BLOCK_SIZE,
callback=audio_callback,
):
last_status = STATUS_IDLE
segment_count = 0
while running:
time.sleep(0.05) # 50ms 轮询
# 检测是否有语音段结束
while not vad.empty():
speech_segment = vad.front # sherpa_onnx.SpeechSegment
samples = np.array(speech_segment.samples, dtype=np.float32)
audio_duration = len(samples) / SAMPLE_RATE
vad.pop()
# ── 识别 ──
sys.stdout.write(f"\r{STATUS_PROCESSING} ({audio_duration:.1f}s 音频)")
sys.stdout.flush()
t0 = time.perf_counter()
stream = recognizer.create_stream()
stream.accept_waveform(SAMPLE_RATE, samples)
recognizer.decode_stream(stream)
text = stream.result.text.strip()
t1 = time.perf_counter()
asr_time = t1 - t0
asr_stats.record(audio_duration, asr_time, text)
# ── 显示结果 ──
segment_count += 1
rtf = asr_time / audio_duration if audio_duration > 0 else 0
speed = 1 / rtf if rtf > 0 else float("inf")
# 清除状态行
sys.stdout.write("\r" + " " * 60 + "\r")
# 输出识别文本
prefix = color(f"[{segment_count}]", C["dim"])
speed_info = color(
f"({asr_time:.2f}s / {audio_duration:.1f}s, RTF={rtf:.3f}, {speed:.0f}x)",
C["dim"],
)
print(f" {prefix} {color(text, C['green'])}")
print(f" {speed_info}")
# 保存音频(调试用)
if args.save_audio and segment_count == 1:
import sherpa_onnx
sherpa_onnx.write_wave(
str(args.save_audio),
samples.tolist(),
SAMPLE_RATE,
)
print(f" 💾 音频已保存到 {args.save_audio}")
# 恢复状态
sys.stdout.write(STATUS_IDLE)
sys.stdout.flush()
# 更新状态行
current_status = STATUS_LISTENING if vad.is_speech_detected() else STATUS_IDLE
if current_status != last_status:
sys.stdout.write(f"\r{current_status}")
sys.stdout.flush()
last_status = current_status
except KeyboardInterrupt:
pass
finally:
running = False
perf.stop()
# ── 输出统计 ──
print("\n")
print(color("╔════════════════════════════════════════════════╗", C["bold"]))
print(color("║ 📊 性能统计 ║", C["bold"]))
print(color("╚════════════════════════════════════════════════╝", C["bold"]))
print()
# ASR 统计
s = asr_stats.summary()
if s:
print(color(" 🎙️ 识别统计", C["cyan"]))
print(f" 识别段数: {s['segments']}")
print(f" 总音频时长: {s['total_audio_s']:.1f}s")
print(f" 总识别耗时: {s['total_asr_s']:.2f}s")
print(f" 总字符数: {s['total_chars']}")
print(f" RTF: {s['rtf']:.4f}")
print(f" 处理速度: {s['speedup']:.1f}x 实时")
print()
# 系统资源统计
ps = perf.summary()
if isinstance(ps, dict):
print(color(" 💻 系统资源", C["cyan"]))
print(f" CPU 平均: {ps['cpu_avg']:.1f}%")
print(f" CPU 峰值: {ps['cpu_max']:.1f}%")
print(f" 内存平均: {ps['rss_avg_mb']:.0f}MB")
print(f" 内存峰值: {ps['rss_max_mb']:.0f}MB")
print(f" 采样点数: {ps['samples']}")
print()
elif not perf.available:
print(color(" ⚠️ 未安装 psutil无法采集 CPU/内存数据", C["yellow"]))
print(color(" 安装: pip3 install psutil", C["dim"]))
print()
if __name__ == "__main__":
main()

View File

@ -1384,6 +1384,21 @@ def resource_busy_page():
return app.send_static_file('resource_busy.html'), 503
@app.route('/api/voice_debug', methods=['POST'])
def voice_debug():
"""接收手机端语音调试日志"""
import os, datetime
data = request.get_data(as_text=True)
log_dir = os.environ.get('LOGS_DIR', os.path.join(os.path.dirname(__file__), '..', 'logs'))
voice_log_dir = os.path.join(log_dir, 'voice_debug')
os.makedirs(voice_log_dir, exist_ok=True)
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = os.path.join(voice_log_dir, f'voice_{ts}.log')
with open(filename, 'w') as f:
f.write(data)
return jsonify({'ok': True, 'file': filename})
if __name__ == "__main__":
args = parse_arguments()
run_server(

View File

@ -207,13 +207,13 @@
</button>
<div class="input-actions-right">
<button
v-if="isWebSpeechSupported"
v-if="isVoiceInputSupported"
type="button"
class="stadium-btn voice-btn"
:class="{ 'voice-btn--recording': isRecording }"
:class="{ 'voice-btn--recording': isRecording, 'voice-btn--disabled': voiceModelStatus === 'downloading' || voiceModelStatus === 'model_not_ready' }"
@click="toggleVoiceRecording"
:disabled="!isConnected || inputLocked"
title="语音输入"
:title="voiceButtonTitle"
>
<template v-if="!isRecording">
<svg
@ -1185,6 +1185,114 @@ const recognitionInstance = ref<SpeechRecognition | null>(null);
let voiceAnchorPos = 0;
let lastVoiceTextLength = 0;
// idle | downloading | ready | model_not_ready | error
const voiceModelStatus = ref<string>('idle');
const voiceDownloadPercent = ref(0);
const voiceDownloadMsg = ref('');
// Android APK VoiceBridge
const isVoiceInputSupported = computed(() => {
if (typeof window === 'undefined') return false;
return !!(window as any).AndroidVoiceBridge;
});
const voiceButtonTitle = computed(() => {
if (voiceModelStatus.value === 'downloading') return `模型下载中 ${voiceDownloadPercent.value}%`;
if (voiceModelStatus.value === 'model_not_ready') return '模型未下载,请在个人空间下载';
if (isRecording.value) return '停止录音';
return '语音输入';
});
// Android Bridge
const setupVoiceBridgeCallbacks = () => {
if (typeof window === 'undefined') return;
(window as any).__onVoiceResult = (text: string) => {
console.log('[VoiceInput] Android 识别结果:', text);
const bridge = (window as any).AndroidVoiceBridge;
bridge?.debugLog('__onVoiceResult 被调用, text=' + JSON.stringify(text));
if (!text) {
bridge?.debugLog('__onVoiceResult: text 为空,跳过');
return;
}
const ed = editor.value;
if (!ed) {
bridge?.debugLog('__onVoiceResult: editor.value 为 null');
console.warn('[VoiceInput] editor is null');
return;
}
bridge?.debugLog('__onVoiceResult: editor.value 存在,准备插入文字');
bridge?.debugLog('__onVoiceResult: voiceAnchorPos=' + voiceAnchorPos + ' lastVoiceTextLength=' + lastVoiceTextLength);
const { state, view } = ed;
const docSize = state.doc.content.size;
const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, docSize));
const oldEnd = Math.min(safeAnchor + lastVoiceTextLength, docSize);
bridge?.debugLog('__onVoiceResult: docSize=' + docSize + ' safeAnchor=' + safeAnchor + ' oldEnd=' + oldEnd);
try {
let tr = state.tr;
if (oldEnd > safeAnchor) tr = tr.delete(safeAnchor, oldEnd);
tr = tr.insertText(text, safeAnchor);
const newCursor = Math.min(safeAnchor + text.length, tr.doc.content.size);
const TextSelection = (window as any).TextSelection;
if (TextSelection) {
tr = tr.setSelection(TextSelection.create(tr.doc, newCursor));
}
view.dispatch(tr);
view.focus();
lastVoiceTextLength = text.length;
emit('update:input-message', getEditorPlainText());
emit('input-change');
nextTick(() => adjustTextareaSize());
bridge?.debugLog('__onVoiceResult: 文字插入成功');
} catch (e: any) {
bridge?.debugLog('__onVoiceResult: 插入异常 ' + (e.message || String(e)));
}
};
(window as any).__onVoiceStatus = (status: string) => {
console.log('[VoiceInput] 状态:', status);
if (status === 'listening' || status === 'processing' || status === 'initializing') {
// initializing startListening
isRecording.value = true;
} else if (status === 'idle') {
isRecording.value = false;
voiceAnchorPos = 0;
lastVoiceTextLength = 0;
} else if (status === 'model_not_ready') {
voiceModelStatus.value = 'model_not_ready';
}
};
(window as any).__onVoiceError = (error: string) => {
console.warn('[VoiceInput] 错误:', error);
isRecording.value = false;
voiceAnchorPos = 0;
lastVoiceTextLength = 0;
};
(window as any).__onVoiceDownloadProgress = (pct: number, msg: string) => {
voiceModelStatus.value = 'downloading';
voiceDownloadPercent.value = pct;
voiceDownloadMsg.value = msg;
if (pct >= 100) {
voiceModelStatus.value = 'ready';
}
};
(window as any).__onVoiceModelReady = () => {
voiceModelStatus.value = 'ready';
};
};
//
if (typeof window !== 'undefined') {
setupVoiceBridgeCallbacks();
// Android Bridge
const bridge = (window as any).AndroidVoiceBridge;
if (bridge && typeof bridge.isModelReady === 'function') {
try {
voiceModelStatus.value = bridge.isModelReady() ? 'ready' : 'model_not_ready';
} catch (_) {
voiceModelStatus.value = 'idle';
}
}
}
const isWebSpeechSupported = computed(() => {
if (typeof window === 'undefined') return false;
const hasSR = 'SpeechRecognition' in window;
@ -1340,6 +1448,23 @@ const initSpeechRecognition = () => {
const toggleVoiceRecording = () => {
console.log('[VoiceInput] toggleVoiceRecording, isRecording=', isRecording.value);
// Android Bridge
const bridge = (window as any).AndroidVoiceBridge;
if (bridge) {
if (isRecording.value) {
bridge.stopListening();
return;
}
//
const instance = editor.value;
voiceAnchorPos = instance ? instance.state.selection.from : 0;
lastVoiceTextLength = 0;
bridge.startListening();
return;
}
// Web Speech API
if (isRecording.value) {
stopVoiceRecording();
return;

View File

@ -1470,6 +1470,68 @@
</div>
</section>
<section
v-else-if="activeTab === 'voice'"
key="voice"
class="settings-page"
>
<div class="settings-section" style="margin-bottom: 16px;">
<p class="settings-section-desc" style="margin: 0; color: var(--text-secondary); font-size: 13px; line-height: 1.6;">
端侧语音识别模型SenseVoice int8支持中英混说 + 自动标点
模型约 228MB仅在手机本地运行无需网络
</p>
</div>
<div class="settings-action-row">
<span class="settings-row-copy">
<span class="settings-row-title">语音识别模型</span>
<span class="settings-row-desc">
<template v-if="voiceModelReady">已下载 (228MB)</template>
<template v-else-if="voiceDownloading">下载中 {{ voiceDownloadPercent }}% {{ voiceDownloadMsg }}</template>
<template v-else-if="voiceModelPartial">下载不完整请重新下载</template>
<template v-else>未下载 点击下载</template>
</span>
</span>
<div style="display: flex; gap: 6px;">
<button
type="button"
class="settings-secondary-button"
:disabled="voiceDownloading"
@click="downloadVoiceModel"
>
{{ voiceDownloading ? '下载中...' : voiceModelReady ? '重新下载' : '下载模型' }}
</button>
<button
v-if="voiceModelReady || voiceModelPartial"
type="button"
class="settings-secondary-button"
style="color: var(--state-error);"
:disabled="voiceDownloading"
@click="deleteVoiceModel"
>
删除
</button>
</div>
</div>
<div class="settings-action-row" style="margin-top: 8px;">
<span class="settings-row-copy">
<span class="settings-row-desc" style="font-size: 12px;">点击麦克风闪退先复现一次再点上传日志</span>
</span>
<button
type="button"
class="settings-secondary-button"
@click="saveDebugLog"
>
上传日志
</button>
</div>
<div v-if="voiceDownloading" class="voice-download-bar" style="margin: 8px 0 0;">
<div class="voice-download-track">
<div class="voice-download-fill" :style="{ width: voiceDownloadPercent + '%' }"></div>
</div>
<span style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">{{ voiceDownloadPercent }}%</span>
</div>
</section>
<section
v-else-if="activeTab === 'admin'"
key="admin"
@ -1641,7 +1703,8 @@ const baseTabs = [
{ id: 'context', label: '上下文', icon: 'chatBubble' },
{ id: 'tools', label: '工具与 Skills', icon: 'wrench' },
{ id: 'files', label: '文件与图片', icon: 'file' },
{ id: 'data', label: '数据管理', icon: 'layers' }
{ id: 'data', label: '数据管理', icon: 'layers' },
{ id: 'voice', label: '语音模型', icon: 'mic' },
] as const satisfies ReadonlyArray<{ id: PersonalTab; label: string; icon: IconKey }>;
const sessionRole = ref('');
@ -1683,6 +1746,91 @@ const activeTabLabel = computed(() => {
return personalTabs.value.find((tab) => tab.id === activeTab.value)?.label || '常规';
});
//
const voiceModelReady = ref(false);
const voiceModelPartial = ref(false); //
const voiceDownloading = ref(false);
const voiceDownloadPercent = ref(0);
const voiceDownloadMsg = ref('');
const checkVoiceModel = () => {
const bridge = (window as any)?.AndroidVoiceBridge;
if (bridge) {
try {
if (typeof bridge.isModelReady === 'function') {
voiceModelReady.value = bridge.isModelReady();
}
if (!voiceModelReady.value && typeof bridge.isModelPartial === 'function') {
voiceModelPartial.value = bridge.isModelPartial();
}
} catch (_) { /* ignore */ }
}
};
const downloadVoiceModel = () => {
const bridge = (window as any)?.AndroidVoiceBridge;
if (!bridge) {
alert('仅在 App 内支持下载语音模型');
return;
}
voiceDownloading.value = true;
voiceDownloadPercent.value = 0;
voiceDownloadMsg.value = '准备下载...';
voiceModelPartial.value = false;
(window as any).__onVoiceDownloadProgress = (pct: number, msg: string) => {
voiceDownloadPercent.value = pct;
voiceDownloadMsg.value = msg;
if (pct >= 100) {
voiceDownloading.value = false;
voiceModelReady.value = true;
voiceModelPartial.value = false;
}
};
(window as any).__onVoiceModelReady = () => {
voiceModelReady.value = true;
voiceDownloading.value = false;
voiceModelPartial.value = false;
};
bridge.downloadModel();
};
const deleteVoiceModel = () => {
const bridge = (window as any)?.AndroidVoiceBridge;
if (!bridge) return;
if (typeof bridge.deleteModel === 'function') {
bridge.deleteModel();
}
voiceModelReady.value = false;
voiceModelPartial.value = false;
voiceDownloadPercent.value = 0;
voiceDownloadMsg.value = '';
};
const saveDebugLog = async () => {
const bridge = (window as any)?.AndroidVoiceBridge;
if (!bridge) return;
let log = '';
if (typeof bridge.collectDebugLog === 'function') {
log = bridge.collectDebugLog();
}
if (!log) { alert('无法收集日志'); return; }
try {
const res = await fetch('/api/voice_debug', { method: 'POST', body: log });
if (res.ok) {
alert('日志已上传到服务器');
} else {
alert('上传失败: ' + res.status);
}
} catch (e: any) {
alert('上传失败: ' + (e.message || e));
}
};
//
checkVoiceModel();
const updateFloatingMenuPosition = async () => {
if (!activeDropdown.value || typeof window === 'undefined') {
floatingMenuStyle.value = {};
@ -3300,4 +3448,24 @@ const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
grid-template-columns: 1fr;
}
}
/* ── 语音模型下载进度条 ── */
.voice-download-bar {
display: flex;
flex-direction: column;
gap: 4px;
}
.voice-download-track {
width: 100%;
height: 6px;
background: var(--surface-muted);
border-radius: 3px;
overflow: hidden;
}
.voice-download-fill {
height: 100%;
background: var(--accent-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
</style>

View File

@ -838,6 +838,12 @@ body[data-theme='light'] {
background: var(--hover-bg) !important;
}
.voice-btn--disabled {
opacity: 0.5;
cursor: not-allowed;
color: var(--text-secondary);
}
.voice-wave {
display: flex;
align-items: center;

View File

@ -25,6 +25,7 @@ export const ICONS = Object.freeze({
layers: '/static/icons/layers.svg',
keyboard: '/static/icons/keyboard.svg',
menu: '/static/icons/menu.svg',
mic: '/static/icons/mic.svg',
mcpLogo: '/static/icons/mcp-logo.svg',
monitor: '/static/icons/monitor.svg',
navigation: '/static/icons/navigation.svg',