From 9ce8641a6429a41568f68ebf424c7b62464493ab Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:47:49 +0800 Subject: [PATCH 1/8] feat: add Tencent Cloud ASR support (streaming + file recognition) - Implement TencentStreamAsrEngine with WebSocket-based real-time ASR (HMAC-SHA1 auth) - Implement TencentFileAsrEngine with TC3-HMAC-SHA256 REST API (one-sentence recognition) - Add Tencent to AsrVendor enum and AsrVendorRegistry with tags (Online, Streaming, NonStreaming, Accurate) - Expose AppId, SecretId, SecretKey, engine type, streaming toggle, and VAD toggle in Prefs - Add UI configuration fields: credential inputs, streaming switch, engine type dropdown, VAD switch - Register engine in all factory tables (direct mic, Push PCM, file recognizer) - Add engine mode resolver support for streaming/non-streaming selection - Add Chinese, Arabic, Japanese, Traditional Chinese string resources - Add comprehensive tests: baseline, factory, resolver, registry, and stream engine unit tests --- .../asr/AsrDirectMicrophoneEngineFactory.kt | 5 + .../asrkb/asr/AsrEngineModeResolver.kt | 2 + .../asrkb/asr/AsrFileRecognizerFactory.kt | 6 +- .../asrkb/asr/AsrPushPcmEngineFactory.kt | 4 + .../java/com/brycewg/asrkb/asr/AsrVendor.kt | 3 +- .../brycewg/asrkb/asr/AsrVendorRegistry.kt | 12 + .../brycewg/asrkb/asr/TencentFileAsrEngine.kt | 305 +++++++++++++ .../asrkb/asr/TencentStreamAsrEngine.kt | 418 ++++++++++++++++++ .../java/com/brycewg/asrkb/store/Prefs.kt | 18 + .../asrkb/store/PrefsAsrVendorFields.kt | 8 + .../java/com/brycewg/asrkb/store/PrefsKeys.kt | 6 + .../ui/settings/asr/AsrSettingsViewModel.kt | 9 + .../screens/AsrOnlineSettingsFields.kt | 44 +- .../compose/screens/AsrOnlineVendorConfigs.kt | 145 ++++++ .../screens/AsrSettingsRouteContent.kt | 15 +- .../screens/AsrSettingsRouteContract.kt | 14 +- .../compose/screens/AsrSettingsScreen.kt | 16 + .../main/res/values-ar/strings_tencent.xml | 41 ++ .../main/res/values-ja/strings_tencent.xml | 41 ++ .../res/values-zh-rCN/strings_tencent.xml | 41 ++ .../res/values-zh-rTW/strings_tencent.xml | 41 ++ app/src/main/res/values/strings_tencent.xml | 41 ++ .../asrkb/asr/AsrConstructionBaselineTest.kt | 3 + .../AsrDirectMicrophoneEngineFactoryTest.kt | 6 +- .../asrkb/asr/AsrEngineModeResolverTest.kt | 4 +- .../asrkb/asr/AsrFileRecognizerFactoryTest.kt | 1 + .../asrkb/asr/AsrPushPcmEngineFactoryTest.kt | 3 + .../asrkb/asr/AsrVendorRegistryTest.kt | 13 +- .../asr/CurrentAsrConstructionBaseline.kt | 11 + .../asrkb/asr/TencentStreamAsrEngineTest.kt | 134 ++++++ 30 files changed, 1401 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt create mode 100644 app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt create mode 100644 app/src/main/res/values-ar/strings_tencent.xml create mode 100644 app/src/main/res/values-ja/strings_tencent.xml create mode 100644 app/src/main/res/values-zh-rCN/strings_tencent.xml create mode 100644 app/src/main/res/values-zh-rTW/strings_tencent.xml create mode 100644 app/src/main/res/values/strings_tencent.xml create mode 100644 app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt index c1d627f7..754c3f49 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt @@ -70,6 +70,7 @@ internal enum class AsrDirectMicrophoneEngineConstructorKey( OpenAiRealtime("OpenAiRealtimeAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), DashscopeStream("DashscopeStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), SonioxStream("SonioxStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), + TencentStream("TencentStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), SenseVoicePseudoStream("SenseVoicePseudoStreamAsrEngine", AsrDirectMicrophoneEngineFamily.LocalPseudoStream), FireRedAsrPseudoStream("FireRedAsrPseudoStreamAsrEngine", AsrDirectMicrophoneEngineFamily.LocalPseudoStream), XAsrStream("XAsrStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream) @@ -194,6 +195,7 @@ internal class AsrDirectMicrophoneEngineFactory( AsrVendor.OpenAI -> AsrDirectMicrophoneEngineConstructorKey.OpenAiRealtime AsrVendor.DashScope -> AsrDirectMicrophoneEngineConstructorKey.DashscopeStream AsrVendor.Soniox -> AsrDirectMicrophoneEngineConstructorKey.SonioxStream + AsrVendor.Tencent -> AsrDirectMicrophoneEngineConstructorKey.TencentStream AsrVendor.XAsr -> AsrDirectMicrophoneEngineConstructorKey.XAsrStream else -> error("$vendor has no direct stream engine") } @@ -274,6 +276,8 @@ internal object RealAsrDirectMicrophoneEngineConstructorTable : AsrDirectMicroph DashscopeStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) AsrDirectMicrophoneEngineConstructorKey.SonioxStream -> SonioxStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) + AsrDirectMicrophoneEngineConstructorKey.TencentStream -> + TencentStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) AsrDirectMicrophoneEngineConstructorKey.SenseVoicePseudoStream -> SenseVoicePseudoStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, request.onRequestDuration) AsrDirectMicrophoneEngineConstructorKey.FireRedAsrPseudoStream -> @@ -296,6 +300,7 @@ internal fun Prefs.asrEngineModePreferencesSnapshot(): AsrEngineModePreferences openAiStreamingEnabled = isOpenAiStreamingEffective(), dashScopeStreamingEnabled = isDashStreamingModelSelected(), sonioxStreamingEnabled = sonioxStreamingEnabled, + tencentStreamingEnabled = tencentStreamingEnabled, senseVoicePseudoStreamEnabled = svPseudoStreamEnabled, fireRedPseudoStreamEnabled = frPseudoStreamEnabled ) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt index 42f162ff..560a7f88 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt @@ -8,6 +8,7 @@ internal data class AsrEngineModePreferences( val openAiStreamingEnabled: Boolean = false, val dashScopeStreamingEnabled: Boolean = false, val sonioxStreamingEnabled: Boolean = false, + val tencentStreamingEnabled: Boolean = false, val senseVoicePseudoStreamEnabled: Boolean = false, val fireRedPseudoStreamEnabled: Boolean = false ) @@ -201,6 +202,7 @@ internal object AsrEngineModeResolver { AsrVendor.OpenAI -> openAiStreamingEnabled AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled + AsrVendor.Tencent -> tencentStreamingEnabled AsrVendor.XAsr -> true else -> false } diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt index 41afda11..b59b4199 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt @@ -40,7 +40,8 @@ internal enum class AsrFileRecognizerKey( FunAsrNanoFile("FunAsrNanoFileAsrEngine", AsrFileRecognizerFamily.LocalFile), Qwen3AsrFile("Qwen3AsrFileAsrEngine", AsrFileRecognizerFamily.LocalFile), ParakeetFile("ParakeetFileAsrEngine", AsrFileRecognizerFamily.LocalFile), - FireRedAsrFile("FireRedAsrFileAsrEngine", AsrFileRecognizerFamily.LocalFile) + FireRedAsrFile("FireRedAsrFileAsrEngine", AsrFileRecognizerFamily.LocalFile), + TencentFile("TencentFileAsrEngine", AsrFileRecognizerFamily.File) } internal fun fileRecognizerKeyFor( @@ -62,6 +63,7 @@ internal fun fileRecognizerKeyFor( AsrVendor.StepAudio -> AsrFileRecognizerKey.StepAudioFile AsrVendor.Zhipu -> AsrFileRecognizerKey.ZhipuFile AsrVendor.Cohere -> AsrFileRecognizerKey.CohereFile + AsrVendor.Tencent -> AsrFileRecognizerKey.TencentFile AsrVendor.SenseVoice -> AsrFileRecognizerKey.SenseVoiceFile AsrVendor.FunAsrNano -> AsrFileRecognizerKey.FunAsrNanoFile AsrVendor.Qwen3Asr -> AsrFileRecognizerKey.Qwen3AsrFile @@ -118,6 +120,8 @@ internal object RealAsrFileRecognizerConstructorTable : AsrFileRecognizerConstru ParakeetFileAsrEngine(request.context, request.scope, request.prefs, request.listener, request.onRequestDuration) AsrFileRecognizerKey.FireRedAsrFile -> FireRedAsrFileAsrEngine(request.context, request.scope, request.prefs, request.listener, request.onRequestDuration) + AsrFileRecognizerKey.TencentFile -> + TencentFileAsrEngine(request.context, request.scope, request.prefs, request.listener, request.onRequestDuration) } } diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt index fe9c20bf..a8f09204 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt @@ -60,6 +60,7 @@ internal enum class AsrPushPcmEngineConstructorKey( OpenAiRealtime("OpenAiRealtimeAsrEngine", AsrPushPcmEngineFamily.NativeStream), DashscopeStream("DashscopeStreamAsrEngine", AsrPushPcmEngineFamily.NativeStream), SonioxStream("SonioxStreamAsrEngine", AsrPushPcmEngineFamily.NativeStream), + TencentStream("TencentStreamAsrEngine", AsrPushPcmEngineFamily.NativeStream), SenseVoicePushPcmPseudoStream("SenseVoicePushPcmPseudoStreamAsrEngine", AsrPushPcmEngineFamily.PseudoStream), FireRedAsrPushPcmPseudoStream("FireRedAsrPushPcmPseudoStreamAsrEngine", AsrPushPcmEngineFamily.PseudoStream), XAsrStream("XAsrStreamAsrEngine", AsrPushPcmEngineFamily.LocalStream) @@ -201,6 +202,7 @@ internal class AsrPushPcmEngineFactory( AsrVendor.OpenAI -> AsrPushPcmEngineConstructorKey.OpenAiRealtime AsrVendor.DashScope -> AsrPushPcmEngineConstructorKey.DashscopeStream AsrVendor.Soniox -> AsrPushPcmEngineConstructorKey.SonioxStream + AsrVendor.Tencent -> AsrPushPcmEngineConstructorKey.TencentStream AsrVendor.XAsr -> AsrPushPcmEngineConstructorKey.XAsrStream else -> error("$vendor has no Push PCM native stream engine") } @@ -278,6 +280,8 @@ internal object RealAsrPushPcmEngineConstructorTable : AsrPushPcmEngineConstruct DashscopeStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) AsrPushPcmEngineConstructorKey.SonioxStream -> SonioxStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) + AsrPushPcmEngineConstructorKey.TencentStream -> + TencentStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) AsrPushPcmEngineConstructorKey.SenseVoicePushPcmPseudoStream -> SenseVoicePushPcmPseudoStreamAsrEngine( request.context, diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrVendor.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrVendor.kt index c5e3a756..a8be7e8c 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrVendor.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrVendor.kt @@ -21,7 +21,8 @@ enum class AsrVendor(val id: String) { Parakeet("parakeet"), FireRedAsr("firered_asr"), XAsr("x_asr"), - MiMo("mimo"); + MiMo("mimo"), + Tencent("tencent"); companion object { fun fromId(id: String?): AsrVendor = diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrVendorRegistry.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrVendorRegistry.kt index ac12b0f4..901c573f 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrVendorRegistry.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrVendorRegistry.kt @@ -284,6 +284,18 @@ internal object AsrVendorRegistry { AsrVendorDisplayTag.Accurate ), capabilities = localStreamingCapabilities() + ), + descriptor( + vendor = AsrVendor.Tencent, + displayNameResId = R.string.vendor_tencent, + pickerOrder = 18, + tags = listOf( + AsrVendorDisplayTag.Online, + AsrVendorDisplayTag.Streaming, + AsrVendorDisplayTag.NonStreaming, + AsrVendorDisplayTag.Accurate + ), + capabilities = onlineFileAndStreamCapabilities() ) ) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt b/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt new file mode 100644 index 00000000..2e385bad --- /dev/null +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt @@ -0,0 +1,305 @@ +package com.brycewg.asrkb.asr + +import android.content.Context +import android.util.Base64 +import com.brycewg.asrkb.R +import com.brycewg.asrkb.store.Prefs +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.ByteArrayOutputStream + +/** + * 腾讯云一句话识别(SentenceRecognition)引擎。 + * + * 调用腾讯云一句话识别 API(非流式,单次 HTTP POST): + * https://cloud.tencent.com/document/product/1093/35646 + * + * 注意:该接口仅支持 60 秒以内的音频,超出会被截断。 + * 实现上的 "File" 命名遵从项目惯例——所有非流式一次性识别引擎均称为 FileAsrEngine。 + */ +class TencentFileAsrEngine( + context: Context, + scope: CoroutineScope, + prefs: Prefs, + listener: StreamingAsrEngine.Listener, + onRequestDuration: ((Long) -> Unit)? = null, + httpClient: OkHttpClient? = null, + private val baseUrl: String = "https://asr.tencentcloudapi.com" +) : BaseFileAsrEngine(context, scope, prefs, listener, onRequestDuration), PcmBatchRecognizer { + + override val maxRecordDurationMillis: Int = 60_000 + override val uploadAudioEncodingSpec: UploadAudioEncodingSpec? = null + + private val http: OkHttpClient = httpClient ?: OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(ApiLogInterceptor()) + .build() + + override suspend fun recognize(pcm: ByteArray) { + val appId = prefs.tencentAppId + val secretId = prefs.tencentSecretId + val secretKey = prefs.tencentSecretKey + if (appId.isBlank() || secretId.isBlank() || secretKey.isBlank()) { + listener.onError(context.getString(R.string.error_missing_tencent_key)) + return + } + + val engineType = prefs.tencentEngineType.ifBlank { "16k_zh" } + val pcmWithHeader = wavHeader(pcm.size) + pcm + val base64Audio = Base64.encodeToString(pcmWithHeader, Base64.NO_WRAP) + + val timestamp = System.currentTimeMillis() / 1000 + val date = formatDate(timestamp) + + val payload = buildJsonPayload(engineType, base64Audio, pcmWithHeader.size) + val host = java.net.URI(baseUrl).host ?: "asr.tencentcloudapi.com" + val action = "SentenceRecognition" + val signedHeaders = "content-type;host;x-tc-action" + val canonicalUri = "/" + val canonicalQueryString = "" + val contentType = "application/json; charset=utf-8" + + val canonicalHeaders = "content-type:$contentType\nhost:$host\nx-tc-action:${action.lowercase()}\n" + val hashedPayload = sha256Hex(payload) + val canonicalRequest = "POST\n$canonicalUri\n$canonicalQueryString\n$canonicalHeaders\n$signedHeaders\n$hashedPayload" + + val credentialScope = "$date/asr/tc3_request" + val hashedCanonicalRequest = sha256Hex(canonicalRequest) + val algorithm = "TC3-HMAC-SHA256" + val stringToSign = "$algorithm\n$timestamp\n$credentialScope\n$hashedCanonicalRequest" + + val secretDate = hmac256("TC3$secretKey", date) + val secretService = hmac256(secretDate, "asr") + val secretSigning = hmac256(secretService, "tc3_request") + val signature = hmac256Hex(secretSigning, stringToSign) + + val authorization = "$algorithm Credential=$secretId/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature" + + val request = Request.Builder() + .url(baseUrl) + .tag( + ApiLogMeta::class.java, + ApiLogRecorder.meta( + category = "ASR", + vendor = "tencent", + model = engineType, + requestStructure = "json keys=EngSerViceType, SourceType, VoiceFormat, Data(base64), DataLen" + ) + ) + .addHeader("Authorization", authorization) + .addHeader("Content-Type", contentType) + .addHeader("Host", host) + .addHeader("X-TC-Action", action) + .addHeader("X-TC-Version", "2019-06-14") + .addHeader("X-TC-Timestamp", timestamp.toString()) + .addHeader("X-TC-Region", "ap-shanghai") + .post(payload.toRequestBody(contentType.toMediaType())) + .build() + + val t0 = System.nanoTime() + try { + http.newCall(request).execute().use { r -> + val bodyStr = r.body?.string().orEmpty() + if (!r.isSuccessful) { + val detail = formatHttpDetail(r.message, extractApiError(bodyStr) ?: bodyStr.take(200).trim()) + listener.onError( + context.getString(R.string.error_request_failed_http, r.code, detail) + ) + return + } + // TC3 协议:业务错误同样以 HTTP 200 返回,错误位于 Response.Error(Code 为字符串) + val apiError = extractApiError(bodyStr) + if (apiError != null) { + listener.onError( + context.getString(R.string.error_recognize_failed_with_reason, apiError) + ) + return + } + val text = parseResult(bodyStr) + if (!text.isNullOrBlank()) { + val dt = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0) + try { + onRequestDuration?.invoke(dt) + } catch (_: Throwable) {} + listener.onFinal(text) + } else { + listener.onError(context.getString(R.string.error_asr_empty_result)) + } + } + } catch (t: Throwable) { + listener.onError( + context.getString(R.string.error_recognize_failed_with_reason, t.message ?: "") + ) + } + } + + /** TC3 错误协议:HTTP 200 也可能携带业务错误,位于 Response.Error(Code/Message 均为字符串) */ + private fun extractApiError(body: String): String? { + if (body.isBlank()) return null + return try { + val err = JSONObject(body).optJSONObject("Response")?.optJSONObject("Error") + ?: return null + val code = err.optString("Code").trim() + val msg = err.optString("Message").trim() + listOf(code, msg).filter { it.isNotEmpty() }.joinToString(": ").ifBlank { null } + } catch (_: Throwable) { + null + } + } + + private fun parseResult(body: String): String? = try { + JSONObject(body).optJSONObject("Response")?.optString("Result")?.trim()?.ifBlank { null } + } catch (_: Throwable) { + null + } + + override suspend fun recognizeEncoded(audio: UploadAudioData): Unit = recognize(audio.bytes) + + override suspend fun recognizeFromPcm(pcm: ByteArray): Unit = recognize(pcm) + + private fun buildJsonPayload( + engineType: String, + base64Audio: String, + dataLen: Int + ): String = """{"EngSerViceType":"$engineType","SourceType":1,"VoiceFormat":"wav","Data":"$base64Audio","DataLen":$dataLen}""" + + private fun wavHeader(dataLen: Int): ByteArray { + val sampleRate = 16000 + val channels = 1 + val bitsPerSample = 16 + val byteRate = sampleRate * channels * bitsPerSample / 8 + val blockAlign = channels * bitsPerSample / 8 + val totalDataLen = dataLen + 44 - 8 + val buf = ByteArrayOutputStream() + buf.write("RIFF".toByteArray()) + buf.write(intToBytes(totalDataLen)) + buf.write("WAVE".toByteArray()) + buf.write("fmt ".toByteArray()) + buf.write(intToBytes(16)) + buf.write(shortToBytes(1)) + buf.write(shortToBytes(channels)) + buf.write(intToBytes(sampleRate)) + buf.write(intToBytes(byteRate)) + buf.write(shortToBytes(blockAlign)) + buf.write(shortToBytes(bitsPerSample)) + buf.write("data".toByteArray()) + buf.write(intToBytes(dataLen)) + return buf.toByteArray() + } + + private fun intToBytes(value: Int): ByteArray = byteArrayOf( + (value and 0xff).toByte(), + ((value shr 8) and 0xff).toByte(), + ((value shr 16) and 0xff).toByte(), + ((value shr 24) and 0xff).toByte() + ) + + private fun shortToBytes(value: Int): ByteArray = byteArrayOf( + (value and 0xff).toByte(), + ((value shr 8) and 0xff).toByte() + ) +} + +internal fun sha256Hex(s: String): String { + val md = java.security.MessageDigest.getInstance("SHA-256") + return md.digest(s.toByteArray()).joinToString("") { "%02x".format(it) } +} + +internal fun hmac256(key: ByteArray, msg: String): ByteArray { + val mac = javax.crypto.Mac.getInstance("HmacSHA256") + mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(msg.toByteArray()) +} + +internal fun hmac256(key: String, msg: String): ByteArray = hmac256(key.toByteArray(), msg) + +internal fun hmac256Hex(key: ByteArray, msg: String): String = + hmac256(key, msg).joinToString("") { "%02x".format(it) } + +internal fun hmac256Hex(key: String, msg: String): String = hmac256Hex(key.toByteArray(), msg) + +internal fun formatDate(timestamp: Long): String { + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US) + sdf.timeZone = java.util.TimeZone.getTimeZone("UTC") + return sdf.format(java.util.Date(timestamp * 1000)) +} + +internal fun extractJsonStr(json: String, vararg path: String): String? { + var current = json + for (key in path) { + val search = "\"$key\":" + val idx = current.indexOf(search) + if (idx < 0) return null + val start = idx + search.length + val trimmed = current.substring(start).trimStart() + if (trimmed.startsWith("\"")) { + val end = trimmed.indexOf('"', 1) + if (end < 0) return null + current = trimmed.substring(1, end) + } else { + val end = indexOfJsonValueEnd(trimmed) + if (end < 0) return null + current = trimmed.substring(0, end) + } + } + return current +} + +internal fun extractJsonInt(json: String, vararg path: String): Int? { + val str = extractJsonStr(json, *path) + return str?.toIntOrNull() +} + +internal fun extractJsonStrRaw(json: String, key: String): String? { + val search = "\"$key\":" + val idx = json.indexOf(search) + if (idx < 0) return null + val start = idx + search.length + val trimmed = json.substring(start).trimStart() + if (trimmed.startsWith("{")) { + var depth = 1 + var inStr = false + var escaped = false + for (i in 1 until trimmed.length) { + val c = trimmed[i] + if (escaped) { escaped = false; continue } + when { + c == '\\' && inStr -> escaped = true + c == '"' -> inStr = !inStr + !inStr -> { + if (c == '{') depth++ + if (c == '}') { depth--; if (depth == 0) return trimmed.substring(1, i) } + } + } + } + } + return null +} + +private fun indexOfJsonValueEnd(s: String): Int { + var depth = 0 + var inStr = false + var escaped = false + for (i in s.indices) { + val c = s[i] + if (escaped) { escaped = false; continue } + when { + c == '\\' && inStr -> escaped = true + c == '"' -> inStr = !inStr + !inStr -> when (c) { + '{', '[' -> depth++ + '}', ']' -> { depth--; if (depth < 0) return i } + ',', ' ' -> if (depth == 0) return i + } + } + } + return s.length +} + diff --git a/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt b/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt new file mode 100644 index 00000000..478148d0 --- /dev/null +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt @@ -0,0 +1,418 @@ +package com.brycewg.asrkb.asr + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.util.Base64 +import android.util.Log +import androidx.core.content.ContextCompat +import com.brycewg.asrkb.R +import com.brycewg.asrkb.store.Prefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString.Companion.toByteString +import java.net.URLEncoder +import java.util.ArrayDeque +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * 腾讯云实时语音识别(流式 WebSocket)引擎。 + * + * 调用腾讯云实时识别 WebSocket API: + * https://cloud.tencent.com/document/product/1093/48982 + * + * 鉴权方式:HMAC-SHA1 + Base64 + URLEncode + * 连接地址:wss://asr.cloud.tencent.com/asr/v2/ + * engine_model_type 枚举列表见上述文档。 + */ +class TencentStreamAsrEngine( + private val context: Context, + private val scope: CoroutineScope, + private val prefs: Prefs, + private val listener: StreamingAsrEngine.Listener, + httpClient: OkHttpClient? = null, + private val externalPcmMode: Boolean = false +) : StreamingAsrEngine, ExternalPcmConsumer, AudioFrameSinkOwner { + + private val running = AtomicBoolean(false) + private var wssJob: Job? = null + private var audioJob: Job? = null + override var audioFrameSink: AudioFrameSink? = null + private var audioChan: Channel? = null + private val wsReady = AtomicBoolean(false) + private var currentVoiceId: String? = null + + private val clientOwned: Boolean = httpClient == null + private val client: OkHttpClient = httpClient ?: OkHttpClient.Builder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + + private val sampleRate = 16000 + private val channelConfig = AudioFormat.CHANNEL_IN_MONO + private val audioFormat = AudioFormat.ENCODING_PCM_16BIT + private val prebuffer = ArrayDeque() + private val prebufferLock = Any() + private val externalVadInputLeveler = VadInputLevelerBranch(sampleRate = sampleRate) + private val maxPrebufferFrames = (2000 / CHUNK_MILLIS).coerceAtLeast(1) + + override val isRunning: Boolean get() = running.get() + + override fun start() { + if (running.getAndSet(true)) return + if (!externalPcmMode) { + val hasPermission = ContextCompat.checkSelfPermission( + context, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + if (!hasPermission) { + listener.onError(context.getString(R.string.error_record_permission_denied)) + running.set(false) + return + } + } + externalVadInputLeveler.reset() + synchronized(prebufferLock) { prebuffer.clear() } + audioChan = Channel(Channel.UNLIMITED) + if (!externalPcmMode) startAudioStreaming() + openWebSocket() + } + + override fun stop() { + if (!running.get()) return + running.set(false) + wsReady.set(false) + audioJob?.cancel() + audioJob = null + try { audioChan?.close() } catch (_: Throwable) { } + } + + private fun openWebSocket() { + wssJob = scope.launch(Dispatchers.IO) { + runSession(listener) + } + } + + private fun startAudioStreaming() { + audioJob?.cancel() + audioJob = scope.launch(Dispatchers.IO) { + val audioManager = AudioCaptureManager( + context = context, + sampleRate = sampleRate, + channelConfig = channelConfig, + audioFormat = audioFormat, + chunkMillis = CHUNK_MILLIS, + audioFrameSinkProvider = { audioFrameSink } + ) + + if (!audioManager.hasPermission()) { + Log.e(TAG, "Missing RECORD_AUDIO permission") + listener.onError(context.getString(R.string.error_record_permission_denied)) + running.set(false) + return@launch + } + + val vadDetector = if (isVadAutoStopEnabled(context, prefs)) { + VadDetector( + context, sampleRate, + prefs.autoStopSilenceWindowMs, + prefs.autoStopSilenceSensitivity + ) + } else { + null + } + val maxDurationLimiter = RecordingDurationLimiter.fromPrefs( + prefs = prefs, sampleRate = sampleRate + ) + val vadInputLeveler = VadInputLevelerBranch(sampleRate = sampleRate) + + try { + audioManager.startCapture().collect { audioChunk -> + if (!isActive || !running.get()) return@collect + + val leveled = vadInputLeveler.process(audioChunk) + + try { + listener.onAmplitude(leveled.stableAmplitude) + } catch (_: Throwable) { } + + if (vadDetector?.shouldStop(leveled.leveledPcm, leveled.leveledPcm.size) == true) { + try { listener.onStopped() } catch (_: Throwable) { } + stop() + return@collect + } + + enqueuePcm(audioChunk) + + if (maxDurationLimiter.acceptPcm(audioChunk.size)) { + try { listener.onStopped() } catch (_: Throwable) { } + stop() + return@collect + } + } + } catch (t: Throwable) { + if (t is kotlinx.coroutines.CancellationException) { + Log.d(TAG, "Audio streaming cancelled: ${t.message}") + } else { + Log.e(TAG, "Audio streaming failed: ${t.message}", t) + listener.onError(context.getString(R.string.error_audio_error, t.message ?: "")) + } + } finally { + try { vadDetector?.release() } catch (_: Throwable) { } + } + } + } + + // ========== ExternalPcmConsumer(外部推流) ========== + + override fun appendPcm(pcm: ByteArray, sampleRate: Int, channels: Int) { + if (!running.get()) return + if (sampleRate != this.sampleRate || channels != 1) return + val leveled = externalVadInputLeveler.process(pcm) + try { + listener.onAmplitude(leveled.stableAmplitude) + } catch (_: Throwable) { } + enqueuePcm(pcm.copyOf()) + } + + /** + * 将一帧 PCM 送入发送队列;WebSocket 未就绪时先进入预缓冲, + * 就绪后先冲刷预缓冲再发送当前帧(麦克风采集与外部推流共用)。 + */ + private fun enqueuePcm(chunk: ByteArray) { + if (!wsReady.get()) { + synchronized(prebufferLock) { + prebuffer.addLast(chunk) + while (prebuffer.size > maxPrebufferFrames) prebuffer.removeFirst() + } + } else { + var flushed: Array? = null + synchronized(prebufferLock) { + if (prebuffer.isNotEmpty()) { + flushed = prebuffer.toTypedArray() + prebuffer.clear() + } + } + flushed?.forEach { b -> audioChan?.trySend(b) } + audioChan?.trySend(chunk) + } + } + + fun buildWsUrl(): String? { + val appId = prefs.tencentAppId + val secretId = prefs.tencentSecretId + val secretKey = prefs.tencentSecretKey + if (appId.isBlank() || secretId.isBlank() || secretKey.isBlank()) return null + + val voiceId = UUID.randomUUID().toString().replace("-", "") + currentVoiceId = voiceId + val engineType = prefs.tencentEngineType.ifBlank { "16k_zh" } + val voiceFormat = 1 + val needvad = if (prefs.tencentVadEnabled) 1 else 0 + val timestamp = System.currentTimeMillis() / 1000 + val expired = timestamp + 86400 + val nonce = (1000000..9999999).random() + + val params = sortedMapOf( + "engine_model_type" to engineType, + "expired" to expired.toString(), + "needvad" to needvad.toString(), + "nonce" to nonce.toString(), + "secretid" to secretId, + "timestamp" to timestamp.toString(), + "voice_format" to voiceFormat.toString(), + "voice_id" to voiceId + ) + + val queryString = params.entries.joinToString("&") { (k, v) -> "$k=${URLEncoder.encode(v, "UTF-8")}" } + val signaturePlain = "asr.cloud.tencent.com/asr/v2/$appId?$queryString" + val signature = hmacSha1Base64(signaturePlain, secretKey) + val encodedSignature = URLEncoder.encode(signature, "UTF-8") + return "wss://asr.cloud.tencent.com/asr/v2/$appId?$queryString&signature=$encodedSignature" + } + + private suspend fun runSession(listener: StreamingAsrEngine.Listener) { + val wsUrl = buildWsUrl() ?: run { + listener.onError(context.getString(R.string.error_missing_tencent_key)) + running.set(false) + return + } + + val chan = audioChan ?: run { + listener.onError("Audio channel not initialized") + running.set(false) + return + } + + val wsClosed = AtomicBoolean(false) + val accumulatedText = StringBuilder() + val finalTextDelivered = AtomicBoolean(false) + + val meta = ApiCallLogger.meta( + category = "ASR", + vendor = "tencent", + model = prefs.tencentEngineType.ifBlank { "16k_zh" }, + requestStructure = "WebSocket wss://asr.cloud.tencent.com/asr/v2/?signed_query, binary PCM frames" + ) + val request = Request.Builder() + .url(wsUrl) + .tag(ApiLogMeta::class.java, meta) + .build() + val apiLogSession = ApiCallLogger.startWebSocket(request, meta) + + val wsListener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + wsReady.set(true) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val code = extractJsonInt(text, "code") ?: 0 + if (code != 0) { + val msg = extractJsonStr(text, "message") ?: "Unknown error" + listener.onError( + context.getString( + R.string.error_recognize_failed_with_reason, + "Tencent stream error $code: $msg" + ) + ) + wsClosed.set(true) + return + } + + val isFinal = extractJsonInt(text, "final") == 1 + val resultRaw = extractJsonStrRaw(text, "result") + + if (resultRaw != null) { + val sliceType = extractJsonInt(resultRaw, "slice_type") ?: -1 + val voiceText = extractJsonStr(resultRaw, "voice_text_str") ?: "" + when (sliceType) { + 0 -> { } + 1 -> if (voiceText.isNotEmpty()) listener.onPartial(voiceText) + 2 -> if (voiceText.isNotEmpty()) { + listener.onPartial(voiceText) + accumulatedText.append(voiceText) + } + } + } + + if (isFinal) { + if (resultRaw != null) { + val finalText = extractJsonStr(resultRaw, "voice_text_str") ?: "" + if (finalText.isNotBlank()) { + accumulatedText.setLength(0) + accumulatedText.append(finalText) + } + } + if (!finalTextDelivered.getAndSet(true)) { + val ft = accumulatedText.toString() + if (ft.isNotBlank()) { + listener.onFinal(ft) + } + } + wsClosed.set(true) + } + } catch (e: Exception) { + Log.e(TAG, "Parse error: ${e.message}") + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + wsClosed.set(true) + webSocket.close(1000, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + wsClosed.set(true) + apiLogSession.complete(success = finalTextDelivered.get(), code = code, error = reason) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + wsClosed.set(true) + apiLogSession.failure( + code = response?.code ?: 0, + error = response?.message ?: t.message.orEmpty() + ) + if (finalTextDelivered.get()) return + if (!finalTextDelivered.getAndSet(true)) { + val ft = accumulatedText.toString() + if (ft.isNotBlank()) { + listener.onFinal(ft) + } + } + listener.onError( + context.getString(R.string.error_recognize_failed_with_reason, t.message ?: "") + ) + } + } + + val ws = client.newWebSocket(request, wsListener) + + val sendJob = scope.launch(Dispatchers.IO) { + while (isActive && running.get() && !wsClosed.get()) { + val pcm = try { + chan.receive() + } catch (_: Exception) { + break + } + ws.send(pcm.toByteString()) + } + } + + try { + sendJob.join() + } catch (_: Exception) { } + + try { + ws.send("{\"type\": \"end\"}") + } catch (_: Exception) { } + + val finalWaitStart = System.currentTimeMillis() + val finalTimeoutMs = 10000L + while (!wsClosed.get() && (System.currentTimeMillis() - finalWaitStart < finalTimeoutMs)) { + delay(100) + } + + try { + ws.close(1000, "client close") + if (clientOwned) { + client.dispatcher.executorService.shutdown() + } + } catch (_: Exception) { } + + if (!finalTextDelivered.getAndSet(true)) { + val ft = accumulatedText.toString() + if (ft.isNotBlank()) { + listener.onFinal(ft) + } + } + running.set(false) + } + + companion object { + private const val TAG = "TencentStreamAsrEngine" + private const val CHUNK_MILLIS = 200 + + fun hmacSha1Base64(plainText: String, secretKey: String): String { + val mac = Mac.getInstance("HmacSHA1") + mac.init(SecretKeySpec(secretKey.toByteArray(Charsets.UTF_8), "HmacSHA1")) + return Base64.encodeToString(mac.doFinal(plainText.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) + } + + fun sortedMapOf(vararg pairs: Pair): Map = + linkedMapOf(*pairs).toSortedMap() + } +} diff --git a/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt b/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt index 4129aee1..7aa13017 100644 --- a/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt +++ b/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt @@ -1177,6 +1177,18 @@ class Prefs(context: Context) { } } + // 腾讯云 ASR + var tencentAppId: String by stringPref(KEY_TENCENT_APP_ID, "") + var tencentSecretId: String by stringPref(KEY_TENCENT_SECRET_ID, "") + var tencentSecretKey: String by stringPref(KEY_TENCENT_SECRET_KEY, "") + var tencentEngineType: String by stringPref(KEY_TENCENT_ENGINE_TYPE, DEFAULT_TENCENT_ENGINE_TYPE) + var tencentStreamingEnabled: Boolean + get() = sp.getBoolean(KEY_TENCENT_STREAMING_ENABLED, false) + set(value) = sp.edit { putBoolean(KEY_TENCENT_STREAMING_ENABLED, value) } + var tencentVadEnabled: Boolean + get() = sp.getBoolean(KEY_TENCENT_VAD_ENABLED, false) + set(value) = sp.edit { putBoolean(KEY_TENCENT_VAD_ENABLED, value) } + // Google Gemini 语音理解(通过提示词转写) var gemEndpoint: String by stringPref(KEY_GEM_ENDPOINT, DEFAULT_GEM_ENDPOINT) @@ -1629,6 +1641,9 @@ class Prefs(context: Context) { if (v == AsrVendor.MiMo) { return mimoAsrApiKey.isNotBlank() && getEffectiveMimoAsrEndpoint().isNotBlank() } + if (v == AsrVendor.Tencent) { + return tencentAppId.isNotBlank() && tencentSecretId.isNotBlank() && tencentSecretKey.isNotBlank() + } if (v == AsrVendor.StepAudio) { return stepAudioApiKey.isNotBlank() && getEffectiveStepAudioAsrEndpoint().isNotBlank() } @@ -1670,6 +1685,7 @@ class Prefs(context: Context) { fun hasOpenRouterKeys(): Boolean = hasVendorKeys(AsrVendor.OpenRouter) fun hasGeminiKeys(): Boolean = hasVendorKeys(AsrVendor.Gemini) fun hasMiMoKeys(): Boolean = hasVendorKeys(AsrVendor.MiMo) + fun hasTencentKeys(): Boolean = hasVendorKeys(AsrVendor.Tencent) fun hasSonioxKeys(): Boolean = hasVendorKeys(AsrVendor.Soniox) fun hasStepAudioKeys(): Boolean = hasVendorKeys(AsrVendor.StepAudio) fun hasZhipuKeys(): Boolean = hasVendorKeys(AsrVendor.Zhipu) @@ -2091,6 +2107,8 @@ class Prefs(context: Context) { const val DEFAULT_COHERE_ASR_MODEL = "cohere-transcribe-03-2026" const val COHERE_ARABIC_ASR_MODEL = "cohere-transcribe-arabic-07-2026" const val DEFAULT_COHERE_ASR_LANGUAGE = "zh" + // 腾讯云 ASR 默认值 + const val DEFAULT_TENCENT_ENGINE_TYPE = "16k_zh" val COHERE_ASR_MODELS: List = listOf( DEFAULT_COHERE_ASR_MODEL, COHERE_ARABIC_ASR_MODEL diff --git a/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt b/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt index 79fd1323..428eb54f 100644 --- a/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt +++ b/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt @@ -90,6 +90,14 @@ internal object PrefsAsrVendorFields { VendorField.prompt(KEY_MIMO_ASR_PROMPT, default = "请将以下音频准确转写为文字"), VendorField.boolean(KEY_MIMO_ASR_DISABLE_THINKING, default = false) ), + AsrVendor.Tencent to listOf( + VendorField.credential(KEY_TENCENT_APP_ID, required = false), + VendorField.credential(KEY_TENCENT_SECRET_ID, required = false), + VendorField.credential(KEY_TENCENT_SECRET_KEY, required = false), + VendorField.streamingToggle(KEY_TENCENT_STREAMING_ENABLED, default = false), + VendorField(KEY_TENCENT_ENGINE_TYPE, default = Prefs.DEFAULT_TENCENT_ENGINE_TYPE), + VendorField.boolean(KEY_TENCENT_VAD_ENABLED, default = false) + ), AsrVendor.Soniox to listOf( VendorField.credential(KEY_SONIOX_API_KEY), VendorField.streamingToggle(KEY_SONIOX_STREAMING_ENABLED, default = true), diff --git a/app/src/main/java/com/brycewg/asrkb/store/PrefsKeys.kt b/app/src/main/java/com/brycewg/asrkb/store/PrefsKeys.kt index 17070b3a..36c5c789 100644 --- a/app/src/main/java/com/brycewg/asrkb/store/PrefsKeys.kt +++ b/app/src/main/java/com/brycewg/asrkb/store/PrefsKeys.kt @@ -167,6 +167,12 @@ internal const val KEY_MIMO_ASR_MODEL = "mimo_asr_model" internal const val KEY_MIMO_ASR_LANGUAGE = "mimo_asr_language" internal const val KEY_MIMO_ASR_PROMPT = "mimo_asr_prompt" internal const val KEY_MIMO_ASR_DISABLE_THINKING = "mimo_asr_disable_thinking" +internal const val KEY_TENCENT_APP_ID = "tencent_app_id" +internal const val KEY_TENCENT_SECRET_ID = "tencent_secret_id" +internal const val KEY_TENCENT_SECRET_KEY = "tencent_secret_key" +internal const val KEY_TENCENT_ENGINE_TYPE = "tencent_engine_type" +internal const val KEY_TENCENT_STREAMING_ENABLED = "tencent_streaming_enabled" +internal const val KEY_TENCENT_VAD_ENABLED = "tencent_vad_enabled" internal const val KEY_PUNCT_1 = "punct_1" internal const val KEY_PUNCT_2 = "punct_2" internal const val KEY_PUNCT_3 = "punct_3" diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt index e8c16321..0e1c4ea3 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt @@ -91,6 +91,8 @@ class AsrSettingsViewModel : ViewModel() { // Soniox settings sonioxStreamingEnabled = prefs.sonioxStreamingEnabled, sonioxLanguages = prefs.getSonioxLanguages(), + // Tencent settings + tencentStreamingEnabled = prefs.tencentStreamingEnabled, // SenseVoice settings svModelVariant = prefs.svModelVariant, svNumThreads = prefs.svNumThreads, @@ -379,6 +381,11 @@ class AsrSettingsViewModel : ViewModel() { _uiState.value = _uiState.value.copy(sonioxStreamingEnabled = enabled) } + fun updateTencentStreaming(enabled: Boolean) { + prefs.tencentStreamingEnabled = enabled + _uiState.value = _uiState.value.copy(tencentStreamingEnabled = enabled) + } + fun updateSonioxLanguages(languages: List) { prefs.setSonioxLanguages(languages) _uiState.value = _uiState.value.copy(sonioxLanguages = languages) @@ -963,6 +970,8 @@ data class AsrSettingsUiState( // Soniox settings val sonioxStreamingEnabled: Boolean = false, val sonioxLanguages: List = emptyList(), + // Tencent settings + val tencentStreamingEnabled: Boolean = false, // SenseVoice settings val svModelVariant: String = "small-int8", val svNumThreads: Int = 2, diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt index 330f7360..19bdb5d6 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt @@ -70,6 +70,12 @@ internal class AsrOnlineSettingsFields( var mimoPrompt by mutableStateOf(prefs.mimoAsrPrompt) var mimoModel by mutableStateOf(prefs.mimoAsrModel) var mimoDisableThinking by mutableStateOf(prefs.mimoAsrDisableThinking) + var tencentAppId by mutableStateOf(prefs.tencentAppId) + var tencentSecretId by mutableStateOf(prefs.tencentSecretId) + var tencentSecretKey by mutableStateOf(prefs.tencentSecretKey) + var tencentEngineType by mutableStateOf(prefs.tencentEngineType.ifBlank { Prefs.DEFAULT_TENCENT_ENGINE_TYPE }) + var tencentStreaming by mutableStateOf(prefs.tencentStreamingEnabled) + var tencentVadEnabled by mutableStateOf(prefs.tencentVadEnabled) var openAiProviders by mutableStateOf(prefs.getOpenAiAsrProviders()) var openAiActiveProviderId by mutableStateOf(prefs.activeOpenAiAsrProviderId) var openAiProfileName by mutableStateOf(prefs.getActiveOpenAiAsrProvider()?.name.orEmpty()) @@ -142,6 +148,12 @@ internal class AsrOnlineSettingsFields( sonioxEndpointSensitivityLevel = prefs.sonioxEndpointSensitivityLevel sonioxLanguages = prefs.getSonioxLanguages() sonioxLanguageStrict = prefs.sonioxLanguageHintsStrict + tencentAppId = prefs.tencentAppId + tencentSecretId = prefs.tencentSecretId + tencentSecretKey = prefs.tencentSecretKey + tencentEngineType = prefs.tencentEngineType.ifBlank { Prefs.DEFAULT_TENCENT_ENGINE_TYPE } + tencentStreaming = prefs.tencentStreamingEnabled + tencentVadEnabled = prefs.tencentVadEnabled } private fun readAndPersistNormalizedCohereLanguage(): String { @@ -194,6 +206,7 @@ internal class AsrOnlineSettingsFields( applySonioxStreamingSwitch: (Boolean) -> Unit, applySonioxLanguageStrictSwitch: (Boolean) -> Unit, applyStepAudioUseItnSwitch: (Boolean) -> Unit, + applyTencentStreamingSwitch: (Boolean) -> Unit, openAiDefaultProfileName: (Int) -> String ): AsrOnlineSettingsRouteState = AsrOnlineSettingsRouteState( volcAppKey = volcAppKey, @@ -522,7 +535,36 @@ internal class AsrOnlineSettingsFields( sonioxLanguageStrict = sonioxLanguageStrict, onSonioxLanguageStrictChange = { checked -> applySonioxLanguageStrictSwitch(checked) - } + }, + tencentAppId = tencentAppId, + onTencentAppIdChange = { value -> + tencentAppId = value + prefs.tencentAppId = value + }, + tencentSecretId = tencentSecretId, + onTencentSecretIdChange = { value -> + tencentSecretId = value + prefs.tencentSecretId = value + }, + tencentSecretKey = tencentSecretKey, + onTencentSecretKeyChange = { value -> + tencentSecretKey = value + prefs.tencentSecretKey = value + }, + tencentEngineType = tencentEngineType, + onTencentEngineTypeChange = { value -> + tencentEngineType = value + prefs.tencentEngineType = value + }, + tencentStreaming = tencentStreaming, + onTencentStreamingChange = { checked -> + applyTencentStreamingSwitch(checked) + }, + tencentVadEnabled = tencentVadEnabled, + onTencentVadEnabledChange = { checked -> + tencentVadEnabled = checked + prefs.tencentVadEnabled = checked + }, ) } diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineVendorConfigs.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineVendorConfigs.kt index 149fc226..d8597a4e 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineVendorConfigs.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineVendorConfigs.kt @@ -142,6 +142,18 @@ internal fun CurrentAsrVendorConfig( onChooseSonioxLanguages: () -> Unit, sonioxLanguageStrict: Boolean, onSonioxLanguageStrictChange: (Boolean) -> Unit, + tencentAppId: String, + onTencentAppIdChange: (String) -> Unit, + tencentSecretId: String, + onTencentSecretIdChange: (String) -> Unit, + tencentSecretKey: String, + onTencentSecretKeyChange: (String) -> Unit, + tencentEngineType: String, + onTencentEngineTypeChange: (String) -> Unit, + tencentStreaming: Boolean, + onTencentStreamingChange: (Boolean) -> Unit, + tencentVadEnabled: Boolean, + onTencentVadEnabledChange: (Boolean) -> Unit, onOpenGuide: (String) -> Unit, primaryIndexOffset: Int = 0, primaryGroupCount: Int? = null @@ -612,6 +624,71 @@ internal fun CurrentAsrVendorConfig( ) } + AsrVendor.Tencent -> { + var itemIndex = primaryIndexOffset + val itemCount = primaryGroupCount ?: 8 + AsrTextField( + uiMode = uiMode, + value = tencentAppId, + onValueChange = onTencentAppIdChange, + label = stringResource(R.string.label_tencent_app_id), + password = false, + index = itemIndex++, + count = itemCount + ) + AsrTextField( + uiMode = uiMode, + value = tencentSecretId, + onValueChange = onTencentSecretIdChange, + label = stringResource(R.string.label_tencent_secret_id), + password = false, + index = itemIndex++, + count = itemCount + ) + AsrTextField( + uiMode = uiMode, + value = tencentSecretKey, + onValueChange = onTencentSecretKeyChange, + label = stringResource(R.string.label_tencent_secret_key), + password = true, + index = itemIndex++, + count = itemCount + ) + AsrSwitchPreference( + id = "tencent_streaming", + titleRes = R.string.label_tencent_streaming, + checked = tencentStreaming, + index = itemIndex++, + count = itemCount, + onCheckedChange = onTencentStreamingChange + ) + AsrDropdownPreference( + titleRes = R.string.label_tencent_engine_type, + options = tencentEngineTypeOptions(context, tencentStreaming).map { option -> + DropdownOption(option.value, option.label) + }, + selectedOptionId = tencentEngineType, + index = itemIndex++, + count = itemCount, + onSelectedOptionChange = onTencentEngineTypeChange + ) + AsrSwitchPreference( + id = "tencent_vad_enabled", + titleRes = R.string.label_tencent_vad_enabled, + checked = tencentVadEnabled, + index = itemIndex++, + count = itemCount, + onCheckedChange = onTencentVadEnabledChange + ) + AsrActionPreference( + id = "tencent_get_key_guide", + titleRes = R.string.btn_get_api_key_guide, + index = itemIndex, + count = itemCount, + onClick = { onOpenGuide(TENCENT_KEY_GUIDE_URL) } + ) + } + else -> Unit } } @@ -1039,6 +1116,7 @@ internal fun currentOnlineAsrPrimaryItemCount( openAiUseCompletions ) AsrVendor.Soniox -> 6 + AsrVendor.Tencent -> 8 else -> 0 } @@ -1190,6 +1268,7 @@ private const val COHERE_DASHBOARD_URL = "https://dashboard.cohere.com/api-keys" internal const val COHERE_CUSTOM_MODEL_OPTION_ID = "__custom__" private const val OPENROUTER_KEY_URL = "https://openrouter.ai/settings/keys" private const val STEPAUDIO_KEY_URL = "https://platform.stepfun.com" +private const val TENCENT_KEY_GUIDE_URL = "https://console.cloud.tencent.com/cam/capi" private fun openAiProfileDisplayName( context: Context, @@ -1257,3 +1336,69 @@ internal fun sonioxLanguageSummary(context: Context, languages: List): S return labels.takeIf { it.isNotEmpty() }?.joinToString(separator = "、") ?: context.getString(R.string.soniox_lang_auto) } + +/** + * 腾讯云引擎模型选项(按识别路径区分;枚举写死自官方文档,腾讯无运行时查询 API)。 + * - 流式(实时识别 WebSocket):engine_model_type + * https://cloud.tencent.com/document/product/1093/48982 + * - 非流式(一句话识别 SentenceRecognition):EngSerViceType + * https://cloud.tencent.com/document/product/1093/35646 + * 仅收录 16k 引擎:应用音频固定 16kHz PCM,8k 电话引擎采样率不匹配,无法正确使用。 + * 「大模型1.0版」引擎需账号开通大模型1.0计费方案,否则会被服务端拒绝(标签中已标注)。 + */ +internal fun tencentEngineTypeOptions(context: Context, streaming: Boolean): List { + fun entry(id: String, labelRes: Int) = OnlineVendorChoice(id, context.getString(labelRes)) + return if (streaming) { + listOf( + entry("16k_zh", R.string.label_tencent_engine_type_16k_zh), + entry("16k_zh-TW", R.string.label_tencent_engine_type_16k_zh_TW), + entry("16k_zh_edu", R.string.label_tencent_engine_type_16k_zh_edu), + entry("16k_zh_medical", R.string.label_tencent_engine_type_16k_zh_medical), + entry("16k_zh_court", R.string.label_tencent_engine_type_16k_zh_court), + entry("16k_yue", R.string.label_tencent_engine_type_16k_yue), + entry("16k_en", R.string.label_tencent_engine_type_16k_en), + entry("16k_en_game", R.string.label_tencent_engine_type_16k_en_game), + entry("16k_en_edu", R.string.label_tencent_engine_type_16k_en_edu), + entry("16k_ko", R.string.label_tencent_engine_type_16k_ko), + entry("16k_ja", R.string.label_tencent_engine_type_16k_ja), + entry("16k_th", R.string.label_tencent_engine_type_16k_th), + entry("16k_id", R.string.label_tencent_engine_type_16k_id), + entry("16k_vi", R.string.label_tencent_engine_type_16k_vi), + entry("16k_ms", R.string.label_tencent_engine_type_16k_ms), + entry("16k_fil", R.string.label_tencent_engine_type_16k_fil), + entry("16k_pt", R.string.label_tencent_engine_type_16k_pt), + entry("16k_tr", R.string.label_tencent_engine_type_16k_tr), + entry("16k_ar", R.string.label_tencent_engine_type_16k_ar), + entry("16k_es", R.string.label_tencent_engine_type_16k_es), + entry("16k_hi", R.string.label_tencent_engine_type_16k_hi), + entry("16k_fr", R.string.label_tencent_engine_type_16k_fr), + entry("16k_de", R.string.label_tencent_engine_type_16k_de), + entry("16k_zh_en", R.string.label_tencent_engine_type_16k_zh_en), + entry("16k_multi_lang", R.string.label_tencent_engine_type_16k_multi_lang), + entry("16k_en_large", R.string.label_tencent_engine_type_16k_en_large) + ) + } else { + listOf( + entry("16k_zh", R.string.label_tencent_engine_type_16k_zh), + entry("16k_zh-PY", R.string.label_tencent_engine_type_16k_zh_PY), + entry("16k_zh_medical", R.string.label_tencent_engine_type_16k_zh_medical), + entry("16k_zh_dialect", R.string.label_tencent_engine_type_16k_zh_dialect), + entry("16k_yue", R.string.label_tencent_engine_type_16k_yue), + entry("16k_en", R.string.label_tencent_engine_type_16k_en), + entry("16k_ja", R.string.label_tencent_engine_type_16k_ja), + entry("16k_ko", R.string.label_tencent_engine_type_16k_ko), + entry("16k_vi", R.string.label_tencent_engine_type_16k_vi), + entry("16k_ms", R.string.label_tencent_engine_type_16k_ms), + entry("16k_id", R.string.label_tencent_engine_type_16k_id), + entry("16k_fil", R.string.label_tencent_engine_type_16k_fil), + entry("16k_th", R.string.label_tencent_engine_type_16k_th), + entry("16k_pt", R.string.label_tencent_engine_type_16k_pt), + entry("16k_tr", R.string.label_tencent_engine_type_16k_tr), + entry("16k_ar", R.string.label_tencent_engine_type_16k_ar), + entry("16k_es", R.string.label_tencent_engine_type_16k_es), + entry("16k_hi", R.string.label_tencent_engine_type_16k_hi), + entry("16k_fr", R.string.label_tencent_engine_type_16k_fr), + entry("16k_de", R.string.label_tencent_engine_type_16k_de) + ) + } +} diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContent.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContent.kt index aef18440..057e510f 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContent.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContent.kt @@ -269,6 +269,18 @@ internal fun AsrSettingsRouteContent( onChooseSonioxLanguages = showSonioxLanguagePicker, sonioxLanguageStrict = onlineState.sonioxLanguageStrict, onSonioxLanguageStrictChange = onlineState.onSonioxLanguageStrictChange, + tencentAppId = onlineState.tencentAppId, + onTencentAppIdChange = onlineState.onTencentAppIdChange, + tencentSecretId = onlineState.tencentSecretId, + onTencentSecretIdChange = onlineState.onTencentSecretIdChange, + tencentSecretKey = onlineState.tencentSecretKey, + onTencentSecretKeyChange = onlineState.onTencentSecretKeyChange, + tencentEngineType = onlineState.tencentEngineType, + onTencentEngineTypeChange = onlineState.onTencentEngineTypeChange, + tencentStreaming = onlineState.tencentStreaming, + onTencentStreamingChange = onlineState.onTencentStreamingChange, + tencentVadEnabled = onlineState.tencentVadEnabled, + onTencentVadEnabledChange = onlineState.onTencentVadEnabledChange, onOpenGuide = onOpenUrl, primaryIndexOffset = 1, primaryGroupCount = vendorGroupCount @@ -341,7 +353,8 @@ private fun currentAsrVendorPrimaryItemCount( AsrVendor.OpenRouter, AsrVendor.MiMo, AsrVendor.OpenAI, - AsrVendor.Soniox -> currentOnlineAsrPrimaryItemCount( + AsrVendor.Soniox, + AsrVendor.Tencent -> currentOnlineAsrPrimaryItemCount( selectedVendor = selectedVendor, openAiProviders = onlineState.openAiProviders, openAiUsePrompt = onlineState.openAiUsePrompt, diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContract.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContract.kt index 7ba6b34c..a5de94b5 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContract.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsRouteContract.kt @@ -142,7 +142,19 @@ internal data class AsrOnlineSettingsRouteState( val onSonioxEndpointSensitivityLevelChange: (Int) -> Unit, val sonioxLanguages: List, val sonioxLanguageStrict: Boolean, - val onSonioxLanguageStrictChange: (Boolean) -> Unit + val onSonioxLanguageStrictChange: (Boolean) -> Unit, + val tencentAppId: String, + val onTencentAppIdChange: (String) -> Unit, + val tencentSecretId: String, + val onTencentSecretIdChange: (String) -> Unit, + val tencentSecretKey: String, + val onTencentSecretKeyChange: (String) -> Unit, + val tencentEngineType: String, + val onTencentEngineTypeChange: (String) -> Unit, + val tencentStreaming: Boolean, + val onTencentStreamingChange: (Boolean) -> Unit, + val tencentVadEnabled: Boolean, + val onTencentVadEnabledChange: (Boolean) -> Unit, ) internal data class AsrBackupSettingsRouteState( diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt index 49cb4cd7..419ef63e 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt @@ -400,6 +400,21 @@ fun AsrSettingsScreen( ) } + fun applyTencentStreamingSwitch(target: Boolean) { + featureExplainerDialog = settingsFeatureExplainerDialogState( + context = context, + titleRes = R.string.label_tencent_streaming, + offDescRes = R.string.feature_tencent_streaming_off_desc, + onDescRes = R.string.feature_tencent_streaming_on_desc, + currentState = onlineFields.tencentStreaming, + preferenceKey = "tencent_streaming_explained", + onConfirm = { + onlineFields.tencentStreaming = target + viewModel.updateTencentStreaming(target) + } + ) + } + fun applyOpenAiStreamingSwitch(target: Boolean) { featureExplainerDialog = settingsFeatureExplainerDialogState( context = context, @@ -662,6 +677,7 @@ fun AsrSettingsScreen( applySonioxStreamingSwitch = ::applySonioxStreamingSwitch, applySonioxLanguageStrictSwitch = ::applySonioxLanguageStrictSwitch, applyStepAudioUseItnSwitch = ::applyStepAudioUseItnSwitch, + applyTencentStreamingSwitch = ::applyTencentStreamingSwitch, openAiDefaultProfileName = { index -> context.getString(R.string.openai_profile_default_name, index) } diff --git a/app/src/main/res/values-ar/strings_tencent.xml b/app/src/main/res/values-ar/strings_tencent.xml new file mode 100644 index 00000000..6fb771d1 --- /dev/null +++ b/app/src/main/res/values-ar/strings_tencent.xml @@ -0,0 +1,41 @@ + + + Tencent Cloud ASR + AppId + SecretId + SecretKey + نموذج المحرك + التعرف المتدفق + يستخدم التعرف على الجمل غير المتدفق. يتم رفع الصوت بعد انتهاء التسجيل. + يستخدم البث الفوري عبر WebSocket لعرض النتائج الوسيطة أثناء التحدث. + 16k_zh 中文通用 + 16k_zh-TW 中文繁体 + 16k_zh_edu 中文教育 + 16k_zh_medical 中文医疗 + 16k_zh_court 中文法庭 + 16k_yue 粤语 + 16k_en 英文通用 + 16k_en_game 英文游戏 + 16k_en_edu 英文教育 + 16k_ko 韩语 + 16k_ja 日语 + 16k_th 泰语 + 16k_id 印度尼西亚语 + 16k_vi 越南语 + 16k_ms 马来语 + 16k_fil 菲律宾语 + 16k_pt 葡萄牙语 + 16k_tr 土耳其语 + 16k_ar 阿拉伯语 + 16k_es 西班牙语 + 16k_hi 印地语 + 16k_fr 法语 + 16k_de 德语 + 16k_zh_en 中英大模型(需大模型1.0计费) + 16k_multi_lang 多语种大模型(需大模型1.0计费) + 16k_en_large 英文大模型(需大模型1.0计费) + 16k_zh-PY 中英粤 + 16k_zh_dialect 多方言(23种) + فصل الجمل تلقائيًا + لم يتم ضبط بيانات اعتماد Tencent Cloud + diff --git a/app/src/main/res/values-ja/strings_tencent.xml b/app/src/main/res/values-ja/strings_tencent.xml new file mode 100644 index 00000000..7001b50f --- /dev/null +++ b/app/src/main/res/values-ja/strings_tencent.xml @@ -0,0 +1,41 @@ + + + Tencent Cloud ASR + AppId + SecretId + SecretKey + エンジンモデル + ストリーミング認識 + 非ストリーミングの一文認識を使用します。録音終了後に音声をアップロードします。 + リアルタイム WebSocket ストリーミングを使用し、話している間に中間結果を表示します。 + 16k_zh 中文通用 + 16k_zh-TW 中文繁体 + 16k_zh_edu 中文教育 + 16k_zh_medical 中文医疗 + 16k_zh_court 中文法庭 + 16k_yue 粤语 + 16k_en 英文通用 + 16k_en_game 英文游戏 + 16k_en_edu 英文教育 + 16k_ko 韩语 + 16k_ja 日语 + 16k_th 泰语 + 16k_id 印度尼西亚语 + 16k_vi 越南语 + 16k_ms 马来语 + 16k_fil 菲律宾语 + 16k_pt 葡萄牙语 + 16k_tr 土耳其语 + 16k_ar 阿拉伯语 + 16k_es 西班牙语 + 16k_hi 印地语 + 16k_fr 法语 + 16k_de 德语 + 16k_zh_en 中英大模型(需大模型1.0计费) + 16k_multi_lang 多语种大模型(需大模型1.0计费) + 16k_en_large 英文大模型(需大模型1.0计费) + 16k_zh-PY 中英粤 + 16k_zh_dialect 多方言(23种) + 自動文区切り + Tencent Cloud の認証情報が未設定です + diff --git a/app/src/main/res/values-zh-rCN/strings_tencent.xml b/app/src/main/res/values-zh-rCN/strings_tencent.xml new file mode 100644 index 00000000..7ded2e86 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings_tencent.xml @@ -0,0 +1,41 @@ + + + 腾讯云 ASR + AppId + SecretId + SecretKey + 引擎模型 + 流式识别 + 使用非流式一句话识别。录音结束后上传音频。 + 使用实时 WebSocket 流式识别,说话即可看到中间结果。 + 16k_zh 中文通用 + 16k_zh-TW 中文繁体 + 16k_zh_edu 中文教育 + 16k_zh_medical 中文医疗 + 16k_zh_court 中文法庭 + 16k_yue 粤语 + 16k_en 英文通用 + 16k_en_game 英文游戏 + 16k_en_edu 英文教育 + 16k_ko 韩语 + 16k_ja 日语 + 16k_th 泰语 + 16k_id 印度尼西亚语 + 16k_vi 越南语 + 16k_ms 马来语 + 16k_fil 菲律宾语 + 16k_pt 葡萄牙语 + 16k_tr 土耳其语 + 16k_ar 阿拉伯语 + 16k_es 西班牙语 + 16k_hi 印地语 + 16k_fr 法语 + 16k_de 德语 + 16k_zh_en 中英大模型(需大模型1.0计费) + 16k_multi_lang 多语种大模型(需大模型1.0计费) + 16k_en_large 英文大模型(需大模型1.0计费) + 16k_zh-PY 中英粤 + 16k_zh_dialect 多方言(23种) + 自动断句 + 未配置腾讯云密钥 + diff --git a/app/src/main/res/values-zh-rTW/strings_tencent.xml b/app/src/main/res/values-zh-rTW/strings_tencent.xml new file mode 100644 index 00000000..f92b2c78 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_tencent.xml @@ -0,0 +1,41 @@ + + + 騰訊雲 ASR + AppId + SecretId + SecretKey + 引擎模型 + 流式辨識 + 使用非流式一句話辨識。錄音結束後上傳音訊。 + 使用即時 WebSocket 流式辨識,說話即可看到中間結果。 + 16k_zh 中文通用 + 16k_zh-TW 中文繁體 + 16k_zh_edu 中文教育 + 16k_zh_medical 中文醫療 + 16k_zh_court 中文法庭 + 16k_yue 粵語 + 16k_en 英文通用 + 16k_en_game 英文遊戲 + 16k_en_edu 英文教育 + 16k_ko 韓語 + 16k_ja 日語 + 16k_th 泰語 + 16k_id 印度尼西亞語 + 16k_vi 越南語 + 16k_ms 馬來語 + 16k_fil 菲律賓語 + 16k_pt 葡萄牙語 + 16k_tr 土耳其語 + 16k_ar 阿拉伯語 + 16k_es 西班牙語 + 16k_hi 印地語 + 16k_fr 法語 + 16k_de 德語 + 16k_zh_en 中英大模型(需大模型1.0計費) + 16k_multi_lang 多語種大模型(需大模型1.0計費) + 16k_en_large 英文大模型(需大模型1.0計費) + 16k_zh-PY 中英粵 + 16k_zh_dialect 多方言(23種) + 自動斷句 + 未配置騰訊雲金鑰 + diff --git a/app/src/main/res/values/strings_tencent.xml b/app/src/main/res/values/strings_tencent.xml new file mode 100644 index 00000000..2ef44204 --- /dev/null +++ b/app/src/main/res/values/strings_tencent.xml @@ -0,0 +1,41 @@ + + + 腾讯云 ASR + AppId + SecretId + SecretKey + Engine Model + Streaming recognition + Uses non-streaming sentence recognition. Audio uploaded after recording ends. + Uses real-time WebSocket streaming for instant partial results while speaking. + 16k_zh 中文通用 + 16k_zh-TW 中文繁体 + 16k_zh_edu 中文教育 + 16k_zh_medical 中文医疗 + 16k_zh_court 中文法庭 + 16k_yue 粤语 + 16k_en 英文通用 + 16k_en_game 英文游戏 + 16k_en_edu 英文教育 + 16k_ko 韩语 + 16k_ja 日语 + 16k_th 泰语 + 16k_id 印度尼西亚语 + 16k_vi 越南语 + 16k_ms 马来语 + 16k_fil 菲律宾语 + 16k_pt 葡萄牙语 + 16k_tr 土耳其语 + 16k_ar 阿拉伯语 + 16k_es 西班牙语 + 16k_hi 印地语 + 16k_fr 法语 + 16k_de 德语 + 16k_zh_en 中英大模型(需大模型1.0计费) + 16k_multi_lang 多语种大模型(需大模型1.0计费) + 16k_en_large 英文大模型(需大模型1.0计费) + 16k_zh-PY 中英粤 + 16k_zh_dialect 多方言(23种) + Auto sentence break + Tencent Cloud credentials not set + diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt index 5ba18fb2..3fd5ea1d 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt @@ -34,6 +34,8 @@ class AsrConstructionBaselineTest { OpenRouter[fileOnly] | app=File:OpenRouterFileAsrEngine | speech=File:OpenRouterFileAsrEngine | external=File:OpenRouterFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) Gemini[fileOnly] | app=File:GeminiFileAsrEngine | speech=File:GeminiFileAsrEngine | external=File:GeminiFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) MiMo[fileOnly] | app=File:MiMoFileAsrEngine | speech=File:MiMoFileAsrEngine | external=File:MiMoFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) + Tencent[streaming=true] | app=Stream:TencentStreamAsrEngine | speech=Stream:TencentStreamAsrEngine | external=Stream:TencentStreamAsrEngine | externalPush=PushPcmStream:TencentStreamAsrEngine | recordingPush=PushPcmStream:TencentStreamAsrEngine | recordingMode=PushPcm | parallelDirect=PushPcmStream:TencentStreamAsrEngine | parallelPush=PushPcmStream:TencentStreamAsrEngine + Tencent[streaming=false] | app=File:TencentFileAsrEngine | speech=File:TencentFileAsrEngine | external=File:TencentFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) StepAudio[fileOnly] | app=File:StepAudioFileAsrEngine | speech=File:StepAudioFileAsrEngine | external=File:StepAudioFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) Zhipu[fileOnly] | app=File:ZhipuFileAsrEngine | speech=File:ZhipuFileAsrEngine | external=File:ZhipuFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) Cohere[fileOnly] | app=File:CohereFileAsrEngine | speech=File:CohereFileAsrEngine | external=File:CohereFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) @@ -531,6 +533,7 @@ class AsrConstructionBaselineTest { fileOnlyRow(AsrVendor.OpenRouter), fileOnlyRow(AsrVendor.Gemini), fileOnlyRow(AsrVendor.MiMo), + streamingRows(AsrVendor.Tencent), fileOnlyRow(AsrVendor.StepAudio), fileOnlyRow(AsrVendor.Zhipu), fileOnlyRow(AsrVendor.Cohere), diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt index cb84e611..bc17924f 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt @@ -205,6 +205,7 @@ class AsrDirectMicrophoneEngineFactoryTest { AsrEngineModePreferences(openAiStreamingEnabled = true), AsrEngineModePreferences(dashScopeStreamingEnabled = true), AsrEngineModePreferences(sonioxStreamingEnabled = true), + AsrEngineModePreferences(tencentStreamingEnabled = true), AsrEngineModePreferences(senseVoicePseudoStreamEnabled = true), AsrEngineModePreferences(fireRedPseudoStreamEnabled = true) ) @@ -288,6 +289,7 @@ class AsrDirectMicrophoneEngineFactoryTest { AsrVendor.OpenAI -> openAiStreamingEnabled AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled + AsrVendor.Tencent -> tencentStreamingEnabled AsrVendor.XAsr -> true else -> false }, @@ -316,6 +318,7 @@ class AsrDirectMicrophoneEngineFactoryTest { AsrVendor.OpenAI -> AsrEngineModePreferences(openAiStreamingEnabled = true) AsrVendor.DashScope -> AsrEngineModePreferences(dashScopeStreamingEnabled = true) AsrVendor.Soniox -> AsrEngineModePreferences(sonioxStreamingEnabled = true) + AsrVendor.Tencent -> AsrEngineModePreferences(tencentStreamingEnabled = true) else -> error("$vendor is not controlled by a streaming preference") } @@ -343,7 +346,8 @@ class AsrDirectMicrophoneEngineFactoryTest { AsrVendor.ElevenLabs, AsrVendor.OpenAI, AsrVendor.DashScope, - AsrVendor.Soniox + AsrVendor.Soniox, + AsrVendor.Tencent ) } } diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrEngineModeResolverTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrEngineModeResolverTest.kt index ce68cf49..14850efb 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrEngineModeResolverTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrEngineModeResolverTest.kt @@ -218,6 +218,7 @@ class AsrEngineModeResolverTest { AsrVendor.OpenAI -> AsrEngineModePreferences(openAiStreamingEnabled = streaming) AsrVendor.DashScope -> AsrEngineModePreferences(dashScopeStreamingEnabled = streaming) AsrVendor.Soniox -> AsrEngineModePreferences(sonioxStreamingEnabled = streaming) + AsrVendor.Tencent -> AsrEngineModePreferences(tencentStreamingEnabled = streaming) else -> error("$vendor is not controlled by an online streaming toggle") } @@ -238,7 +239,8 @@ class AsrEngineModeResolverTest { StreamingCase(AsrVendor.ElevenLabs), StreamingCase(AsrVendor.OpenAI), StreamingCase(AsrVendor.DashScope), - StreamingCase(AsrVendor.Soniox) + StreamingCase(AsrVendor.Soniox), + StreamingCase(AsrVendor.Tencent) ) private val pseudoCases = listOf( diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt index bfe94393..214c380d 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt @@ -237,6 +237,7 @@ class AsrFileRecognizerFactoryTest { AsrEngineModePreferences(fireRedPseudoStreamEnabled = true), CurrentAsrConstructionSettings(pseudoStreamEnabled = true) ), + fileCase(AsrVendor.Tencent), streamingCase(AsrVendor.XAsr, AsrEngineModePreferences()) ) diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt index 6b1af338..0ec81586 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt @@ -260,6 +260,7 @@ class AsrPushPcmEngineFactoryTest { AsrEngineModePreferences(openAiStreamingEnabled = true), AsrEngineModePreferences(dashScopeStreamingEnabled = true), AsrEngineModePreferences(sonioxStreamingEnabled = true), + AsrEngineModePreferences(tencentStreamingEnabled = true), AsrEngineModePreferences(senseVoicePseudoStreamEnabled = true), AsrEngineModePreferences(fireRedPseudoStreamEnabled = true) ) @@ -384,6 +385,7 @@ class AsrPushPcmEngineFactoryTest { AsrVendor.OpenAI -> openAiStreamingEnabled AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled + AsrVendor.Tencent -> tencentStreamingEnabled AsrVendor.XAsr -> true else -> false }, @@ -416,6 +418,7 @@ class AsrPushPcmEngineFactoryTest { AsrVendor.OpenAI -> AsrEngineModePreferences(openAiStreamingEnabled = true) AsrVendor.DashScope -> AsrEngineModePreferences(dashScopeStreamingEnabled = true) AsrVendor.Soniox -> AsrEngineModePreferences(sonioxStreamingEnabled = true) + AsrVendor.Tencent -> AsrEngineModePreferences(tencentStreamingEnabled = true) else -> error("$vendor is not controlled by a streaming preference") } diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrVendorRegistryTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrVendorRegistryTest.kt index 8d86253e..53d3fd25 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrVendorRegistryTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrVendorRegistryTest.kt @@ -65,7 +65,8 @@ class AsrVendorRegistryTest { AsrVendor.Qwen3Asr, AsrVendor.Parakeet, AsrVendor.FireRedAsr, - AsrVendor.XAsr + AsrVendor.XAsr, + AsrVendor.Tencent ), AsrVendorRegistry.ordered().map { it.vendor } ) @@ -213,6 +214,13 @@ class AsrVendorRegistryTest { AsrVendorDisplayTag.Local, AsrVendorDisplayTag.Streaming, AsrVendorDisplayTag.Accurate + ), + AsrVendor.Tencent to metadata( + R.string.vendor_tencent, + AsrVendorDisplayTag.Online, + AsrVendorDisplayTag.Streaming, + AsrVendorDisplayTag.NonStreaming, + AsrVendorDisplayTag.Accurate ) ) @@ -231,7 +239,8 @@ class AsrVendorRegistryTest { AsrVendor.OpenAI, AsrVendor.DashScope, AsrVendor.Soniox, - AsrVendor.XAsr + AsrVendor.XAsr, + AsrVendor.Tencent ) val pseudoStreaming = setOf(AsrVendor.SenseVoice, AsrVendor.FireRedAsr) val local = setOf( diff --git a/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt b/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt index 101de295..1af52237 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt @@ -131,6 +131,11 @@ internal object CurrentAsrConstructionBaseline { AsrVendor.StepAudio -> file("StepAudioFileAsrEngine") AsrVendor.Zhipu -> file("ZhipuFileAsrEngine") AsrVendor.Cohere -> file("CohereFileAsrEngine") + AsrVendor.Tencent -> if (settings.streamingEnabled) { + stream("TencentStreamAsrEngine") + } else { + file("TencentFileAsrEngine") + } AsrVendor.SenseVoice -> if (settings.pseudoStreamEnabled) { localPseudo("SenseVoicePseudoStreamAsrEngine") } else { @@ -199,6 +204,11 @@ internal object CurrentAsrConstructionBaseline { AsrVendor.StepAudio -> pushAdapter("StepAudioFileAsrEngine") AsrVendor.Zhipu -> pushAdapter("ZhipuFileAsrEngine") AsrVendor.Cohere -> pushAdapter("CohereFileAsrEngine") + AsrVendor.Tencent -> if (settings.streamingEnabled) { + pushStream("TencentStreamAsrEngine") + } else { + pushAdapter("TencentFileAsrEngine") + } AsrVendor.SenseVoice -> if (settings.pseudoStreamEnabled) { use(CurrentAsrEngineFamily.PushPcmPseudoStream, "SenseVoicePushPcmPseudoStreamAsrEngine") } else { @@ -225,6 +235,7 @@ internal object CurrentAsrConstructionBaseline { AsrVendor.ElevenLabs, AsrVendor.OpenAI -> settings.streamingEnabled AsrVendor.XAsr -> true + AsrVendor.Tencent -> settings.streamingEnabled AsrVendor.SenseVoice, AsrVendor.FireRedAsr -> settings.pseudoStreamEnabled AsrVendor.SiliconFlow, diff --git a/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt new file mode 100644 index 00000000..8739d070 --- /dev/null +++ b/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt @@ -0,0 +1,134 @@ +package com.brycewg.asrkb.asr + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.brycewg.asrkb.store.Prefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class TencentStreamAsrEngineTest { + + private lateinit var context: Context + private lateinit var prefs: Prefs + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + prefs = Prefs(context).apply { + tencentAppId = "1300000000" + tencentSecretId = "PLACEHOLDER_AKID" + tencentSecretKey = "PLACEHOLDER_SECRET_KEY" + tencentEngineType = "16k_zh" + tencentVadEnabled = true + } + } + + @Test + fun buildsWsUrlWithValidCredentials() { + val engine = TencentStreamAsrEngine(context, kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default), prefs, object : StreamingAsrEngine.Listener { + override fun onFinal(text: String) {} + override fun onError(message: String) {} + override fun onPartial(text: String) {} + override fun onStopped() {} + override fun onAmplitude(amplitude: Float) {} + }) + val url = engine.buildWsUrl() + assertNotNull("URL should not be null with valid credentials", url) + assertTrue("URL should start with wss://", url!!.startsWith("wss://")) + assertTrue("URL should contain asr.cloud.tencent.com", url.contains("asr.cloud.tencent.com")) + assertTrue("URL should contain /asr/v2/", url.contains("/asr/v2/")) + assertTrue("URL should contain the appid", url.contains("1300000000")) + assertTrue("URL should contain engine_model_type=16k_zh", url.contains("engine_model_type=16k_zh")) + assertTrue("URL should contain signature parameter", url.contains("signature=")) + assertTrue("URL should contain secretid", url.contains("secretid=")) + assertTrue("URL should contain timestamp", url.contains("timestamp=")) + } + + @Test + fun wsUrlFailsWithMissingCredentials() { + val badPrefs = Prefs(context).apply { + tencentAppId = "" + tencentSecretId = "" + tencentSecretKey = "" + } + val engine = TencentStreamAsrEngine(context, kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default), badPrefs, object : StreamingAsrEngine.Listener { + override fun onFinal(text: String) {} + override fun onError(message: String) {} + override fun onPartial(text: String) {} + override fun onStopped() {} + override fun onAmplitude(amplitude: Float) {} + }) + val url = engine.buildWsUrl() + assertEquals(null, url) + } + + @Test + fun hmacSha1Base64ProducesCorrectSignature() { + val plain = "asr.cloud.tencent.com/asr/v2/1305857615?engine_model_type=16k_zh&expired=9999999999&nonce=1234567&secretid=PLACEHOLDER_AKID×tamp=9999999998&voice_format=1&voice_id=test123" + val result = TencentStreamAsrEngine.hmacSha1Base64(plain, "testkey") + assertNotNull(result) + assertTrue("HMAC-SHA1 signature should be base64 encoded", result.isNotBlank()) + } + + @Test + fun sortedMapOfPreservesAlphabeticalOrder() { + val map = TencentStreamAsrEngine.sortedMapOf( + "voice_id" to "abc", + "engine_model_type" to "16k_zh", + "secretid" to "test", + "timestamp" to "12345" + ) + val keys = map.keys.toList() + assertEquals("engine_model_type", keys[0]) + assertEquals("secretid", keys[1]) + assertEquals("timestamp", keys[2]) + assertEquals("voice_id", keys[3]) + } + + @Test + fun responseJsonParsingExtractsCode() { + val json = """{"code":0,"message":"success","voice_id":"abc123"}""" + val code = extractJsonInt(json, "code") + assertEquals(0, code?.toInt()) + } + + @Test + fun responseJsonParsingExtractsErrorCode() { + val json = """{"code":4002,"message":"auth failed"}""" + val code = extractJsonInt(json, "code") + assertEquals(4002, code?.toInt()) + } + + @Test + fun responseJsonParsingExtractsFinalFlag() { + val json = """{"code":0,"message":"success","voice_id":"abc","final":1}""" + val final = extractJsonInt(json, "final") + assertEquals(1, final?.toInt()) + } + + @Test + fun responseJsonParsingExtractsResultWithSliceType() { + val json = """{"code":0,"result":{"slice_type":2,"voice_text_str":"腾讯云语音识别测试欢迎您。"}}""" + val resultRaw = extractJsonStrRaw(json, "result") + assertNotNull(resultRaw) + val sliceType = extractJsonInt(resultRaw!!, "slice_type") + val voiceText = extractJsonStr(resultRaw, "voice_text_str") + assertEquals(2, sliceType?.toInt()) + assertEquals("腾讯云语音识别测试欢迎您。", voiceText) + } + + @Test + fun responseJsonHandlesMissingFieldsGracefully() { + val json = """{"code":0,"message":"success"}""" + val resultRaw = extractJsonStrRaw(json, "result") + assertEquals(null, resultRaw) + val final = extractJsonInt(json, "final") + assertEquals(null, final) + } +} From 967bc9f67eaad04ecab13205f8f5b5655c145a95 Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:37:17 +0800 Subject: [PATCH 2/8] refactor: replace custom JSON parsing with org.json.JSONObject in Tencent engines - Remove extractJsonStr, extractJsonInt, extractJsonStrRaw functions from TencentFileAsrEngine (hand-rolled string parsing, error-prone) - Replace all calls in TencentStreamAsrEngine with JSONObject.optXxx() - Update unit tests to use JSONObject directly - Simplify class-level KDoc to bare doc URL --- .../brycewg/asrkb/asr/TencentFileAsrEngine.kt | 80 +------------------ .../asrkb/asr/TencentStreamAsrEngine.kt | 28 +++---- .../asrkb/asr/TencentStreamAsrEngineTest.kt | 35 ++++---- 3 files changed, 30 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt b/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt index 2e385bad..5c44446a 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt @@ -14,13 +14,7 @@ import org.json.JSONObject import java.io.ByteArrayOutputStream /** - * 腾讯云一句话识别(SentenceRecognition)引擎。 - * - * 调用腾讯云一句话识别 API(非流式,单次 HTTP POST): - * https://cloud.tencent.com/document/product/1093/35646 - * - * 注意:该接口仅支持 60 秒以内的音频,超出会被截断。 - * 实现上的 "File" 命名遵从项目惯例——所有非流式一次性识别引擎均称为 FileAsrEngine。 + * https://cloud.tencent.com/document/product/1093/35646 */ class TencentFileAsrEngine( context: Context, @@ -231,75 +225,3 @@ internal fun formatDate(timestamp: Long): String { return sdf.format(java.util.Date(timestamp * 1000)) } -internal fun extractJsonStr(json: String, vararg path: String): String? { - var current = json - for (key in path) { - val search = "\"$key\":" - val idx = current.indexOf(search) - if (idx < 0) return null - val start = idx + search.length - val trimmed = current.substring(start).trimStart() - if (trimmed.startsWith("\"")) { - val end = trimmed.indexOf('"', 1) - if (end < 0) return null - current = trimmed.substring(1, end) - } else { - val end = indexOfJsonValueEnd(trimmed) - if (end < 0) return null - current = trimmed.substring(0, end) - } - } - return current -} - -internal fun extractJsonInt(json: String, vararg path: String): Int? { - val str = extractJsonStr(json, *path) - return str?.toIntOrNull() -} - -internal fun extractJsonStrRaw(json: String, key: String): String? { - val search = "\"$key\":" - val idx = json.indexOf(search) - if (idx < 0) return null - val start = idx + search.length - val trimmed = json.substring(start).trimStart() - if (trimmed.startsWith("{")) { - var depth = 1 - var inStr = false - var escaped = false - for (i in 1 until trimmed.length) { - val c = trimmed[i] - if (escaped) { escaped = false; continue } - when { - c == '\\' && inStr -> escaped = true - c == '"' -> inStr = !inStr - !inStr -> { - if (c == '{') depth++ - if (c == '}') { depth--; if (depth == 0) return trimmed.substring(1, i) } - } - } - } - } - return null -} - -private fun indexOfJsonValueEnd(s: String): Int { - var depth = 0 - var inStr = false - var escaped = false - for (i in s.indices) { - val c = s[i] - if (escaped) { escaped = false; continue } - when { - c == '\\' && inStr -> escaped = true - c == '"' -> inStr = !inStr - !inStr -> when (c) { - '{', '[' -> depth++ - '}', ']' -> { depth--; if (depth < 0) return i } - ',', ' ' -> if (depth == 0) return i - } - } - } - return s.length -} - diff --git a/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt b/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt index 478148d0..adaf5a00 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt @@ -31,14 +31,7 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** - * 腾讯云实时语音识别(流式 WebSocket)引擎。 - * - * 调用腾讯云实时识别 WebSocket API: - * https://cloud.tencent.com/document/product/1093/48982 - * - * 鉴权方式:HMAC-SHA1 + Base64 + URLEncode - * 连接地址:wss://asr.cloud.tencent.com/asr/v2/ - * engine_model_type 枚举列表见上述文档。 + * https://cloud.tencent.com/document/product/1093/48982 */ class TencentStreamAsrEngine( private val context: Context, @@ -280,9 +273,10 @@ class TencentStreamAsrEngine( override fun onMessage(webSocket: WebSocket, text: String) { try { - val code = extractJsonInt(text, "code") ?: 0 + val obj = org.json.JSONObject(text) + val code = obj.optInt("code", 0) if (code != 0) { - val msg = extractJsonStr(text, "message") ?: "Unknown error" + val msg = obj.optString("message", "Unknown error") listener.onError( context.getString( R.string.error_recognize_failed_with_reason, @@ -293,12 +287,12 @@ class TencentStreamAsrEngine( return } - val isFinal = extractJsonInt(text, "final") == 1 - val resultRaw = extractJsonStrRaw(text, "result") + val isFinal = obj.optInt("final") == 1 + val resultObj = obj.optJSONObject("result") - if (resultRaw != null) { - val sliceType = extractJsonInt(resultRaw, "slice_type") ?: -1 - val voiceText = extractJsonStr(resultRaw, "voice_text_str") ?: "" + if (resultObj != null) { + val sliceType = resultObj.optInt("slice_type", -1) + val voiceText = resultObj.optString("voice_text_str", "") when (sliceType) { 0 -> { } 1 -> if (voiceText.isNotEmpty()) listener.onPartial(voiceText) @@ -310,8 +304,8 @@ class TencentStreamAsrEngine( } if (isFinal) { - if (resultRaw != null) { - val finalText = extractJsonStr(resultRaw, "voice_text_str") ?: "" + if (resultObj != null) { + val finalText = resultObj.optString("voice_text_str", "") if (finalText.isNotBlank()) { accumulatedText.setLength(0) accumulatedText.append(finalText) diff --git a/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt index 8739d070..5e6f8858 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt @@ -3,6 +3,7 @@ package com.brycewg.asrkb.asr import android.content.Context import androidx.test.core.app.ApplicationProvider import com.brycewg.asrkb.store.Prefs +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -94,41 +95,41 @@ class TencentStreamAsrEngineTest { @Test fun responseJsonParsingExtractsCode() { val json = """{"code":0,"message":"success","voice_id":"abc123"}""" - val code = extractJsonInt(json, "code") - assertEquals(0, code?.toInt()) + val code = JSONObject(json).optInt("code", -1) + assertEquals(0, code) } @Test fun responseJsonParsingExtractsErrorCode() { val json = """{"code":4002,"message":"auth failed"}""" - val code = extractJsonInt(json, "code") - assertEquals(4002, code?.toInt()) + val code = JSONObject(json).optInt("code", -1) + assertEquals(4002, code) } @Test fun responseJsonParsingExtractsFinalFlag() { val json = """{"code":0,"message":"success","voice_id":"abc","final":1}""" - val final = extractJsonInt(json, "final") - assertEquals(1, final?.toInt()) + val final = JSONObject(json).optInt("final", 0) + assertEquals(1, final) } @Test fun responseJsonParsingExtractsResultWithSliceType() { - val json = """{"code":0,"result":{"slice_type":2,"voice_text_str":"腾讯云语音识别测试欢迎您。"}}""" - val resultRaw = extractJsonStrRaw(json, "result") - assertNotNull(resultRaw) - val sliceType = extractJsonInt(resultRaw!!, "slice_type") - val voiceText = extractJsonStr(resultRaw, "voice_text_str") - assertEquals(2, sliceType?.toInt()) - assertEquals("腾讯云语音识别测试欢迎您。", voiceText) + val json = """{"code":0,"result":{"slice_type":2,"voice_text_str":"hello world"}}""" + val resultObj = JSONObject(json).optJSONObject("result") + assertNotNull(resultObj) + val sliceType = resultObj!!.optInt("slice_type", -1) + val voiceText = resultObj.optString("voice_text_str", "") + assertEquals(2, sliceType) + assertEquals("hello world", voiceText) } @Test fun responseJsonHandlesMissingFieldsGracefully() { val json = """{"code":0,"message":"success"}""" - val resultRaw = extractJsonStrRaw(json, "result") - assertEquals(null, resultRaw) - val final = extractJsonInt(json, "final") - assertEquals(null, final) + val resultObj = JSONObject(json).optJSONObject("result") + assertEquals(null, resultObj) + val final = JSONObject(json).optInt("final", 0) + assertEquals(0, final) } } From e582ad2f1a3f84f82e84b9d79d4f044054bdf9d8 Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:42:09 +0800 Subject: [PATCH 3/8] style: move Tencent entries to end of all enums and when-chains As the newest provider, Tencent should consistently appear last in every enum, when-chain, data class, list, and conditional branch. This avoids scattered insertion points and follows the convention. --- .../asr/AsrDirectMicrophoneEngineFactory.kt | 14 ++++----- .../asrkb/asr/AsrEngineModeResolver.kt | 6 ++-- .../asrkb/asr/AsrFileRecognizerFactory.kt | 2 +- .../asrkb/asr/AsrPushPcmEngineFactory.kt | 10 +++---- .../java/com/brycewg/asrkb/store/Prefs.kt | 29 ++++++++---------- .../asrkb/store/PrefsAsrVendorFields.kt | 19 ++++++------ .../ui/settings/asr/AsrSettingsViewModel.kt | 12 ++++---- .../screens/AsrOnlineSettingsFields.kt | 12 ++++---- .../compose/screens/AsrSettingsScreen.kt | 30 +++++++++---------- .../asrkb/asr/AsrConstructionBaselineTest.kt | 8 ++--- .../AsrDirectMicrophoneEngineFactoryTest.kt | 2 +- .../asrkb/asr/AsrFileRecognizerFactoryTest.kt | 4 +-- .../asrkb/asr/AsrPushPcmEngineFactoryTest.kt | 2 +- .../asr/CurrentAsrConstructionBaseline.kt | 20 ++++++------- 14 files changed, 84 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt index 754c3f49..48741573 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt @@ -70,10 +70,10 @@ internal enum class AsrDirectMicrophoneEngineConstructorKey( OpenAiRealtime("OpenAiRealtimeAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), DashscopeStream("DashscopeStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), SonioxStream("SonioxStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), - TencentStream("TencentStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), SenseVoicePseudoStream("SenseVoicePseudoStreamAsrEngine", AsrDirectMicrophoneEngineFamily.LocalPseudoStream), FireRedAsrPseudoStream("FireRedAsrPseudoStreamAsrEngine", AsrDirectMicrophoneEngineFamily.LocalPseudoStream), - XAsrStream("XAsrStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream) + XAsrStream("XAsrStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream), + TencentStream("TencentStreamAsrEngine", AsrDirectMicrophoneEngineFamily.Stream) } internal class AsrDirectMicrophoneEngineFactory( @@ -195,8 +195,8 @@ internal class AsrDirectMicrophoneEngineFactory( AsrVendor.OpenAI -> AsrDirectMicrophoneEngineConstructorKey.OpenAiRealtime AsrVendor.DashScope -> AsrDirectMicrophoneEngineConstructorKey.DashscopeStream AsrVendor.Soniox -> AsrDirectMicrophoneEngineConstructorKey.SonioxStream - AsrVendor.Tencent -> AsrDirectMicrophoneEngineConstructorKey.TencentStream AsrVendor.XAsr -> AsrDirectMicrophoneEngineConstructorKey.XAsrStream + AsrVendor.Tencent -> AsrDirectMicrophoneEngineConstructorKey.TencentStream else -> error("$vendor has no direct stream engine") } @@ -276,14 +276,14 @@ internal object RealAsrDirectMicrophoneEngineConstructorTable : AsrDirectMicroph DashscopeStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) AsrDirectMicrophoneEngineConstructorKey.SonioxStream -> SonioxStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) - AsrDirectMicrophoneEngineConstructorKey.TencentStream -> - TencentStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) AsrDirectMicrophoneEngineConstructorKey.SenseVoicePseudoStream -> SenseVoicePseudoStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, request.onRequestDuration) AsrDirectMicrophoneEngineConstructorKey.FireRedAsrPseudoStream -> FireRedAsrPseudoStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, request.onRequestDuration) AsrDirectMicrophoneEngineConstructorKey.XAsrStream -> XAsrStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) + AsrDirectMicrophoneEngineConstructorKey.TencentStream -> + TencentStreamAsrEngine(request.context, request.scope, request.prefs, request.listener) } } @@ -300,7 +300,7 @@ internal fun Prefs.asrEngineModePreferencesSnapshot(): AsrEngineModePreferences openAiStreamingEnabled = isOpenAiStreamingEffective(), dashScopeStreamingEnabled = isDashStreamingModelSelected(), sonioxStreamingEnabled = sonioxStreamingEnabled, - tencentStreamingEnabled = tencentStreamingEnabled, senseVoicePseudoStreamEnabled = svPseudoStreamEnabled, - fireRedPseudoStreamEnabled = frPseudoStreamEnabled + fireRedPseudoStreamEnabled = frPseudoStreamEnabled, + tencentStreamingEnabled = tencentStreamingEnabled ) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt index 560a7f88..a55787cb 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt @@ -8,9 +8,9 @@ internal data class AsrEngineModePreferences( val openAiStreamingEnabled: Boolean = false, val dashScopeStreamingEnabled: Boolean = false, val sonioxStreamingEnabled: Boolean = false, - val tencentStreamingEnabled: Boolean = false, val senseVoicePseudoStreamEnabled: Boolean = false, - val fireRedPseudoStreamEnabled: Boolean = false + val fireRedPseudoStreamEnabled: Boolean = false, + val tencentStreamingEnabled: Boolean = false ) internal enum class AsrEngineConstructionSource { @@ -202,8 +202,8 @@ internal object AsrEngineModeResolver { AsrVendor.OpenAI -> openAiStreamingEnabled AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled - AsrVendor.Tencent -> tencentStreamingEnabled AsrVendor.XAsr -> true + AsrVendor.Tencent -> tencentStreamingEnabled else -> false } diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt index b59b4199..c8acaa5a 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactory.kt @@ -63,13 +63,13 @@ internal fun fileRecognizerKeyFor( AsrVendor.StepAudio -> AsrFileRecognizerKey.StepAudioFile AsrVendor.Zhipu -> AsrFileRecognizerKey.ZhipuFile AsrVendor.Cohere -> AsrFileRecognizerKey.CohereFile - AsrVendor.Tencent -> AsrFileRecognizerKey.TencentFile AsrVendor.SenseVoice -> AsrFileRecognizerKey.SenseVoiceFile AsrVendor.FunAsrNano -> AsrFileRecognizerKey.FunAsrNanoFile AsrVendor.Qwen3Asr -> AsrFileRecognizerKey.Qwen3AsrFile AsrVendor.Parakeet -> AsrFileRecognizerKey.ParakeetFile AsrVendor.FireRedAsr -> AsrFileRecognizerKey.FireRedAsrFile AsrVendor.XAsr -> error("X-ASR has no file recognizer") + AsrVendor.Tencent -> AsrFileRecognizerKey.TencentFile } internal fun interface AsrFileRecognizerConstructorTable { diff --git a/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt b/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt index a8f09204..0cd22c19 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt @@ -60,10 +60,10 @@ internal enum class AsrPushPcmEngineConstructorKey( OpenAiRealtime("OpenAiRealtimeAsrEngine", AsrPushPcmEngineFamily.NativeStream), DashscopeStream("DashscopeStreamAsrEngine", AsrPushPcmEngineFamily.NativeStream), SonioxStream("SonioxStreamAsrEngine", AsrPushPcmEngineFamily.NativeStream), - TencentStream("TencentStreamAsrEngine", AsrPushPcmEngineFamily.NativeStream), SenseVoicePushPcmPseudoStream("SenseVoicePushPcmPseudoStreamAsrEngine", AsrPushPcmEngineFamily.PseudoStream), FireRedAsrPushPcmPseudoStream("FireRedAsrPushPcmPseudoStreamAsrEngine", AsrPushPcmEngineFamily.PseudoStream), - XAsrStream("XAsrStreamAsrEngine", AsrPushPcmEngineFamily.LocalStream) + XAsrStream("XAsrStreamAsrEngine", AsrPushPcmEngineFamily.LocalStream), + TencentStream("TencentStreamAsrEngine", AsrPushPcmEngineFamily.NativeStream) } internal class AsrPushPcmEngineFactory( @@ -202,8 +202,8 @@ internal class AsrPushPcmEngineFactory( AsrVendor.OpenAI -> AsrPushPcmEngineConstructorKey.OpenAiRealtime AsrVendor.DashScope -> AsrPushPcmEngineConstructorKey.DashscopeStream AsrVendor.Soniox -> AsrPushPcmEngineConstructorKey.SonioxStream - AsrVendor.Tencent -> AsrPushPcmEngineConstructorKey.TencentStream AsrVendor.XAsr -> AsrPushPcmEngineConstructorKey.XAsrStream + AsrVendor.Tencent -> AsrPushPcmEngineConstructorKey.TencentStream else -> error("$vendor has no Push PCM native stream engine") } @@ -280,8 +280,6 @@ internal object RealAsrPushPcmEngineConstructorTable : AsrPushPcmEngineConstruct DashscopeStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) AsrPushPcmEngineConstructorKey.SonioxStream -> SonioxStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) - AsrPushPcmEngineConstructorKey.TencentStream -> - TencentStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) AsrPushPcmEngineConstructorKey.SenseVoicePushPcmPseudoStream -> SenseVoicePushPcmPseudoStreamAsrEngine( request.context, @@ -300,6 +298,8 @@ internal object RealAsrPushPcmEngineConstructorTable : AsrPushPcmEngineConstruct ) AsrPushPcmEngineConstructorKey.XAsrStream -> XAsrStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) + AsrPushPcmEngineConstructorKey.TencentStream -> + TencentStreamAsrEngine(request.context, request.scope, request.prefs, request.listener, externalPcmMode = true) } private fun createFileRecognizer( diff --git a/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt b/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt index 7aa13017..18aa4fdc 100644 --- a/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt +++ b/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt @@ -1177,18 +1177,6 @@ class Prefs(context: Context) { } } - // 腾讯云 ASR - var tencentAppId: String by stringPref(KEY_TENCENT_APP_ID, "") - var tencentSecretId: String by stringPref(KEY_TENCENT_SECRET_ID, "") - var tencentSecretKey: String by stringPref(KEY_TENCENT_SECRET_KEY, "") - var tencentEngineType: String by stringPref(KEY_TENCENT_ENGINE_TYPE, DEFAULT_TENCENT_ENGINE_TYPE) - var tencentStreamingEnabled: Boolean - get() = sp.getBoolean(KEY_TENCENT_STREAMING_ENABLED, false) - set(value) = sp.edit { putBoolean(KEY_TENCENT_STREAMING_ENABLED, value) } - var tencentVadEnabled: Boolean - get() = sp.getBoolean(KEY_TENCENT_VAD_ENABLED, false) - set(value) = sp.edit { putBoolean(KEY_TENCENT_VAD_ENABLED, value) } - // Google Gemini 语音理解(通过提示词转写) var gemEndpoint: String by stringPref(KEY_GEM_ENDPOINT, DEFAULT_GEM_ENDPOINT) @@ -1618,6 +1606,18 @@ class Prefs(context: Context) { } set(value) = sp.edit { putBoolean(KEY_X_ASR_USE_ITN, value) } + // 腾讯云 ASR + var tencentAppId: String by stringPref(KEY_TENCENT_APP_ID, "") + var tencentSecretId: String by stringPref(KEY_TENCENT_SECRET_ID, "") + var tencentSecretKey: String by stringPref(KEY_TENCENT_SECRET_KEY, "") + var tencentEngineType: String by stringPref(KEY_TENCENT_ENGINE_TYPE, DEFAULT_TENCENT_ENGINE_TYPE) + var tencentStreamingEnabled: Boolean + get() = sp.getBoolean(KEY_TENCENT_STREAMING_ENABLED, false) + set(value) = sp.edit { putBoolean(KEY_TENCENT_STREAMING_ENABLED, value) } + var tencentVadEnabled: Boolean + get() = sp.getBoolean(KEY_TENCENT_VAD_ENABLED, false) + set(value) = sp.edit { putBoolean(KEY_TENCENT_VAD_ENABLED, value) } + // Zipformer 模型清理标记(移除 Zipformer 支持后仅执行一次) var zipformerCleanupDone: Boolean get() = sp.getBoolean(KEY_ZIPFORMER_CLEANUP_DONE, false) @@ -1641,9 +1641,6 @@ class Prefs(context: Context) { if (v == AsrVendor.MiMo) { return mimoAsrApiKey.isNotBlank() && getEffectiveMimoAsrEndpoint().isNotBlank() } - if (v == AsrVendor.Tencent) { - return tencentAppId.isNotBlank() && tencentSecretId.isNotBlank() && tencentSecretKey.isNotBlank() - } if (v == AsrVendor.StepAudio) { return stepAudioApiKey.isNotBlank() && getEffectiveStepAudioAsrEndpoint().isNotBlank() } @@ -1685,11 +1682,11 @@ class Prefs(context: Context) { fun hasOpenRouterKeys(): Boolean = hasVendorKeys(AsrVendor.OpenRouter) fun hasGeminiKeys(): Boolean = hasVendorKeys(AsrVendor.Gemini) fun hasMiMoKeys(): Boolean = hasVendorKeys(AsrVendor.MiMo) - fun hasTencentKeys(): Boolean = hasVendorKeys(AsrVendor.Tencent) fun hasSonioxKeys(): Boolean = hasVendorKeys(AsrVendor.Soniox) fun hasStepAudioKeys(): Boolean = hasVendorKeys(AsrVendor.StepAudio) fun hasZhipuKeys(): Boolean = hasVendorKeys(AsrVendor.Zhipu) fun hasCohereKeys(): Boolean = hasVendorKeys(AsrVendor.Cohere) + fun hasTencentKeys(): Boolean = hasVendorKeys(AsrVendor.Tencent) fun hasAsrKeys(): Boolean = hasVendorKeys(asrVendor) fun hasLlmKeys(): Boolean { // 使用新的 getEffectiveLlmConfig 检查配置有效性 diff --git a/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt b/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt index 428eb54f..a5e22842 100644 --- a/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt +++ b/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt @@ -90,14 +90,6 @@ internal object PrefsAsrVendorFields { VendorField.prompt(KEY_MIMO_ASR_PROMPT, default = "请将以下音频准确转写为文字"), VendorField.boolean(KEY_MIMO_ASR_DISABLE_THINKING, default = false) ), - AsrVendor.Tencent to listOf( - VendorField.credential(KEY_TENCENT_APP_ID, required = false), - VendorField.credential(KEY_TENCENT_SECRET_ID, required = false), - VendorField.credential(KEY_TENCENT_SECRET_KEY, required = false), - VendorField.streamingToggle(KEY_TENCENT_STREAMING_ENABLED, default = false), - VendorField(KEY_TENCENT_ENGINE_TYPE, default = Prefs.DEFAULT_TENCENT_ENGINE_TYPE), - VendorField.boolean(KEY_TENCENT_VAD_ENABLED, default = false) - ), AsrVendor.Soniox to listOf( VendorField.credential(KEY_SONIOX_API_KEY), VendorField.streamingToggle(KEY_SONIOX_STREAMING_ENABLED, default = true), @@ -179,7 +171,16 @@ internal object PrefsAsrVendorFields { // 本地 FireRedASR(sherpa-onnx)无需鉴权 AsrVendor.FireRedAsr to emptyList(), // 本地 X-ASR(sherpa-onnx)无需鉴权 - AsrVendor.XAsr to emptyList() + AsrVendor.XAsr to emptyList(), + // 腾讯云 ASR + AsrVendor.Tencent to listOf( + VendorField.credential(KEY_TENCENT_APP_ID, required = false), + VendorField.credential(KEY_TENCENT_SECRET_ID, required = false), + VendorField.credential(KEY_TENCENT_SECRET_KEY, required = false), + VendorField.streamingToggle(KEY_TENCENT_STREAMING_ENABLED, default = false), + VendorField(KEY_TENCENT_ENGINE_TYPE, default = Prefs.DEFAULT_TENCENT_ENGINE_TYPE), + VendorField.boolean(KEY_TENCENT_VAD_ENABLED, default = false) + ) ) internal val backupFields: List = diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt index 0e1c4ea3..2bb31055 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt @@ -91,8 +91,6 @@ class AsrSettingsViewModel : ViewModel() { // Soniox settings sonioxStreamingEnabled = prefs.sonioxStreamingEnabled, sonioxLanguages = prefs.getSonioxLanguages(), - // Tencent settings - tencentStreamingEnabled = prefs.tencentStreamingEnabled, // SenseVoice settings svModelVariant = prefs.svModelVariant, svNumThreads = prefs.svNumThreads, @@ -132,7 +130,9 @@ class AsrSettingsViewModel : ViewModel() { xAsrNumThreads = prefs.xAsrNumThreads, xAsrKeepAliveMinutes = prefs.xAsrKeepAliveMinutes, xAsrPreloadEnabled = prefs.xAsrPreloadEnabled, - xAsrUseItn = prefs.xAsrUseItn + xAsrUseItn = prefs.xAsrUseItn, + // Tencent settings + tencentStreamingEnabled = prefs.tencentStreamingEnabled ) } @@ -970,8 +970,6 @@ data class AsrSettingsUiState( // Soniox settings val sonioxStreamingEnabled: Boolean = false, val sonioxLanguages: List = emptyList(), - // Tencent settings - val tencentStreamingEnabled: Boolean = false, // SenseVoice settings val svModelVariant: String = "small-int8", val svNumThreads: Int = 2, @@ -1011,7 +1009,9 @@ data class AsrSettingsUiState( val xAsrNumThreads: Int = 2, val xAsrKeepAliveMinutes: Int = -1, val xAsrPreloadEnabled: Boolean = true, - val xAsrUseItn: Boolean = false + val xAsrUseItn: Boolean = false, + // Tencent settings + val tencentStreamingEnabled: Boolean = false ) { // Computed visibility properties based on selected vendor val isVolcVisible: Boolean get() = selectedVendor == AsrVendor.Volc diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt index 19bdb5d6..3f41ffb9 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt @@ -70,12 +70,6 @@ internal class AsrOnlineSettingsFields( var mimoPrompt by mutableStateOf(prefs.mimoAsrPrompt) var mimoModel by mutableStateOf(prefs.mimoAsrModel) var mimoDisableThinking by mutableStateOf(prefs.mimoAsrDisableThinking) - var tencentAppId by mutableStateOf(prefs.tencentAppId) - var tencentSecretId by mutableStateOf(prefs.tencentSecretId) - var tencentSecretKey by mutableStateOf(prefs.tencentSecretKey) - var tencentEngineType by mutableStateOf(prefs.tencentEngineType.ifBlank { Prefs.DEFAULT_TENCENT_ENGINE_TYPE }) - var tencentStreaming by mutableStateOf(prefs.tencentStreamingEnabled) - var tencentVadEnabled by mutableStateOf(prefs.tencentVadEnabled) var openAiProviders by mutableStateOf(prefs.getOpenAiAsrProviders()) var openAiActiveProviderId by mutableStateOf(prefs.activeOpenAiAsrProviderId) var openAiProfileName by mutableStateOf(prefs.getActiveOpenAiAsrProvider()?.name.orEmpty()) @@ -92,6 +86,12 @@ internal class AsrOnlineSettingsFields( var sonioxEndpointSensitivityLevel by mutableStateOf(prefs.sonioxEndpointSensitivityLevel) var sonioxLanguages by mutableStateOf(prefs.getSonioxLanguages()) var sonioxLanguageStrict by mutableStateOf(prefs.sonioxLanguageHintsStrict) + var tencentAppId by mutableStateOf(prefs.tencentAppId) + var tencentSecretId by mutableStateOf(prefs.tencentSecretId) + var tencentSecretKey by mutableStateOf(prefs.tencentSecretKey) + var tencentEngineType by mutableStateOf(prefs.tencentEngineType.ifBlank { Prefs.DEFAULT_TENCENT_ENGINE_TYPE }) + var tencentStreaming by mutableStateOf(prefs.tencentStreamingEnabled) + var tencentVadEnabled by mutableStateOf(prefs.tencentVadEnabled) fun refreshFromPrefs() { volcAppKey = prefs.appKey diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt index 419ef63e..68b6b381 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt @@ -400,21 +400,6 @@ fun AsrSettingsScreen( ) } - fun applyTencentStreamingSwitch(target: Boolean) { - featureExplainerDialog = settingsFeatureExplainerDialogState( - context = context, - titleRes = R.string.label_tencent_streaming, - offDescRes = R.string.feature_tencent_streaming_off_desc, - onDescRes = R.string.feature_tencent_streaming_on_desc, - currentState = onlineFields.tencentStreaming, - preferenceKey = "tencent_streaming_explained", - onConfirm = { - onlineFields.tencentStreaming = target - viewModel.updateTencentStreaming(target) - } - ) - } - fun applyOpenAiStreamingSwitch(target: Boolean) { featureExplainerDialog = settingsFeatureExplainerDialogState( context = context, @@ -521,6 +506,21 @@ fun AsrSettingsScreen( ) } + fun applyTencentStreamingSwitch(target: Boolean) { + featureExplainerDialog = settingsFeatureExplainerDialogState( + context = context, + titleRes = R.string.label_tencent_streaming, + offDescRes = R.string.feature_tencent_streaming_off_desc, + onDescRes = R.string.feature_tencent_streaming_on_desc, + currentState = onlineFields.tencentStreaming, + preferenceKey = "tencent_streaming_explained", + onConfirm = { + onlineFields.tencentStreaming = target + viewModel.updateTencentStreaming(target) + } + ) + } + fun applyVolcSwitch( target: Boolean, titleResId: Int, diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt index 3fd5ea1d..b2a8a7c7 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt @@ -34,8 +34,6 @@ class AsrConstructionBaselineTest { OpenRouter[fileOnly] | app=File:OpenRouterFileAsrEngine | speech=File:OpenRouterFileAsrEngine | external=File:OpenRouterFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(OpenRouterFileAsrEngine) Gemini[fileOnly] | app=File:GeminiFileAsrEngine | speech=File:GeminiFileAsrEngine | external=File:GeminiFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(GeminiFileAsrEngine) MiMo[fileOnly] | app=File:MiMoFileAsrEngine | speech=File:MiMoFileAsrEngine | external=File:MiMoFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(MiMoFileAsrEngine) - Tencent[streaming=true] | app=Stream:TencentStreamAsrEngine | speech=Stream:TencentStreamAsrEngine | external=Stream:TencentStreamAsrEngine | externalPush=PushPcmStream:TencentStreamAsrEngine | recordingPush=PushPcmStream:TencentStreamAsrEngine | recordingMode=PushPcm | parallelDirect=PushPcmStream:TencentStreamAsrEngine | parallelPush=PushPcmStream:TencentStreamAsrEngine - Tencent[streaming=false] | app=File:TencentFileAsrEngine | speech=File:TencentFileAsrEngine | external=File:TencentFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) StepAudio[fileOnly] | app=File:StepAudioFileAsrEngine | speech=File:StepAudioFileAsrEngine | external=File:StepAudioFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(StepAudioFileAsrEngine) Zhipu[fileOnly] | app=File:ZhipuFileAsrEngine | speech=File:ZhipuFileAsrEngine | external=File:ZhipuFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(ZhipuFileAsrEngine) Cohere[fileOnly] | app=File:CohereFileAsrEngine | speech=File:CohereFileAsrEngine | external=File:CohereFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(CohereFileAsrEngine) @@ -47,6 +45,8 @@ class AsrConstructionBaselineTest { FireRedAsr[pseudoStream=false] | app=LocalFile:FireRedAsrFileAsrEngine | speech=LocalFile:FireRedAsrFileAsrEngine | external=LocalFile:FireRedAsrFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(FireRedAsrFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(FireRedAsrFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(FireRedAsrFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(FireRedAsrFileAsrEngine) FireRedAsr[pseudoStream=true] | app=LocalPseudoStream:FireRedAsrPseudoStreamAsrEngine | speech=LocalPseudoStream:FireRedAsrPseudoStreamAsrEngine | external=LocalFile:FireRedAsrFileAsrEngine | externalPush=PushPcmPseudoStream:FireRedAsrPushPcmPseudoStreamAsrEngine | recordingPush=PushPcmPseudoStream:FireRedAsrPushPcmPseudoStreamAsrEngine | recordingMode=PushPcm | parallelDirect=PushPcmPseudoStream:FireRedAsrPushPcmPseudoStreamAsrEngine | parallelPush=PushPcmPseudoStream:FireRedAsrPushPcmPseudoStreamAsrEngine XAsr[alwaysStreaming] | app=Stream:XAsrStreamAsrEngine | speech=Stream:XAsrStreamAsrEngine | external=Stream:XAsrStreamAsrEngine | externalPush=PushPcmStream:XAsrStreamAsrEngine | recordingPush=PushPcmStream:XAsrStreamAsrEngine | recordingMode=PushPcm | parallelDirect=PushPcmStream:XAsrStreamAsrEngine | parallelPush=PushPcmStream:XAsrStreamAsrEngine + Tencent[streaming=true] | app=Stream:TencentStreamAsrEngine | speech=Stream:TencentStreamAsrEngine | external=Stream:TencentStreamAsrEngine | externalPush=PushPcmStream:TencentStreamAsrEngine | recordingPush=PushPcmStream:TencentStreamAsrEngine | recordingMode=PushPcm | parallelDirect=PushPcmStream:TencentStreamAsrEngine | parallelPush=PushPcmStream:TencentStreamAsrEngine + Tencent[streaming=false] | app=File:TencentFileAsrEngine | speech=File:TencentFileAsrEngine | external=File:TencentFileAsrEngine | externalPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | recordingPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | recordingMode=File | parallelDirect=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) | parallelPush=PushPcmAdapter:GenericPushFileAsrAdapter(TencentFileAsrEngine) """.trimIndent() assertEquals(expected, renderGoldenMatrix()) @@ -533,7 +533,6 @@ class AsrConstructionBaselineTest { fileOnlyRow(AsrVendor.OpenRouter), fileOnlyRow(AsrVendor.Gemini), fileOnlyRow(AsrVendor.MiMo), - streamingRows(AsrVendor.Tencent), fileOnlyRow(AsrVendor.StepAudio), fileOnlyRow(AsrVendor.Zhipu), fileOnlyRow(AsrVendor.Cohere), @@ -548,7 +547,8 @@ class AsrConstructionBaselineTest { "alwaysStreaming", CurrentAsrConstructionSettings(streamingEnabled = true) ) - ) + ), + streamingRows(AsrVendor.Tencent) ).flatten() private fun streamingRows(vendor: AsrVendor): List = listOf( diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt index bc17924f..e9c7c517 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactoryTest.kt @@ -289,8 +289,8 @@ class AsrDirectMicrophoneEngineFactoryTest { AsrVendor.OpenAI -> openAiStreamingEnabled AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled - AsrVendor.Tencent -> tencentStreamingEnabled AsrVendor.XAsr -> true + AsrVendor.Tencent -> tencentStreamingEnabled else -> false }, volcStandardFileEnabled = volcStandardFileEnabled, diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt index 214c380d..0c6684b1 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrFileRecognizerFactoryTest.kt @@ -237,8 +237,8 @@ class AsrFileRecognizerFactoryTest { AsrEngineModePreferences(fireRedPseudoStreamEnabled = true), CurrentAsrConstructionSettings(pseudoStreamEnabled = true) ), - fileCase(AsrVendor.Tencent), - streamingCase(AsrVendor.XAsr, AsrEngineModePreferences()) + streamingCase(AsrVendor.XAsr, AsrEngineModePreferences()), + fileCase(AsrVendor.Tencent) ) private fun streamingCase( diff --git a/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt b/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt index 0ec81586..99608396 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactoryTest.kt @@ -385,8 +385,8 @@ class AsrPushPcmEngineFactoryTest { AsrVendor.OpenAI -> openAiStreamingEnabled AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled - AsrVendor.Tencent -> tencentStreamingEnabled AsrVendor.XAsr -> true + AsrVendor.Tencent -> tencentStreamingEnabled else -> false }, volcStandardFileEnabled = volcStandardFileEnabled, diff --git a/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt b/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt index 1af52237..aad144be 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt @@ -131,11 +131,6 @@ internal object CurrentAsrConstructionBaseline { AsrVendor.StepAudio -> file("StepAudioFileAsrEngine") AsrVendor.Zhipu -> file("ZhipuFileAsrEngine") AsrVendor.Cohere -> file("CohereFileAsrEngine") - AsrVendor.Tencent -> if (settings.streamingEnabled) { - stream("TencentStreamAsrEngine") - } else { - file("TencentFileAsrEngine") - } AsrVendor.SenseVoice -> if (settings.pseudoStreamEnabled) { localPseudo("SenseVoicePseudoStreamAsrEngine") } else { @@ -150,6 +145,11 @@ internal object CurrentAsrConstructionBaseline { localFile("FireRedAsrFileAsrEngine") } AsrVendor.XAsr -> stream("XAsrStreamAsrEngine") + AsrVendor.Tencent -> if (settings.streamingEnabled) { + stream("TencentStreamAsrEngine") + } else { + file("TencentFileAsrEngine") + } } private fun describeExternalDirect( @@ -204,11 +204,6 @@ internal object CurrentAsrConstructionBaseline { AsrVendor.StepAudio -> pushAdapter("StepAudioFileAsrEngine") AsrVendor.Zhipu -> pushAdapter("ZhipuFileAsrEngine") AsrVendor.Cohere -> pushAdapter("CohereFileAsrEngine") - AsrVendor.Tencent -> if (settings.streamingEnabled) { - pushStream("TencentStreamAsrEngine") - } else { - pushAdapter("TencentFileAsrEngine") - } AsrVendor.SenseVoice -> if (settings.pseudoStreamEnabled) { use(CurrentAsrEngineFamily.PushPcmPseudoStream, "SenseVoicePushPcmPseudoStreamAsrEngine") } else { @@ -223,6 +218,11 @@ internal object CurrentAsrConstructionBaseline { pushAdapter("FireRedAsrFileAsrEngine") } AsrVendor.XAsr -> pushStream("XAsrStreamAsrEngine") + AsrVendor.Tencent -> if (settings.streamingEnabled) { + pushStream("TencentStreamAsrEngine") + } else { + pushAdapter("TencentFileAsrEngine") + } } private fun isRecordingTestPushPcmMode( From 00d67dcd800b28d8709bf81a495b6e422adc5d9d Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:44:23 +0800 Subject: [PATCH 4/8] feat: add VAD feature explainer dialog for Tencent ASR - Add feature_tencent_vad_on_desc/off_desc strings in 5 locales - Wire VAD switch through toRouteState parameter chain - Add applyTencentVadSwitch handler with feature explainer dialog --- .../compose/screens/AsrOnlineSettingsFields.kt | 4 ++-- .../compose/screens/AsrSettingsScreen.kt | 16 ++++++++++++++++ app/src/main/res/values-ar/strings_tencent.xml | 2 ++ app/src/main/res/values-ja/strings_tencent.xml | 2 ++ .../main/res/values-zh-rCN/strings_tencent.xml | 2 ++ .../main/res/values-zh-rTW/strings_tencent.xml | 2 ++ app/src/main/res/values/strings_tencent.xml | 2 ++ 7 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt index 3f41ffb9..197af59f 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt @@ -207,6 +207,7 @@ internal class AsrOnlineSettingsFields( applySonioxLanguageStrictSwitch: (Boolean) -> Unit, applyStepAudioUseItnSwitch: (Boolean) -> Unit, applyTencentStreamingSwitch: (Boolean) -> Unit, + applyTencentVadSwitch: (Boolean) -> Unit, openAiDefaultProfileName: (Int) -> String ): AsrOnlineSettingsRouteState = AsrOnlineSettingsRouteState( volcAppKey = volcAppKey, @@ -562,8 +563,7 @@ internal class AsrOnlineSettingsFields( }, tencentVadEnabled = tencentVadEnabled, onTencentVadEnabledChange = { checked -> - tencentVadEnabled = checked - prefs.tencentVadEnabled = checked + applyTencentVadSwitch(checked) }, ) } diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt index 68b6b381..2299e073 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt @@ -521,6 +521,21 @@ fun AsrSettingsScreen( ) } + fun applyTencentVadSwitch(target: Boolean) { + featureExplainerDialog = settingsFeatureExplainerDialogState( + context = context, + titleRes = R.string.label_tencent_vad_enabled, + offDescRes = R.string.feature_tencent_vad_off_desc, + onDescRes = R.string.feature_tencent_vad_on_desc, + currentState = onlineFields.tencentVadEnabled, + preferenceKey = "tencent_vad_explained", + onConfirm = { + onlineFields.tencentVadEnabled = target + prefs.tencentVadEnabled = target + } + ) + } + fun applyVolcSwitch( target: Boolean, titleResId: Int, @@ -678,6 +693,7 @@ fun AsrSettingsScreen( applySonioxLanguageStrictSwitch = ::applySonioxLanguageStrictSwitch, applyStepAudioUseItnSwitch = ::applyStepAudioUseItnSwitch, applyTencentStreamingSwitch = ::applyTencentStreamingSwitch, + applyTencentVadSwitch = ::applyTencentVadSwitch, openAiDefaultProfileName = { index -> context.getString(R.string.openai_profile_default_name, index) } diff --git a/app/src/main/res/values-ar/strings_tencent.xml b/app/src/main/res/values-ar/strings_tencent.xml index 6fb771d1..b242b3ab 100644 --- a/app/src/main/res/values-ar/strings_tencent.xml +++ b/app/src/main/res/values-ar/strings_tencent.xml @@ -37,5 +37,7 @@ 16k_zh-PY 中英粤 16k_zh_dialect 多方言(23种) فصل الجمل تلقائيًا + الخادم لا يقسم عند الصمت. يتم التقسيم القسري بعد 60 ثانية. + الخادم يقسم عند الصمت. كل توقف يؤدي إلى قطع الجملة. لم يتم ضبط بيانات اعتماد Tencent Cloud diff --git a/app/src/main/res/values-ja/strings_tencent.xml b/app/src/main/res/values-ja/strings_tencent.xml index 7001b50f..c223b708 100644 --- a/app/src/main/res/values-ja/strings_tencent.xml +++ b/app/src/main/res/values-ja/strings_tencent.xml @@ -37,5 +37,7 @@ 16k_zh-PY 中英粤 16k_zh_dialect 多方言(23种) 自動文区切り + サーバーは無音で区切らない。60秒超で強制分割。 + サーバーが無音を検出して自動で区切る。発話の区切りごとに分割。 Tencent Cloud の認証情報が未設定です diff --git a/app/src/main/res/values-zh-rCN/strings_tencent.xml b/app/src/main/res/values-zh-rCN/strings_tencent.xml index 7ded2e86..bb622259 100644 --- a/app/src/main/res/values-zh-rCN/strings_tencent.xml +++ b/app/src/main/res/values-zh-rCN/strings_tencent.xml @@ -37,5 +37,7 @@ 16k_zh-PY 中英粤 16k_zh_dialect 多方言(23种) 自动断句 + 服务端不按静音断句,超过60秒会被强制切分。 + 服务端检测到静音时自动断句,一句话结束即切分。 未配置腾讯云密钥 diff --git a/app/src/main/res/values-zh-rTW/strings_tencent.xml b/app/src/main/res/values-zh-rTW/strings_tencent.xml index f92b2c78..d8c9dc35 100644 --- a/app/src/main/res/values-zh-rTW/strings_tencent.xml +++ b/app/src/main/res/values-zh-rTW/strings_tencent.xml @@ -37,5 +37,7 @@ 16k_zh-PY 中英粵 16k_zh_dialect 多方言(23種) 自動斷句 + 服務端不按靜音斷句,超過60秒會被強制切分。 + 服務端偵測到靜音時自動斷句,一句話結束即切分。 未配置騰訊雲金鑰 diff --git a/app/src/main/res/values/strings_tencent.xml b/app/src/main/res/values/strings_tencent.xml index 2ef44204..448d8eb4 100644 --- a/app/src/main/res/values/strings_tencent.xml +++ b/app/src/main/res/values/strings_tencent.xml @@ -37,5 +37,7 @@ 16k_zh-PY 中英粤 16k_zh_dialect 多方言(23种) Auto sentence break + Server does not split on silence. Audio exceeding 60s is force-split. + Server splits on silence. Each pause triggers a sentence break. Tencent Cloud credentials not set From 91dbca0486782b30f83fa6c3326e3d3721761435 Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:46:25 +0800 Subject: [PATCH 5/8] fix: rethrow CancellationException in BaseFileAsrEngine Without this, a CancellationException would be swallowed by the generic Throwable catch, breaking coroutine cancellation. --- app/src/main/java/com/brycewg/asrkb/asr/BaseFileAsrEngine.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/BaseFileAsrEngine.kt b/app/src/main/java/com/brycewg/asrkb/asr/BaseFileAsrEngine.kt index 64b63dc0..6ce0ac73 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/BaseFileAsrEngine.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/BaseFileAsrEngine.kt @@ -134,6 +134,8 @@ abstract class BaseFileAsrEngine( recognizeEncoded(seg.audio) } } + } catch (t: kotlinx.coroutines.CancellationException) { + throw t } catch (t: Throwable) { Log.e(TAG, "Recognition failed for segment", t) try { From 875352c286b75af08b9cfc42b00983a89f87a01f Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:46:31 +0800 Subject: [PATCH 6/8] refactor: use JSONObject for payload building in TencentFileAsrEngine Replace string interpolation with JSONObject to avoid injection issues from special characters in base64 audio. Also rethrow CancellationException and make buildJsonPayload internal for test access. --- .../com/brycewg/asrkb/asr/TencentFileAsrEngine.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt b/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt index 5c44446a..02ea4251 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt @@ -127,6 +127,8 @@ class TencentFileAsrEngine( listener.onError(context.getString(R.string.error_asr_empty_result)) } } + } catch (t: kotlinx.coroutines.CancellationException) { + throw t } catch (t: Throwable) { listener.onError( context.getString(R.string.error_recognize_failed_with_reason, t.message ?: "") @@ -158,11 +160,17 @@ class TencentFileAsrEngine( override suspend fun recognizeFromPcm(pcm: ByteArray): Unit = recognize(pcm) - private fun buildJsonPayload( + internal fun buildJsonPayload( engineType: String, base64Audio: String, dataLen: Int - ): String = """{"EngSerViceType":"$engineType","SourceType":1,"VoiceFormat":"wav","Data":"$base64Audio","DataLen":$dataLen}""" + ): String = JSONObject().apply { + put("EngSerViceType", engineType) + put("SourceType", 1) + put("VoiceFormat", "wav") + put("Data", base64Audio) + put("DataLen", dataLen) + }.toString() private fun wavHeader(dataLen: Int): ByteArray { val sampleRate = 16000 From 52cd9b728568e717b0a463b95909d615f1b45a0b Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:46:36 +0800 Subject: [PATCH 7/8] fix: fix race condition in enqueuePcm and missing wssJob cleanup - enqueuePcm had a TOCTOU race: wsReady was checked outside the lock - wssJob was never cancelled in cancelAll() - clientOwned was dead code after removing dispatcher shutdown - Add log warning for mismatched sampleRate/channels in appendPcm --- .../asrkb/asr/TencentStreamAsrEngine.kt | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt b/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt index adaf5a00..cb0b88b7 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt @@ -50,7 +50,6 @@ class TencentStreamAsrEngine( private val wsReady = AtomicBoolean(false) private var currentVoiceId: String? = null - private val clientOwned: Boolean = httpClient == null private val client: OkHttpClient = httpClient ?: OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) .build() @@ -90,6 +89,8 @@ class TencentStreamAsrEngine( wsReady.set(false) audioJob?.cancel() audioJob = null + wssJob?.cancel() + wssJob = null try { audioChan?.close() } catch (_: Throwable) { } } @@ -173,7 +174,10 @@ class TencentStreamAsrEngine( override fun appendPcm(pcm: ByteArray, sampleRate: Int, channels: Int) { if (!running.get()) return - if (sampleRate != this.sampleRate || channels != 1) return + if (sampleRate != this.sampleRate || channels != 1) { + Log.w(TAG, "ignore frame: sr=$sampleRate ch=$channels") + return + } val leveled = externalVadInputLeveler.process(pcm) try { listener.onAmplitude(leveled.stableAmplitude) @@ -186,22 +190,20 @@ class TencentStreamAsrEngine( * 就绪后先冲刷预缓冲再发送当前帧(麦克风采集与外部推流共用)。 */ private fun enqueuePcm(chunk: ByteArray) { - if (!wsReady.get()) { - synchronized(prebufferLock) { + var flushed: Array? = null + synchronized(prebufferLock) { + if (!wsReady.get()) { prebuffer.addLast(chunk) while (prebuffer.size > maxPrebufferFrames) prebuffer.removeFirst() + return } - } else { - var flushed: Array? = null - synchronized(prebufferLock) { - if (prebuffer.isNotEmpty()) { - flushed = prebuffer.toTypedArray() - prebuffer.clear() - } + if (prebuffer.isNotEmpty()) { + flushed = prebuffer.toTypedArray() + prebuffer.clear() } - flushed?.forEach { b -> audioChan?.trySend(b) } - audioChan?.trySend(chunk) } + flushed?.forEach { b -> audioChan?.trySend(b) } + audioChan?.trySend(chunk) } fun buildWsUrl(): String? { @@ -382,9 +384,6 @@ class TencentStreamAsrEngine( try { ws.close(1000, "client close") - if (clientOwned) { - client.dispatcher.executorService.shutdown() - } } catch (_: Exception) { } if (!finalTextDelivered.getAndSet(true)) { From 6f4886f27c9adceaff8a51ef8436006e943cac2a Mon Sep 17 00:00:00 2001 From: jiesou <84175239+jiesou@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:51:09 +0800 Subject: [PATCH 8/8] feat: add tencentVadEnabled to AsrSettingsUiState --- .../asrkb/ui/settings/asr/AsrSettingsViewModel.kt | 11 +++++++++-- .../ui/settings/compose/screens/AsrSettingsScreen.kt | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt index 2bb31055..39f79683 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/asr/AsrSettingsViewModel.kt @@ -132,7 +132,8 @@ class AsrSettingsViewModel : ViewModel() { xAsrPreloadEnabled = prefs.xAsrPreloadEnabled, xAsrUseItn = prefs.xAsrUseItn, // Tencent settings - tencentStreamingEnabled = prefs.tencentStreamingEnabled + tencentStreamingEnabled = prefs.tencentStreamingEnabled, + tencentVadEnabled = prefs.tencentVadEnabled ) } @@ -386,6 +387,11 @@ class AsrSettingsViewModel : ViewModel() { _uiState.value = _uiState.value.copy(tencentStreamingEnabled = enabled) } + fun updateTencentVadEnabled(enabled: Boolean) { + prefs.tencentVadEnabled = enabled + _uiState.value = _uiState.value.copy(tencentVadEnabled = enabled) + } + fun updateSonioxLanguages(languages: List) { prefs.setSonioxLanguages(languages) _uiState.value = _uiState.value.copy(sonioxLanguages = languages) @@ -1011,7 +1017,8 @@ data class AsrSettingsUiState( val xAsrPreloadEnabled: Boolean = true, val xAsrUseItn: Boolean = false, // Tencent settings - val tencentStreamingEnabled: Boolean = false + val tencentStreamingEnabled: Boolean = false, + val tencentVadEnabled: Boolean = false ) { // Computed visibility properties based on selected vendor val isVolcVisible: Boolean get() = selectedVendor == AsrVendor.Volc diff --git a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt index 2299e073..273ca667 100644 --- a/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt +++ b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrSettingsScreen.kt @@ -531,7 +531,7 @@ fun AsrSettingsScreen( preferenceKey = "tencent_vad_explained", onConfirm = { onlineFields.tencentVadEnabled = target - prefs.tencentVadEnabled = target + viewModel.updateTencentVadEnabled(target) } ) }