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..48741573 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrDirectMicrophoneEngineFactory.kt @@ -72,7 +72,8 @@ internal enum class AsrDirectMicrophoneEngineConstructorKey( SonioxStream("SonioxStreamAsrEngine", 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,6 +196,7 @@ internal class AsrDirectMicrophoneEngineFactory( AsrVendor.DashScope -> AsrDirectMicrophoneEngineConstructorKey.DashscopeStream AsrVendor.Soniox -> AsrDirectMicrophoneEngineConstructorKey.SonioxStream AsrVendor.XAsr -> AsrDirectMicrophoneEngineConstructorKey.XAsrStream + AsrVendor.Tencent -> AsrDirectMicrophoneEngineConstructorKey.TencentStream else -> error("$vendor has no direct stream engine") } @@ -280,6 +282,8 @@ internal object RealAsrDirectMicrophoneEngineConstructorTable : AsrDirectMicroph 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) } } @@ -297,5 +301,6 @@ internal fun Prefs.asrEngineModePreferencesSnapshot(): AsrEngineModePreferences dashScopeStreamingEnabled = isDashStreamingModelSelected(), sonioxStreamingEnabled = sonioxStreamingEnabled, 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 42f162ff..a55787cb 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrEngineModeResolver.kt @@ -9,7 +9,8 @@ internal data class AsrEngineModePreferences( val dashScopeStreamingEnabled: Boolean = false, val sonioxStreamingEnabled: Boolean = false, val senseVoicePseudoStreamEnabled: Boolean = false, - val fireRedPseudoStreamEnabled: Boolean = false + val fireRedPseudoStreamEnabled: Boolean = false, + val tencentStreamingEnabled: Boolean = false ) internal enum class AsrEngineConstructionSource { @@ -202,6 +203,7 @@ internal object AsrEngineModeResolver { AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled 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 41afda11..c8acaa5a 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( @@ -68,6 +69,7 @@ internal fun fileRecognizerKeyFor( 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 { @@ -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..0cd22c19 100644 --- a/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt +++ b/app/src/main/java/com/brycewg/asrkb/asr/AsrPushPcmEngineFactory.kt @@ -62,7 +62,8 @@ internal enum class AsrPushPcmEngineConstructorKey( SonioxStream("SonioxStreamAsrEngine", 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,6 +203,7 @@ internal class AsrPushPcmEngineFactory( AsrVendor.DashScope -> AsrPushPcmEngineConstructorKey.DashscopeStream AsrVendor.Soniox -> AsrPushPcmEngineConstructorKey.SonioxStream AsrVendor.XAsr -> AsrPushPcmEngineConstructorKey.XAsrStream + AsrVendor.Tencent -> AsrPushPcmEngineConstructorKey.TencentStream else -> error("$vendor has no Push PCM native stream engine") } @@ -296,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/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/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 { 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..02ea4251 --- /dev/null +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentFileAsrEngine.kt @@ -0,0 +1,235 @@ +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 + +/** + * https://cloud.tencent.com/document/product/1093/35646 + */ +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: kotlinx.coroutines.CancellationException) { + throw t + } 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) + + internal fun buildJsonPayload( + engineType: String, + base64Audio: String, + dataLen: Int + ): 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 + 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)) +} + 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..cb0b88b7 --- /dev/null +++ b/app/src/main/java/com/brycewg/asrkb/asr/TencentStreamAsrEngine.kt @@ -0,0 +1,411 @@ +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 + +/** + * https://cloud.tencent.com/document/product/1093/48982 + */ +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 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 + wssJob?.cancel() + wssJob = 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) { + Log.w(TAG, "ignore frame: sr=$sampleRate ch=$channels") + return + } + val leveled = externalVadInputLeveler.process(pcm) + try { + listener.onAmplitude(leveled.stableAmplitude) + } catch (_: Throwable) { } + enqueuePcm(pcm.copyOf()) + } + + /** + * 将一帧 PCM 送入发送队列;WebSocket 未就绪时先进入预缓冲, + * 就绪后先冲刷预缓冲再发送当前帧(麦克风采集与外部推流共用)。 + */ + private fun enqueuePcm(chunk: ByteArray) { + var flushed: Array? = null + synchronized(prebufferLock) { + if (!wsReady.get()) { + prebuffer.addLast(chunk) + while (prebuffer.size > maxPrebufferFrames) prebuffer.removeFirst() + return + } + 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 obj = org.json.JSONObject(text) + val code = obj.optInt("code", 0) + if (code != 0) { + val msg = obj.optString("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 = obj.optInt("final") == 1 + val resultObj = obj.optJSONObject("result") + + 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) + 2 -> if (voiceText.isNotEmpty()) { + listener.onPartial(voiceText) + accumulatedText.append(voiceText) + } + } + } + + if (isFinal) { + if (resultObj != null) { + val finalText = resultObj.optString("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") + } 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..18aa4fdc 100644 --- a/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt +++ b/app/src/main/java/com/brycewg/asrkb/store/Prefs.kt @@ -1606,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) @@ -1674,6 +1686,7 @@ class Prefs(context: Context) { 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 检查配置有效性 @@ -2091,6 +2104,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..a5e22842 100644 --- a/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt +++ b/app/src/main/java/com/brycewg/asrkb/store/PrefsAsrVendorFields.kt @@ -171,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/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..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 @@ -130,7 +130,10 @@ class AsrSettingsViewModel : ViewModel() { xAsrNumThreads = prefs.xAsrNumThreads, xAsrKeepAliveMinutes = prefs.xAsrKeepAliveMinutes, xAsrPreloadEnabled = prefs.xAsrPreloadEnabled, - xAsrUseItn = prefs.xAsrUseItn + xAsrUseItn = prefs.xAsrUseItn, + // Tencent settings + tencentStreamingEnabled = prefs.tencentStreamingEnabled, + tencentVadEnabled = prefs.tencentVadEnabled ) } @@ -379,6 +382,16 @@ 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 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) @@ -1002,7 +1015,10 @@ 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, + 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/AsrOnlineSettingsFields.kt b/app/src/main/java/com/brycewg/asrkb/ui/settings/compose/screens/AsrOnlineSettingsFields.kt index 330f7360..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 @@ -86,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 @@ -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,8 @@ internal class AsrOnlineSettingsFields( applySonioxStreamingSwitch: (Boolean) -> Unit, applySonioxLanguageStrictSwitch: (Boolean) -> Unit, applyStepAudioUseItnSwitch: (Boolean) -> Unit, + applyTencentStreamingSwitch: (Boolean) -> Unit, + applyTencentVadSwitch: (Boolean) -> Unit, openAiDefaultProfileName: (Int) -> String ): AsrOnlineSettingsRouteState = AsrOnlineSettingsRouteState( volcAppKey = volcAppKey, @@ -522,7 +536,35 @@ 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 -> + applyTencentVadSwitch(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..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 @@ -506,6 +506,36 @@ 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 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 + viewModel.updateTencentVadEnabled(target) + } + ) + } + fun applyVolcSwitch( target: Boolean, titleResId: Int, @@ -662,6 +692,8 @@ fun AsrSettingsScreen( applySonioxStreamingSwitch = ::applySonioxStreamingSwitch, 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 new file mode 100644 index 00000000..b242b3ab --- /dev/null +++ b/app/src/main/res/values-ar/strings_tencent.xml @@ -0,0 +1,43 @@ + + + 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种) + فصل الجمل تلقائيًا + الخادم لا يقسم عند الصمت. يتم التقسيم القسري بعد 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 new file mode 100644 index 00000000..c223b708 --- /dev/null +++ b/app/src/main/res/values-ja/strings_tencent.xml @@ -0,0 +1,43 @@ + + + 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种) + 自動文区切り + サーバーは無音で区切らない。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 new file mode 100644 index 00000000..bb622259 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings_tencent.xml @@ -0,0 +1,43 @@ + + + 腾讯云 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种) + 自动断句 + 服务端不按静音断句,超过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 new file mode 100644 index 00000000..d8c9dc35 --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings_tencent.xml @@ -0,0 +1,43 @@ + + + 騰訊雲 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種) + 自動斷句 + 服務端不按靜音斷句,超過60秒會被強制切分。 + 服務端偵測到靜音時自動斷句,一句話結束即切分。 + 未配置騰訊雲金鑰 + 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..448d8eb4 --- /dev/null +++ b/app/src/main/res/values/strings_tencent.xml @@ -0,0 +1,43 @@ + + + 腾讯云 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 + 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 + 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..b2a8a7c7 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/AsrConstructionBaselineTest.kt @@ -45,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()) @@ -545,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 cb84e611..e9c7c517 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) ) @@ -289,6 +290,7 @@ class AsrDirectMicrophoneEngineFactoryTest { AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled AsrVendor.XAsr -> true + AsrVendor.Tencent -> tencentStreamingEnabled else -> false }, volcStandardFileEnabled = volcStandardFileEnabled, @@ -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..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,7 +237,8 @@ class AsrFileRecognizerFactoryTest { AsrEngineModePreferences(fireRedPseudoStreamEnabled = true), CurrentAsrConstructionSettings(pseudoStreamEnabled = true) ), - 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 6b1af338..99608396 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) ) @@ -385,6 +386,7 @@ class AsrPushPcmEngineFactoryTest { AsrVendor.DashScope -> dashScopeStreamingEnabled AsrVendor.Soniox -> sonioxStreamingEnabled AsrVendor.XAsr -> true + AsrVendor.Tencent -> tencentStreamingEnabled else -> false }, volcStandardFileEnabled = volcStandardFileEnabled, @@ -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..aad144be 100644 --- a/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt +++ b/app/src/test/java/com/brycewg/asrkb/asr/CurrentAsrConstructionBaseline.kt @@ -145,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( @@ -213,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( @@ -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..5e6f8858 --- /dev/null +++ b/app/src/test/java/com/brycewg/asrkb/asr/TencentStreamAsrEngineTest.kt @@ -0,0 +1,135 @@ +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 +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 = JSONObject(json).optInt("code", -1) + assertEquals(0, code) + } + + @Test + fun responseJsonParsingExtractsErrorCode() { + val json = """{"code":4002,"message":"auth failed"}""" + 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 = JSONObject(json).optInt("final", 0) + assertEquals(1, final) + } + + @Test + fun responseJsonParsingExtractsResultWithSliceType() { + 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 resultObj = JSONObject(json).optJSONObject("result") + assertEquals(null, resultObj) + val final = JSONObject(json).optInt("final", 0) + assertEquals(0, final) + } +}