From 504c500e2032599071d174088681a87e840779f3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 23:20:27 +0800 Subject: [PATCH] =?UTF-8?q?W75=20=E5=A5=B9=E7=9A=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=9C=97=E8=AF=BB=20=E2=80=94=E2=80=94=20/audio/speech=20?= =?UTF-8?q?=E5=8F=AA=E8=B5=B0=20owner=20Bearer=EF=BC=8C=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E5=97=85=E6=8E=A2=20WAV=EF=BC=8C=E7=BC=93=E5=AD=98=E8=90=BD=20?= =?UTF-8?q?media/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端回执三十八 §3 点名「独立真机 Bearer 样例待安卓首次接入时顺手确证」,本批次就是 这次接入:在她的消息气泡外侧加一枚「朗读」图标,点按 → 剥标签取纯文本 → POST /api/v1/audio/speech → 播放 WAV。只做手动点按,不做自动朗读 / 设置页开关 / 实时流。 ## 契约(Cyrene_v2/Docs/api-client.md,2246 行) - L126(§1.2 U6 表)、L1603(v0.97 补写)、L1609(v0.96 sessionId 增量)、 L561(416 非信封旁注,同句明说 /audio/speech 的 200 也不是信封)、 L2173(voice / format 不生效)。 - POST /api/v1/audio/speech,owner-only 双凭据门:Authorization: Bearer **或** X-API-Key,两分支同 authorize(RoleOwner);无 / 无效 → 401,错误形状同 §4.1 {error:{code,message}} 信封。 - 请求体 {text, voice?, format?, instruction?, sessionId?}。voice 接受但不生效、 format 不生效;发音人由 sessionId 的生效人格解析(会话绑定 → 全局默认 → 配置 default), **省略 sessionId 不等于没有角色**。 - 成功 200 + 原始音频字节(非信封),产物恒 WAV,**按字节嗅探,不要照抄 Content-Type**。 - 错误码:空 text → 400;500 voice_unavailable / tts_failed / tts_persona_unmapped / tts_no_character / tts_empty(后者带响应头 X-Voice-Provider: silent)。 ## 只走 Bearer,不加 API Key 输入框 主人 2026-10-26 拍板:本端 owner 已持有 JWT,再往设置里塞一个 API Key 输入框只是把 同一份凭据复制一遍、多一个会过期的状态。SpeechClient 的 tokenProvider 直接接 AuthManager 的当前 token,401 交给既有的 unauthorizedPolicy 收口。 ## 落盘 新增 data/api/SpeechApi.kt、data/voice/SpeechClient.kt、domain/chat/VoiceSpeak.kt、 ui/chat/VoiceSpeak.kt;改 ChatViewModel / MessageBubble / ChatScreen。AppContainer 未动 (SpeechClient 在 ViewModel 里 lazy 构造,baseUrl 与 token 都取自 container.authManager)。 - 缓存名 tts_.wav,落 VoiceStore.directory(cacheDir) 即 /media/,**不进 documents/** —— 系统可回收,丢掉重下一份即可。 代价:服务端换发音人后同一段文字会命中旧音频,按 (sessionId, text) 寻址是刻意的。 - 字节嗅探 isWavBytes:size ≥ 12 + RIFF@0 + WAVE@8。200 + JSON 信封(哪怕 Content-Type 谎报 audio/wav)判 SpeechNotAudioException,且不落缓存。 - 两档失败:TtsFailure.Unconfigured 只认 tts_persona_unmapped / tts_no_character 两个 code(引导去设人格),其余一律 Soft(可重试)。不认识的 code 不猜,落 Soft。 - 失败只写 ttsClip.failure 气泡脚注,**不写 state.error 横幅** —— 朗读失败不该盖住聊天。 - 播放交给唯一的播放通道(ui.chat.VoicePlayer),键 tts: 与语音副本的键 分得开 ⇒ 「念这一条」与「放这条消息的语音副本」天然互斥,同一时刻只有一个声音。 - 起播在渲染层那个 LaunchedEffect(ttsClip.clipId, file, autoPlay):三键缺一不可 —— 「放 → 停 → 再点同一条」时 (clipId, file) 一对没变,只有 autoPlay 由假改真才能再点着。 ## 状态机抽成纯函数(可测) VM 拖不进 JVM 测试(要 AppContainer / viewModelScope,工程里没有 coroutines-test), 所以这条路的判据都落在两个纯函数上,VM 只负责循环 CAS + launch: - ChatUiState.beginTtsSpeak(messageId, clipId):返回 null = 这一次点击该被忽略 (同一条正在飞)。已落好的(file != null)算「再放一遍」、失败过的算「用户重试」, 两种都放行 —— 防抖判据只认 pending 这一种。 - ChatUiState.applyTtsOutcome(messageId, clipId, result):clipId 已经不是这一格 ⇒ 原样返回、一个字节都不写(否则「点了 B,界面在 A 下面报错」)。 ## 验证 ./gradlew.bat --offline compileDebugKotlin → BUILD SUCCESSFUL ./gradlew.bat --offline assembleDebug → BUILD SUCCESSFUL(38 tasks) ./gradlew.bat --offline testDebugUnitTest --rerun → BUILD SUCCESSFUL 138 suites / 1715 tests,0 failures / 0 errors / 0 skipped (基线 134 / 1675;+4 suites +40 tests 全来自新增的 SpeechApiTest 4 / SpeechClientTest 8 / VoiceSpeakTest 16 / TtsSpeakStateTest 11) SpeechApiTest 与 SpeechClientTest 都跑真 com.sun.net.httpserver.HttpServer + 真 OkHttp/Retrofit,不是 mock;SpeechClientTest 用 Files.createTempDirectory 验真落盘与 第二次同文本请求计数仍为 1。 未验:真机出声。已知取舍——请求在飞时图标 clickable(enabled = !speakPending) 点不动, 没有做「取消在飞请求」。 --- .../top/yeij/cyrene/data/api/SpeechApi.kt | 67 ++++++ .../yeij/cyrene/data/voice/SpeechClient.kt | 208 +++++++++++++++++ .../top/yeij/cyrene/domain/chat/VoiceSpeak.kt | 167 +++++++++++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 28 +++ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 130 +++++++++++ .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 78 ++++++- .../top/yeij/cyrene/ui/chat/VoiceSpeak.kt | 50 ++++ .../top/yeij/cyrene/data/api/SpeechApiTest.kt | 156 +++++++++++++ .../cyrene/data/voice/SpeechClientTest.kt | 220 ++++++++++++++++++ .../yeij/cyrene/domain/chat/VoiceSpeakTest.kt | 178 ++++++++++++++ .../yeij/cyrene/ui/chat/TtsSpeakStateTest.kt | 192 +++++++++++++++ 11 files changed, 1473 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/SpeechApi.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/voice/SpeechClient.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeak.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceSpeak.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/api/SpeechApiTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/voice/SpeechClientTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeakTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/TtsSpeakStateTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/SpeechApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/SpeechApi.kt new file mode 100644 index 0000000..370031d --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/SpeechApi.kt @@ -0,0 +1,67 @@ +package top.yeij.cyrene.data.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Streaming + +/** + * 一段待朗读的文本(`POST /api/v1/audio/speech` 的请求体,api-client.md §1.2 U6 / 行 126)。 + * + * ## 只有两个字段,是**刻意的** + * 契约给了五个键 `{text, voice?, format?, instruction?, sessionId?}`,但其中三个在本端**不生效**: + * - `voice`:接受但不生效 —— 发音人**不由它决定**; + * - `format`:不生效 —— 产物恒为 WAV(响应也明说「别照抄 `Content-Type`」,见 [SpeechApi]); + * - `instruction`:只影响表达方式,本端当前的产品口径里没有「怎么说」这一档输入。 + * + * 发一个服务端会忽略的键,只会让排查时多一条**看起来有含义、实际没有**的字段。所以这里只发 + * 真正起作用的那两个 —— 尤其是 [sessionId]:发音人由它**那条会话的生效人格**解析 + * (会话绑定人格 → 全局默认人格 → 都没有就取服务端配置的 default),**省略它不等于没有角色**。 + * + * [sessionId] 为 `null` 时字段**整个缺席**(`ApiClientFactory.json` 的 `explicitNulls = false`), + * 而不是发一个 `"sessionId": null`。 + */ +@Serializable +data class SpeechRequestDto( + @SerialName("text") val text: String, + @SerialName("sessionId") val sessionId: String? = null, +) + +/** + * 语音合成(契约 **v0.96 增量 / v0.97 补写**,api-client.md §1.2 **U6** `POST /api/v1/audio/speech`)。 + * + * ## 鉴权:owner-only,双凭据门 + * `Authorization: Bearer ` **或** `X-API-Key`,两个分支在服务端落到同一个 + * `authorize(RoleOwner)`。本端只走前者(主人 2026-10-26 拍板:不设 API Key 输入框),令牌由 + * [ApiClientFactory.create] 挂在 OkHttpClient 上的 `AuthInterceptor` 自动注入,本接口不碰它。 + * 无 / 无效凭据 → `401`,错误体是 §4.1 那个 `{error:{code,message}}` 信封。 + * + * ## 响应**不是信封** + * 成功是 `200` + **原始音频字节**(与 [MediaApi] 同一个形状),所以返回 [ResponseBody] 由上层 + * 读字节并负责关闭,[Streaming] 让它不一次性读进内存。 + * + * ⚠️ **产物恒为 WAV,按字节嗅探判定,不要照抄 `Content-Type`** —— 那条头的实际值不在契约里 + * 承诺(§4.1 行 561 的同句旁注就是这个意思)。嗅探在 [top.yeij.cyrene.data.voice.SpeechClient]。 + * + * 失败时的错误码(全部是 `{error:{code}}` 信封): + * `400`(空 text)、`500 voice_unavailable`(未接语音)、`500 tts_failed`、 + * `500 tts_persona_unmapped`(人格没有发音人映射)、`500 tts_no_character`(连生效人格都没有)、 + * `500 tts_empty`(拿到的是静默空音频,同时响应头带 `X-Voice-Provider: silent`)。 + * + * @see top.yeij.cyrene.data.voice.SpeechClient + */ +interface SpeechApi { + + /** + * 把 [body] 合成为一段 WAV 字节。 + * + * 路径**不带前导 `/`**(与 [ChatApi] 一致):Retrofit 的 `baseUrl` 已含 `/` 结尾, + * 带前导 `/` 会把它当成根路径、把部署在子路径下的服务端地址整段丢掉。 + */ + @Streaming + @POST("api/v1/audio/speech") + suspend fun speech(@Body body: SpeechRequestDto): Response +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/voice/SpeechClient.kt b/app/src/main/kotlin/top/yeij/cyrene/data/voice/SpeechClient.kt new file mode 100644 index 0000000..0f0e642 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/voice/SpeechClient.kt @@ -0,0 +1,208 @@ +package top.yeij.cyrene.data.voice + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy +import top.yeij.cyrene.core.auth.UnauthorizedPolicy +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.ApiErrorMapper +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.SpeechApi +import top.yeij.cyrene.data.api.SpeechRequestDto +import top.yeij.cyrene.data.repository.DocumentCache +import top.yeij.cyrene.data.repository.DocumentCacheUnavailableException +import top.yeij.cyrene.data.repository.VoiceStore +import top.yeij.cyrene.domain.chat.DocumentAttachments +import top.yeij.cyrene.domain.chat.readBounded +import java.io.File +import java.security.MessageDigest + +/** 服务端回的字节**不是**一段 WAV(200 却回了 JSON 信封 / HTML 错误页 / 别的东西)。 */ +class SpeechNotAudioException : Exception("speech response is not wav") + +/** + * 一次 TTS 请求的**字节嗅探**:真的是 RIFF/WAVE 吗? + * + * 契约(api-client.md §4.1 行 561 的旁注、§1.2 U6)只说「产物恒为 WAV」,**没有**承诺 + * `Content-Type` 是什么,并明写「不要照抄 `Content-Type`」。所以判据只能落在字节上: + * 头 4 字节 `RIFF` + 偏移 8 起的 4 字节 `WAVE`(RIFF 容器里夹一段 `fmt ` 那种标准布局)。 + * + * 为什么要判:`MediaPlayer` 拿到非音频字节时抛的是它自己那套 `setDataSource` 异常, + * 与本端「取字节失败」的语义完全不同;而 200 回 JSON 信封恰恰是**最可能**的一种 + * 「网关/反代把错误包了一层」的形态 —— 提前拦在这里,失败就被如实归到「解析失败」那一档, + * 而不是在播放器里炸成一个没人认识的东西。 + */ +fun isWavBytes(bytes: ByteArray): Boolean = + bytes.size >= WAV_HEADER_BYTES && + bytes[0] == 'R'.code.toByte() && + bytes[1] == 'I'.code.toByte() && + bytes[2] == 'F'.code.toByte() && + bytes[3] == 'F'.code.toByte() && + bytes[8] == 'W'.code.toByte() && + bytes[9] == 'A'.code.toByte() && + bytes[10] == 'V'.code.toByte() && + bytes[11] == 'E'.code.toByte() + +/** RIFF 头里本端要看的那几个字节都在前 12 个之内。 */ +private const val WAV_HEADER_BYTES = 12 + +/** 本端一次读得下、也放得动的合成音频上限(与语音副本同一把尺子:附件上限 20 MB)。 */ +private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES + +/** + * 「把一段文字念出来」的取字节层(`POST /api/v1/audio/speech` 的唯一调用方,契约 api-client.md + * §1.2 **U6**,v0.96 增量 + v0.97 补写)。 + * + * ## 凭据:只走 Bearer,不设 API Key + * 服务端是 owner-only 的**双凭据门**(Bearer owner JWT **或** `X-API-Key`,两个分支落到同一个 + * `authorize(RoleOwner)`)。本端只走前者:令牌由 [ApiClientFactory.create] 挂在 OkHttpClient 上的 + * `AuthInterceptor` 注入,并顺带享受「带令牌仍 401 ⇒ 单飞续期并重试一次」。 + * 主人 2026-10-26 拍板**不加 API Key 输入框** —— 那会多一份要保管、要轮换、要泄漏的凭据, + * 而本端本来就已经有 owner 会话。 + * + * ## 发音人从哪来:`sessionId`,不是 `voice` + * 契约里 `voice` **接受但不生效**、`format` **不生效**。真正决定发音人的是 `sessionId` 那条会话的 + * **生效人格**(会话绑定 → 全局默认 → 服务端配置的 default)。所以 [speak] 一定要拿到当前会话 id + * ——**省略它不等于没有角色**,只是让服务端退到全局默认。 + * + * ## 缓存:`/media/tts_.wav`,同一段文字一个请求都不发第二次 + * 名字按 **(sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的 + * 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`, + * 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的 + * `documents/` 根下,不扩大暴露面一个字节)。 + * + * ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的 + * 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。 + * 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit] + * (非空才算)——它是 `cacheDir` 下的东西,系统随时可回收,被回收之后下次朗读重新请求, + * **不是**错误。 + * + * ## 错误怎么分类 + * 非 2xx 一律走 [ApiErrorMapper](`401` ⇒ [CyreneError.Unauthorized],其余 ⇒ + * [CyreneError.Server] 且**保留服务端那句 `code`**——`tts_persona_unmapped` / + * `tts_no_character` 这两档的文案完全不同,折成一句话就说不清了)。 + * 分类成「柔和提示」还是「明确降级」是纯函数的事,在 + * [top.yeij.cyrene.domain.chat.ttsFailureOf] 里,本类不做产品判断。 + * + * @param baseUrlProvider 每次现读服务器地址(换服务器 ⇒ 客户端重建,与 `AppContainer` 同一纪律)。 + * @param apiFactory 按 baseUrl 造 [SpeechApi];收成 lambda 是为了单测能塞一个直连本地 HTTP 替身的 + * 实例,而不必造一整套依赖注入。 + */ +class SpeechClient( + private val baseUrlProvider: () -> String, + private val tokenProvider: () -> String? = { null }, + private val unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy, + private val cacheDir: File, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val apiFactory: (String) -> SpeechApi = { baseUrl -> + ApiClientFactory.create( + baseUrl, + SpeechApi::class.java, + tokenProvider, + unauthorizedPolicy, + ) + }, +) { + + /** 按 baseUrl 缓存 Retrofit 实例(换地址才重建),与 `AppContainer.mediaApi` 同一手法。 */ + private var cachedApi: SpeechApi? = null + private var cachedBaseUrl: String? = null + + private fun api(): SpeechApi { + val baseUrl = baseUrlProvider() + val cached = cachedApi + if (cached != null && cachedBaseUrl == baseUrl) return cached + return apiFactory(baseUrl).also { + cachedApi = it + cachedBaseUrl = baseUrl + } + } + + /** + * 把 [text] 合成成一段 WAV 并落盘,返回**可直接交给 `MediaPlayer`** 的那份文件。 + * + * [sessionId] 是当前会话 id:它决定发音人(见类 KDoc)。传 `null` 表示「本端不知道是哪个会话」, + * 服务端会退到全局默认人格 —— 调用方**不要**为了让字段好看而编一个 id 出来。 + */ + suspend fun speak(text: String, sessionId: String?): Result = withContext(dispatcher) { + val trimmed = text.trim() + if (trimmed.isEmpty()) { + // 服务端对空 text 回 `400`;本端连请求都不发(省一次往返,也省一次无意义的失败)。 + return@withContext Result.failure( + CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)), + ) + } + val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId)) + if (DocumentCache.isHit(file)) return@withContext Result.success(file) + + try { + val response = api().speech(SpeechRequestDto(text = trimmed, sessionId = sessionId)) + val body = response.body() + when { + response.isSuccessful && body != null -> body.use { stream -> + val bytes = readBounded(stream.byteStream(), MAX_SPEECH_BYTES) + when { + // 超过上限:本端一次读不下(合成音频远小于它,走到这里说明对面不对劲)。 + bytes == null -> Result.failure(SpeechNotAudioException()) + // 0 字节:与 MediaRepository 同一口径,判解析失败而不是「拿到一段静音」。 + bytes.isEmpty() -> Result.failure( + CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)), + ) + // 200 却不是 WAV:**绝不**把 JSON / HTML 喂给 MediaPlayer(见 isWavBytes)。 + !isWavBytes(bytes) -> Result.failure(SpeechNotAudioException()) + else -> cache(file, bytes) + } + } + + // 2xx 却没有响应体:与 apiCall 同一口径。 + response.isSuccessful -> Result.failure( + CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)), + ) + + else -> Result.failure( + CyreneException( + ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()), + ), + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e))) + } + } + + private fun cache(file: File, bytes: ByteArray): Result = try { + DocumentCache.write(file, bytes) + Result.success(file) + } catch (_: Exception) { + // 写不进去时把可能的残留清掉,再如实报「缓存不可用」——绝不退回「把字节留在内存里等播放器 + // 读」那条路:MediaPlayer 只吃路径,而缓存目录写不进去时多半写得进的地方也没有。 + file.delete() + Result.failure(DocumentCacheUnavailableException()) + } + + private companion object { + /** 合成缓存的名字前缀:与语音副本(服务端内容寻址名)在同一目录里区分开。 */ + const val NAME_PREFIX = "tts_" + + /** sha256 前 32 位十六进制,与服务端存储名 / [DocumentCache.localName] 同一长度。 */ + const val NAME_HEX_LENGTH = 32 + + /** + * 缓存名 = `tts_.wav`。 + * + * 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字 + * 可能是两个声音。`\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。 + */ + fun cacheName(text: String, sessionId: String?): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest("${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8)) + val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH) + return NAME_PREFIX + hex + ".wav" + } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeak.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeak.kt new file mode 100644 index 0000000..daff720 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeak.kt @@ -0,0 +1,167 @@ +package top.yeij.cyrene.domain.chat + +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.domain.parse.ParsedBubble +import top.yeij.cyrene.domain.parse.parse + +/** + * 「把这条消息念出来」这条路上,**本端要说的那几句话**,以及失败该分到哪一档。 + * + * 这个文件里全是纯函数 / 常量,没有 Android、没有网络、没有状态:单测直接调(见 + * `VoiceSpeakTest`)。真正的取字节在 `data.voice.SpeechClient`,播放复用 `ui.chat.VoicePlayer`。 + */ + +/** + * 从一条消息解析出来的气泡里抽出**要念的那段纯文本**。 + * + * ## 为什么只念 `body` + * [ParsedBubble] 把一条气泡拆成三段:`head`(说话人抬头)、`body`(正文)、`tail`(旁白 / 动作)。 + * 念出来时**只念正文**: + * - `head` 是「昔涟:」这种抬头,念出来像在念字幕; + * - `tail` 里的旁白(`*她笑了笑*`)与动作(`[动作]`)是**舞台指示**,语音合成念它只会变成一串 + * 莫名其妙的字面量。 + * + * ## 为什么按 `\n` 拼、且空就回 `null` + * 一条消息可能被切成多个气泡(连续说两段);`\n` 让合成的停顿落在段落之间,比空格自然。 + * 全都为空(纯附件消息、纯旁白消息、或者解析完什么都不剩)时回 `null` —— 调用方据此**不显示** + * 朗读入口,而不是让用户点一个必然拿到 `400` 的按钮。 + * + * 取完 `trim`:首尾空白会变成合成音频开头 / 结尾的静音,没有任何用处。 + */ +fun ttsTextOf(bubbles: List): String? = + bubbles.asSequence() + .map { it.body } + .filter { it.isNotBlank() } + .joinToString("\n") + .trim() + .takeIf { it.isNotEmpty() } + +/** + * 一条消息(`content` + `mode`)里**要念的那段文本**;`null` = 没有可念的正文。 + * + * 这是「这条消息能不能朗读」的**唯一判据**:`ChatViewModel.speak` 用它决定发不发请求, + * `MessageBubble` 用它决定画不画那枚图标。两处必须是同一个答案 —— 画了却发不出去、或者能发 + * 却不给入口,都是 bug。 + * + * ## 为什么要看 `mode` + * `chat`(或缺席)是「按 markup 切气泡的聊天式渲染」;其余 mode 下气泡里放的是**原文**, + * 一条 markup 都不解析(`MessageBubble` 就是这么画的)。朗读要念的显然也是**用户看到的那段字**: + * - 聊天式 ⇒ 走 [ttsTextOf] 剥掉抬头 / 旁白; + * - 其余 ⇒ 原文原样,只 `trim`。 + * + * 判据本身与 `MessageBubble` 里那句 `isChat` 一字不差,刻意留在这里一份:多写一份就会出现 + * 「界面按一种切法、朗读按另一种切法」的分叉。 + */ +fun ttsTextOf(message: HistoryMessageDto): String? = + if (message.mode.isNullOrBlank() || message.mode == "chat") { + ttsTextOf(parse(message.content).filterNot { it.isEmpty }) + } else { + message.content.trim().takeIf { it.isNotEmpty() } + } + +/** + * 一次朗读失败该归到哪一档 —— 判据只看**服务端到底说了什么**,不看 HTTP 码的表面。 + * + * ## 为什么分两档 + * 这两档对用户的意思完全不同,混成一句就等于什么都没说: + * - [Soft]:**这会儿**没读出来(网络断了、令牌过期、网关抽风)。过一会儿再点是**有意义**的, + * 所以文案只陈述事实、不诱导、也不吓人; + * - [Unconfigured]:**配置**上就没这条路的。服务端明说了是哪种缺(`tts_persona_unmapped` + * 没有发音人映射 / `tts_no_character` 连生效人格都没有),重试**永远不会**成功 —— 所以文案要说 + * 清「缺什么」,并且**不提重试**(提了就是在骗人点)。 + */ +sealed interface TtsFailure { + + /** 要说给用户的那一句话。 */ + val message: String + + /** 再点一次**可能**有用吗?([Unconfigured] 恒 `false`:重试永远不会成功。) */ + val retryable: Boolean + + /** 这会儿没读出来:不解释原因(本端确实不知道是哪一段断了),也不诱导重试。 */ + data object Soft : TtsFailure { + override val message: String = TTS_SOFT_FAILURE_MESSAGE + override val retryable: Boolean = true + } + + /** + * 服务端明说了配置缺一块。[code] 就是服务端那个错误码(原样留着,排查时对得上号)。 + */ + data class Unconfigured(val code: String) : TtsFailure { + override val message: String = when (code) { + TTS_CODE_PERSONA_UNMAPPED -> TTS_PERSONA_UNMAPPED_MESSAGE + TTS_CODE_NO_CHARACTER -> TTS_NO_CHARACTER_MESSAGE + // 兜底:服务端回了一个本端还不认识的「配置缺失」类错误码。不猜它的意思, + // 但也不装作是临时故障——它毕竟带着一个明确的 code。 + else -> TTS_UNCONFIGURED_MESSAGE + } + override val retryable: Boolean = false + } +} + +/** + * 把一次朗读的异常分到 [TtsFailure] 两档里。 + * + * 只有「服务端明确回了一个**配置缺失**类的 `code`」才进 [TtsFailure.Unconfigured];其余一切 + * ——网络失败、解析失败、401(令牌过期,续期后还能用)、`voice_unavailable` / `tts_failed` / + * `tts_empty`(对面这会儿不行,不是本端的配置问题)——都进 [TtsFailure.Soft]。 + * + * `tts_empty`(静默空音频)刻意归到柔和档:那是**这一次**合成出来的东西不能用,换个时间再试 + * 完全可能成功,说成「配置没配好」就是错的。 + */ +fun ttsFailureOf(error: Throwable): TtsFailure { + val cause = (error as? CyreneException)?.error ?: return TtsFailure.Soft + return when (cause) { + is CyreneError.Server -> when (cause.code) { + TTS_CODE_PERSONA_UNMAPPED, + TTS_CODE_NO_CHARACTER, + -> TtsFailure.Unconfigured(cause.code) + + else -> TtsFailure.Soft + } + + is CyreneError.Unauthorized -> TtsFailure.Soft + is CyreneError.Local -> TtsFailure.Soft + } +} + +/** + * 朗读在某一条播放通道上的**键**:`tts:`。 + * + * 播放通道只有一条(`ui.chat.VoicePlayer` 的那个实例,与语音副本共用),所以键必须与语音副本 + * (键 = `voice` 的 `messageId`)分得开:前缀就是那道分界线。同一条消息的朗读与它的语音副本 + * 因此**互斥**(点其中一个会停掉另一个)——这是对的,同一时刻只该有一个声音。 + */ +fun ttsClipIdOf(messageId: String): String = TTS_CLIP_PREFIX + messageId + +/** 朗读入口那枚图标的读屏名字(也是设备验收时 `uiautomator` 的 `content-desc`)。 */ +const val TTS_SPEAK_DESCRIPTION: String = "朗读这条消息" + +/** 正在朗读时那枚图标的读屏名字(点它 = 停)。 */ +const val TTS_STOP_DESCRIPTION: String = "停止朗读" + +/** 正在取合成音频时那枚图标的读屏名字。 */ +const val TTS_LOADING_DESCRIPTION: String = "正在准备朗读" + +/** 柔和档:这会儿没读出来。**不说原因**(本端确实不知道断在哪一段),也不提重试。 */ +const val TTS_SOFT_FAILURE_MESSAGE: String = "这会儿没能把这段读出来" + +/** `tts_persona_unmapped`:人格有,但这个人格没有配发音人。 */ +const val TTS_PERSONA_UNMAPPED_MESSAGE: String = "这个角色还没配好发音人" + +/** `tts_no_character`:连生效人格都没有(会话没绑定、也没有全局默认人格)。 */ +const val TTS_NO_CHARACTER_MESSAGE: String = "这个会话还没有配好发音人" + +/** 服务端回了配置缺失类错误码、但本端还不认识那一个时的兜底文案。 */ +const val TTS_UNCONFIGURED_MESSAGE: String = "这个角色还没有配好朗读的发音人" + +/** 朗读播放通道的键前缀(与语音副本的键 `messageId` 区分开)。 */ +const val TTS_CLIP_PREFIX: String = "tts:" + +/** 服务端错误码:人格没有发音人映射。 */ +const val TTS_CODE_PERSONA_UNMAPPED: String = "tts_persona_unmapped" + +/** 服务端错误码:连生效人格都没有。 */ +const val TTS_CODE_NO_CHARACTER: String = "tts_no_character" diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt index de00da5..dc230c6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatScreen.kt @@ -506,6 +506,27 @@ fun ChatScreen( } } + // 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:`, + // 与语音副本天然互斥——点一个停另一个)。键里带 `file`:`null → 文件` 那一刻才是真正能播的 + // 时刻。`autoPlay` 只用一次,理由与上面那条完全相同(用户可能已经切走了)。 + // + // ⚠️ `autoPlay` 必须进键:用户「放 → 停 → 再点同一条」时 `(clipId, file)` 一对**没变** + // (同一份缓存文件),只按那一对做键就再也不会起播。置假那一次会把键改成 `false`、下一轮 + // 点击改回 `true`,于是每一点都重新点着一次;置假本身触发的重入在第一行就返回了,不成环。 + // + // 起播之后这一格**不再变**:播放走到哪一步是播放通道的事,VM 不需要知道(见 `TtsClip` 的 + // 类 KDoc)。 + val ttsClip = state.ttsClip + LaunchedEffect(ttsClip?.clipId, ttsClip?.file, ttsClip?.autoPlay) { + val clip = ttsClip ?: return@LaunchedEffect + val file = clip.file ?: return@LaunchedEffect + if (!clip.autoPlay) return@LaunchedEffect + viewModel.consumeTtsAutoPlay() + if (!voicePlayer.state.value.isActiveFor(clip.clipId)) { + voicePlayer.toggleClip(clip.clipId) { Result.success(file) } + } + } + // 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。 // 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上, // 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上 @@ -759,6 +780,11 @@ fun ChatScreen( // 语音播放(接收侧):状态来自**这一个会话**的那条播放通道, // 点击交给它去取字节 / 放 / 停。气泡自己不认识 MediaPlayer。 voicePlayback = voicePlayback, + // 朗读(W75):**只有这一条**的朗读状态进来(对不上的当没点过)。 + // 播放状态仍是同一条通道——朗读的键是 `tts:`,所以两枚 + // 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。 + ttsClip = state.ttsClip?.takeIf { it.messageId == item.message.id }, + ttsPlayback = voicePlayback, onDocumentClick = { ref -> previewNotice = null previewTarget = DocumentTarget.of(ref) @@ -774,6 +800,8 @@ fun ChatScreen( onEdit = viewModel::startEdit, onDelete = viewModel::startDelete, onVoiceToggle = voicePlayer::toggle, + // 朗读:请求、失败分类、落盘全在 VM;这里只转一次用户意图。 + onSpeak = viewModel::speak, ) is ChatDisplayItem.Pending -> PendingBubble( diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 9e02313..84c66e3 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -35,6 +35,7 @@ import top.yeij.cyrene.data.repository.MessageEditDraft import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.PagedMessages import top.yeij.cyrene.data.repository.VoiceStore +import top.yeij.cyrene.data.voice.SpeechClient import top.yeij.cyrene.data.repository.applyMessageEdit import top.yeij.cyrene.data.repository.historyForSession import top.yeij.cyrene.data.repository.mergeMessages @@ -71,6 +72,9 @@ import top.yeij.cyrene.domain.chat.parallelForSend import top.yeij.cyrene.domain.chat.parallelWireValue import top.yeij.cyrene.domain.chat.readBounded import top.yeij.cyrene.domain.chat.toDocumentFailure +import top.yeij.cyrene.domain.chat.ttsClipIdOf +import top.yeij.cyrene.domain.chat.ttsFailureOf +import top.yeij.cyrene.domain.chat.ttsTextOf import top.yeij.cyrene.domain.chat.turnBucketOf import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor import top.yeij.cyrene.domain.chat.voiceClipRejection @@ -636,6 +640,46 @@ fun ChatUiState.applyMessageActionFailure( } } +/** + * 点「朗读」这一步的状态迁移(纯函数,VM 与测试共用)。 + * + * 返回 `null` = **这一次点击该被忽略**:同一条消息的请求还在飞([TtsClip.pending])。这是防抖的 + * 唯一判据。已经落好(`file != null`)或已经失败过(`failure != null`)的**不算**在飞 —— + * 前者是「再放一遍」,后者是用户手动重试,两种都该放行。 + * + * 返回新状态 = 放行,并且这一格已经换成「请求在飞」。`autoPlay` 保持默认的真:字节到手那一刻 + * 要起播一次(见 [TtsClip.autoPlay]),置假只在真的起播之后(`ChatViewModel.consumeTtsAutoPlay`)。 + */ +fun ChatUiState.beginTtsSpeak(messageId: String, clipId: String): ChatUiState? { + val current = ttsClip + if (current != null && current.clipId == clipId && current.pending) return null + return copy(ttsClip = TtsClip(messageId, clipId, null, null, false)) +} + +/** + * 请求回来了,把结论落到这一格(纯函数,VM 与测试共用)。 + * + * 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、 + * 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。 + * + * 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的 + * 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。 + */ +fun ChatUiState.applyTtsOutcome( + messageId: String, + clipId: String, + result: Result, +): ChatUiState { + if (ttsClip?.clipId != clipId) return this + return result.fold( + onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false)) }, + onFailure = { error -> + val failure = ttsFailureOf(error) + copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable)) + }, + ) +} + sealed interface ChatDisplayItem { val key: String @@ -857,6 +901,17 @@ data class ChatUiState( * (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。 */ val voiceNotice: String? = null, + /** + * 「把这一条念出来」这一次的产物(W75,`POST /api/v1/audio/speech`);null = 用户还没点过朗读。 + * + * **只有一格**:用户同一时刻点的是**一条**消息(见 [TtsClip])。它同时承载三件事 —— + * 请求在飞([TtsClip.pending])、字节已经落到本机([TtsClip.file])、上一次为什么没成 + * ([TtsClip.failure])。音频本体是**文件路径**而不是字节,理由同 [voiceReply]:每次状态 + * `copy` 都在几 MB 的数组上做相等比较是不可接受的。 + * + * 「正在响」不在这一格里 —— 那归唯一的播放通道(`ui.chat.VoicePlayer`),键是 [TtsClip.clipId]。 + */ + val ttsClip: TtsClip? = null, ) { /** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */ val items: List @@ -1622,6 +1677,81 @@ class ChatViewModel( } } + /** + * 用户点了某条消息上的「朗读」(W75,`POST /api/v1/audio/speech`)。 + * + * ## 念什么 + * 只念**她**的消息(`assistant`):`user` 那一侧点朗读没有意义,而且服务端的发音人由 + * **会话的生效人格**决定 —— 念主人自己说的话会用她的声音,那是错的。判据就是角色这一条, + * 其余(要念哪段字)全交给纯函数 [ttsTextOf]:它剥掉抬头与旁白,只留正文;一个字都不剩 + * (纯附件 / 纯旁白)时**直接不发请求** —— 服务端对空 `text` 回 `400`,本端没有理由先跑一趟。 + * + * ## 防重入 + * 同一条消息的请求**在飞的时候再点一次**直接忽略:`_state.ttsClip` 已经是那条消息的 + * `pending` 态。用户点**另一条**则照常发起(旧那条被整格替换,它的音频文件留在缓存里, + * 下次点它命中、不发请求 —— 见 `data.voice.SpeechClient`)。 + * + * 播放那半边**不在这里**:这里只把「字节到手」写进 [ChatUiState.ttsClip],起播由渲染层那个 + * `LaunchedEffect` 交给唯一的播放通道(`ui.chat.VoicePlayer`)。分开是必须的 —— VM 不该碰 + * `MediaPlayer`,而播放通道要能在「换了一条语音副本」时独立停下,不必回头改这一格。 + * + * ## 失败 + * 分类是纯函数 [ttsFailureOf]:`tts_persona_unmapped` / `tts_no_character` 这两档是**配置**缺失 + * (重试永远不会成功,文案要说清缺什么),其余一律柔和提示。**不重试**:这条路上没有任何 + * 自动重试 —— 用户再点一次就是那次重试,而配置缺失那一档连这句话都不该说。 + * + * 失败**不弹窗、不走 [ChatUiState.error] 横幅**:那是「这一次请求失败了」的全局通道,而朗读 + * 是一次附带的小动作,把它抬成横幅会盖住真正的发送失败。文案挂在**这条消息**下面(见 + * `MessageBubble`)。 + */ + fun speak(message: HistoryMessageDto) { + if (message.role == "user") return + val text = ttsTextOf(message) ?: return + val clipId = ttsClipIdOf(message.id) + // 判据与落位都在纯函数里([beginTtsSpeak] / [applyTtsOutcome]):同一条正在飞 ⇒ 这里 + // 拿回 null,直接返回。用 compareAndSet 而不是 update,是因为「放行与否」和「写进哪一 + // 格」必须是同一次读写的结论——分开写会让并发的两次点击都以为自己放行了。 + while (true) { + val current = _state.value + val begun = current.beginTtsSpeak(message.id, clipId) ?: return + if (_state.compareAndSet(current, begun)) break + } + viewModelScope.launch { + val result = speechClient.speak(text, sessionId) + _state.update { it.applyTtsOutcome(message.id, clipId, result) } + } + } + + /** + * 这一次朗读的**自动起播**已经用掉了(渲染层在真的起了播放之后调)。 + * + * 与 [consumeVoiceReplyAutoPlay] 同一条纪律、同一个理由:请求发出去之后用户可能已经切到别的 + * 会话 / 别的页面,切回来时不该突然响一段刚才点的朗读(见 [TtsClip.autoPlay])。 + */ + fun consumeTtsAutoPlay() { + _state.update { state -> + val clip = state.ttsClip + if (clip == null || !clip.autoPlay) state else state.copy(ttsClip = clip.copy(autoPlay = false)) + } + } + + /** + * 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造 + * (与 `AppContainer` 那些按需 getter 同一手法)。 + * + * `baseUrlProvider` / `tokenProvider` 都传**函数**而不是值:换服务器 / 令牌续期之后, + * 下一次朗读自动用新的(与 `mediaApi` 同源同纪律)。凭据只走 owner Bearer —— + * 主人 2026-10-26 拍板不设 API Key 输入框(见 `data.voice.SpeechClient` 的类 KDoc)。 + */ + private val speechClient: SpeechClient by lazy { + SpeechClient( + baseUrlProvider = { container.authManager.currentBaseUrl }, + tokenProvider = container.authManager::currentToken, + unauthorizedPolicy = container.authManager.unauthorizedPolicy, + cacheDir = container.appContext.cacheDir, + ) + } + /** * 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index 968e02e..426c004 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -47,9 +47,16 @@ import top.yeij.cyrene.domain.chat.DocumentChipModel import top.yeij.cyrene.domain.chat.DocumentChips import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.chat.ImageHostRebase +import top.yeij.cyrene.domain.chat.TTS_LOADING_DESCRIPTION +import top.yeij.cyrene.domain.chat.TTS_SPEAK_DESCRIPTION +import top.yeij.cyrene.domain.chat.TTS_STOP_DESCRIPTION import top.yeij.cyrene.domain.chat.TurnPhase import top.yeij.cyrene.domain.chat.VoicePlaybackState +import top.yeij.cyrene.domain.chat.isActiveFor +import top.yeij.cyrene.domain.chat.isLoadingFor import top.yeij.cyrene.domain.chat.isVoiceMessage +import top.yeij.cyrene.domain.chat.ttsClipIdOf +import top.yeij.cyrene.domain.chat.ttsTextOf import top.yeij.cyrene.domain.chat.voiceNote import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yeij.cyrene.domain.parse.ContentPart @@ -63,6 +70,8 @@ import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.basic.Close import top.yukonga.miuix.kmp.icon.extended.More +import top.yukonga.miuix.kmp.icon.extended.Pause +import top.yukonga.miuix.kmp.icon.extended.Play import top.yukonga.miuix.kmp.theme.MiuixTheme import java.time.Instant import java.time.ZoneId @@ -189,6 +198,11 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean = * 本条消息的那几个态(正在放 / 正在取字节 / 上一次失败)会改变这枚语音形态的外观;默认 * [VoicePlaybackState.Idle] 则与「没有播放通道」逐像素相同(不画任何播放中的痕迹)。 * @param onVoiceToggle 用户点了这枚语音的播放 / 暂停:交给上层真正去取字节 / 放 / 停。 + * @param ttsClip 这一条(或**别的一条**)的朗读状态;只有 `messageId` 对得上时才影响本条。 + * 见 [TtsClip]:`null` / 对不上 / 本条没点过朗读 ⇒ 与加这一块之前逐像素相同。 + * @param ttsPlayback 那条**播放通道**此刻的状态。朗读与语音副本共用它,所以朗读的键是 + * `ttsClipIdOf(message.id)` 而不是 `message.id` —— 两枚图标的「正在响」互不串台。 + * @param onSpeak 用户点了「朗读」:交给上层发请求(本组件不自己发)。 */ @Composable fun MessageBubble( @@ -197,11 +211,14 @@ fun MessageBubble( phase: TurnPhase = TurnPhase.Sent, legBadge: String? = null, voicePlayback: VoicePlaybackState = VoicePlaybackState.Idle, + ttsClip: TtsClip? = null, + ttsPlayback: VoicePlaybackState = VoicePlaybackState.Idle, onDocumentClick: (DocumentRefDto) -> Unit = {}, onImageClick: (String) -> Unit = {}, onEdit: (HistoryMessageDto) -> Unit = {}, onDelete: (HistoryMessageDto) -> Unit = {}, onVoiceToggle: (HistoryMessageDto) -> Unit = {}, + onSpeak: (HistoryMessageDto) -> Unit = {}, ) { val isUser = message.role == "user" // 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同: @@ -237,6 +254,20 @@ fun MessageBubble( // 那一行根本没被画出来。 val timeCarrier = messageTimeCarrier(message, bubbles) + // —— 朗读(W75)—— + // 「这条消息有没有可念的正文」:判据与 VM 发请求用的是**同一个纯函数**([ttsTextOf]), + // 所以「画了入口」与「发得出去」永远不会分叉。这里喂的是**已经解析好的** `bubbles`: + // 它们就是屏幕上这些气泡,比让纯函数再解析一遍更贴近「用户看到的那段字」。 + // 只给她的消息(`assistant`)——主人的话不该用她的声音念回来,VM 里还有第二道闸。 + val speakable = !isUser && remember(bubbles) { ttsTextOf(bubbles) != null } + // 本条自己的那份朗读状态:`messageId` 对不上的(用户点了别的一条)一律当没点过。 + val ownClip = ttsClip?.takeIf { it.messageId == message.id } + val ownClipId = remember(message.id) { ttsClipIdOf(message.id) } + val speakActive = ttsPlayback.isActiveFor(ownClipId) + // 「正在准备」有两个来源:VM 那边请求还在飞(网络 / 合成,绝大多数时间在这里),以及播放通道 + // 正在取字节(本端落好的文件是直接给的,这一档只是一闪而过)。 + val speakPending = ownClip?.pending == true || ttsPlayback.isLoadingFor(ownClipId) + MessageGroup( isUser = isUser, footer = bubbleFooter( @@ -246,6 +277,10 @@ fun MessageBubble( shared = isSharedMessage(message), ), onOpenMenu = openMenu, + onSpeak = if (speakable) { { onSpeak(message) } } else null, + speakActive = speakActive, + speakPending = speakPending, + ttsFailure = ownClip?.failure, ) { message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) } // 下面三块(附件芯片排 / 图片 / 语音形态)与正文气泡共用 [BubbleWithTime]:**这条一个正文 @@ -751,6 +786,10 @@ private fun MessageGroup( isUser: Boolean, footer: String? = null, onOpenMenu: (() -> Unit)? = null, + onSpeak: (() -> Unit)? = null, + speakActive: Boolean = false, + speakPending: Boolean = false, + ttsFailure: String? = null, content: @Composable ColumnScope.() -> Unit, ) { Column( @@ -763,7 +802,7 @@ private fun MessageGroup( content() // 来源角标挂在消息组末尾,紧随它标注的那条消息(主人右下、昔涟左下)。 // 「⋯」与它同一行:那枚图标是**看得见的**动作入口(长按同样能开,见 [MessageBubble])。 - if (footer != null || onOpenMenu != null) { + if (footer != null || onOpenMenu != null || onSpeak != null) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MESSAGE_ACTION_GAP), @@ -776,6 +815,31 @@ private fun MessageGroup( modifier = Modifier.padding(start = 6.dp, end = 6.dp), ) } + // 「朗读」紧挨「⋯」:两枚都是这条消息的动作,分开放会让人以为是两个话题。 + // 与「⋯」同一个尺寸、同一条 `Icon + clickable` 纪律(理由见下面那段注释)。 + if (onSpeak != null) { + Icon( + imageVector = if (speakActive) MiuixIcons.Pause else MiuixIcons.Play, + contentDescription = when { + speakActive -> TTS_STOP_DESCRIPTION + speakPending -> TTS_LOADING_DESCRIPTION + else -> TTS_SPEAK_DESCRIPTION + }, + // 正在响 ⇒ 主色(与语音形态那枚图标同一口径);其余是角标行那档灰。 + tint = if (speakActive) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.onSurfaceVariantSummary + }, + // 正在准备时**点不动**:这时候再发一次请求没有任何意义,用户想要的是 + // 「别再等了」——而那条路(取消在飞的合成)本端没有做,所以不给一个 + // 点了没反应的入口(见报告「未做」)。 + modifier = Modifier + .size(MESSAGE_ACTION_ICON_SIZE) + .clip(CircleShape) + .clickable(enabled = !speakPending, onClick = onSpeak), + ) + } if (onOpenMenu != null) { // 用 Icon + clickable 而不是 IconButton:后者的最小交互尺寸会把每一条消息的 // 角标行撑到 48dp,整屏因此凭空变高一大截;这里只要一枚小图标能点中就够了 @@ -792,6 +856,18 @@ private fun MessageGroup( } } } + // 朗读没成功时那一句。挂在角标行**下面**、自成一行:塞进那一行会把来源角标挤成两截, + // 而这句话是给用户看的一整句,不该跟角标抢横向空间。 + // 颜色用 `error` 而不是灰:它是一条**如实告知**(不是装饰),但也不弹窗、不打断—— + // 用户的眼睛本来就在他刚点的那条消息上。 + if (ttsFailure != null) { + Text( + text = ttsFailure, + style = MiuixTheme.textStyles.footnote2, + color = MiuixTheme.colorScheme.error, + modifier = Modifier.padding(start = 6.dp, end = 6.dp), + ) + } } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceSpeak.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceSpeak.kt new file mode 100644 index 0000000..65d4940 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceSpeak.kt @@ -0,0 +1,50 @@ +package top.yeij.cyrene.ui.chat + +import java.io.File + +/** + * 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。 + * + * ## 为什么它住在 VM 的状态里,而不是气泡自己的 `remember` + * 一次朗读要经过一段**跨组合重建**的等待(发请求、等字节、落盘):转屏 / 滚出屏幕再滚回来 + * 都不该让已经落好的那段音频消失,也不该让「正在准备」这件事凭空重置。所以请求发起、失败分类、 + * 落盘结果都在 `ChatViewModel.speak` 里,气泡只是这份状态的视图。 + * + * ## 一次只有一个:`ttsClip` 是**单格** + * 与 [VoiceReply] 同一取舍:同一时刻用户点的是**一条**消息,多留几条既占内存又没人看。 + * 用户点了另一条 ⇒ 这一格被新的那条整个替换(旧那条的音频文件留在缓存里,下次点它命中不发请求)。 + * + * ## 播放不在这一格里 + * 「正在响 / 已经停了」由**唯一的播放通道**(`ui.chat.VoicePlayer` 的 `state`)说,键是 + * [clipId](`tts:`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、 + * 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道 + * `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事 + * 本来就不是一件事。 + * + * @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。 + * @property clipId 播放通道上的键(`tts:`):与语音副本的键(= 它的 `messageId`) + * 分得开,于是「念这一条」与「放这条消息的语音副本」天然互斥(点一个停另一个)。 + * @property file 合成音频在本机的那份 WAV;`null` = 还没到手(请求在飞,或已经失败了)。 + * @property failure 上一次没成功时给用户看的那句话(`domain.chat.TtsFailure.message`); + * `null` = 没失败过 / 还没结果。 + * @property retryable 再点一次**可能**有用吗(`domain.chat.TtsFailure.retryable`)。为假时图标 + * 仍然可点 —— 但那是「重来一次」而不是「重试」,文案上不提重试(见 `TtsFailure.Unconfigured`)。 + * @property autoPlay 这一次结果还该不该**自动起播**(默认 true,起播过就置假,见 + * `ChatViewModel.consumeTtsAutoPlay`)。 + * + * 为什么要这一位:请求发出去之后用户可能已经切到别的会话 / 别的页面。若只按「文件到了就放」 + * 来触发,等他切回来时会**突然响一段刚才点的朗读** —— 那不是他此刻的动作。所以自动起播只认 + * **第一次**落好的那一下,之后靠那枚图标。 + */ +data class TtsClip( + val messageId: String, + val clipId: String, + val file: File?, + val failure: String?, + val retryable: Boolean, + val autoPlay: Boolean = true, +) { + + /** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */ + val pending: Boolean get() = file == null && failure == null +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/SpeechApiTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/SpeechApiTest.kt new file mode 100644 index 0000000..316a4b4 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/SpeechApiTest.kt @@ -0,0 +1,156 @@ +package top.yeij.cyrene.data.api + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * `POST /api/v1/audio/speech` 的**运行时接线**:`ApiClientFactory.create`(主 REST client)→ + * [SpeechApi]。 + * + * 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`, + * 与 [RestAttemptClientTest] 同一套做法,不引任何新依赖)。 + * + * ## 它钉住的四条事实(每条都是判据,不是描述) + * 1. 请求真的写出去,落到 `POST api/v1/audio/speech` —— 路径**不带前导 `/`**, + * 否则部署在子路径下的服务端地址会被整段丢掉([SpeechApi] 的 KDoc 写的就是这条)。 + * 2. 它带着 **owner Bearer 令牌**与 `X-API-Version: 1`:服务端是 owner-only 的双凭据门, + * 本端只走 Bearer 那一支(主人 2026-10-26 拍板不设 API Key 输入框)。 + * 3. `sessionId` **有值时随体发出**——发音人由那条会话的生效人格决定,这是它唯一的来源。 + * 4. `sessionId` 为 `null` 时字段**整个缺席**(`explicitNulls = false`),而不是发一个 + * `"sessionId": null`:契约里「省略」与「显式空」是两件事。 + */ +class SpeechApiTest { + + private val backend = FakeSpeechBackend() + + @After + fun tearDown() { + backend.close() + } + + /** 与生产同形:主 REST client(带令牌与 401 策略),只是 baseUrl 指向本地假端点。 */ + private fun api(baseUrl: String = backend.baseUrl) = ApiClientFactory.create( + baseUrl = baseUrl, + service = SpeechApi::class.java, + tokenProvider = { TOKEN }, + ) + + /** 发一次真请求并把响应体关掉(`@Streaming` 的体必须由调用方关闭)。 */ + private suspend fun speech(dto: SpeechRequestDto) { + val response = withTimeoutOrNull(TIMEOUT_MILLIS) { api().speech(dto) } + response?.body()?.close() + } + + @Test + fun `the request really puts a POST api v1 audio speech on the wire`() = runBlocking { + backend.reply(200, WAV) + + speech(SpeechRequestDto(text = "你好")) + + // 请求行不含 HTTP 版本(HttpExchange 只给 method + URI),判据是「方法 + 路径」。 + assertEquals("POST /api/v1/audio/speech", backend.requestLines.single()) + } + + @Test + fun `the request carries the owner bearer token and the api version`() = runBlocking { + backend.reply(200, WAV) + + speech(SpeechRequestDto(text = "你好")) + + val headers = backend.headers.single() + assertEquals("Bearer $TOKEN", headers["authorization"]) + assertEquals("1", headers["x-api-version"]) + } + + @Test + fun `the session id travels in the body because it decides the voice`() = runBlocking { + backend.reply(200, WAV) + + speech(SpeechRequestDto(text = "你好", sessionId = SESSION_ID)) + + val raw = backend.bodies.single() + val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw) + assertEquals("你好", sent.text) + assertEquals(SESSION_ID, sent.sessionId) + assertTrue("sessionId 该随体发出,实际 $raw", raw.contains("\"sessionId\"")) + } + + @Test + fun `a null session id leaves the field out entirely instead of sending null`() = runBlocking { + backend.reply(200, WAV) + + speech(SpeechRequestDto(text = "你好")) + + val raw = backend.bodies.single() + val sent = ApiClientFactory.json.decodeFromString(SpeechRequestDto.serializer(), raw) + assertNull(sent.sessionId) + // 「省略」与「显式 null」在契约里是两件事:后者会让服务端以为本端知道是哪个会话。 + assertFalse("sessionId 为 null 时不该出现这个键,实际 $raw", raw.contains("sessionId")) + } + + /** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**、请求头原文与请求体原文。 */ + private inner class FakeSpeechBackend : AutoCloseable { + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + executor = Executors.newFixedThreadPool(2) + createContext("/api/v1/audio/speech", ::handle) + start() + } + + val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" + + val requestLines = CopyOnWriteArrayList() + + /** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */ + val headers = CopyOnWriteArrayList>() + val bodies = CopyOnWriteArrayList() + + @Volatile + private var status = 200 + + @Volatile + private var payload = WAV + + fun reply(status: Int, body: ByteArray) { + this.status = status + this.payload = body + } + + private fun handle(exchange: HttpExchange) { + requestLines += "${exchange.requestMethod} ${exchange.requestURI}" + headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") } + bodies += String(exchange.requestBody.readBytes(), Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "audio/wav") + exchange.sendResponseHeaders(status, payload.size.toLong()) + exchange.responseBody.use { it.write(payload) } + } + + override fun close() { + server.stop(0) + } + } + + private companion object { + const val SESSION_ID = "web" + const val TOKEN = "test-token" + const val TIMEOUT_MILLIS = 10_000L + + /** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */ + val WAV: ByteArray = + "RIFF".toByteArray(Charsets.US_ASCII) + + byteArrayOf(36, 0, 0, 0) + + "WAVE".toByteArray(Charsets.US_ASCII) + + ByteArray(40) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/voice/SpeechClientTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/voice/SpeechClientTest.kt new file mode 100644 index 0000000..4809849 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/voice/SpeechClientTest.kt @@ -0,0 +1,220 @@ +package top.yeij.cyrene.data.voice + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException +import java.io.File +import java.net.InetSocketAddress +import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * 「把一段文字念出来」的取字节层([SpeechClient]):发什么、收什么、落哪儿、失败算哪一档。 + * + * 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`, + * 与 [top.yeij.cyrene.data.api.RestAttemptClientTest] 同一套做法,不引任何新依赖),落盘用真临时目录。 + * + * ## 它钉住的六条事实 + * 1. **产物恒为 WAV,按字节嗅探**:`200` 却回 JSON 信封(网关/反代把错误包了一层的典型形态) + * 绝不喂给 `MediaPlayer`,如实判 [SpeechNotAudioException]。这一条刻意让假端点回 + * `Content-Type: audio/wav` —— 头是**不可信**的,判据只落在字节上。 + * 2. **0 字节不算「一段静音」**,判解析失败(与 `MediaRepository` 同一口径)。 + * 3. **落盘位置是 `/media/tts_.wav`**:不在 `documents/`(那是 FileProvider 唯一 + * 暴露出去的根),名字按 `(sessionId, text)` 内容寻址。 + * 4. **同一段文字一个请求都不发第二次**(用请求计数断言,不是「文件存在」)。 + * 5. **空文本连请求都不发**:服务端对它只会回 `400`,本端没必要走一次往返。 + * 6. **非 2xx 保留服务端那句 `code`**:`tts_persona_unmapped` / `tts_no_character` 这两档的文案 + * 完全不同,折成一句话就说不清了。 + */ +class SpeechClientTest { + + private lateinit var cacheRoot: File + private val backend = FakeSpeechBackend() + + @Before + fun setUp() { + cacheRoot = Files.createTempDirectory("cyrene-tts").toFile() + } + + @After + fun tearDown() { + backend.close() + cacheRoot.deleteRecursively() + } + + /** 与生产同形:令牌由 [top.yeij.cyrene.data.api.ApiClientFactory] 的拦截器注入。 */ + private fun client(baseUrl: String = backend.baseUrl) = SpeechClient( + baseUrlProvider = { baseUrl }, + tokenProvider = { TOKEN }, + cacheDir = cacheRoot, + ) + + private suspend fun speak( + client: SpeechClient = client(), + text: String = "你好", + sessionId: String? = SESSION_ID, + ): Result? = withTimeoutOrNull(TIMEOUT_MILLIS) { client.speak(text, sessionId) } + + @Test + fun `a real wav lands under media as a tts named file and comes back playable`() = runBlocking { + backend.reply(200, WAV) + + val file = speak()!!.getOrThrow() + + assertEquals("media", file.parentFile?.name) + assertTrue("缓存名该带 tts_ 前缀,实际 ${file.name}", file.name.startsWith("tts_")) + assertTrue("缓存名该是 .wav,实际 ${file.name}", file.name.endsWith(".wav")) + assertArrayEquals(WAV, file.readBytes()) + // 令牌确实是本层挂上去的那一条(owner Bearer,不是 API Key)。 + assertEquals("Bearer $TOKEN", backend.headers.single()["authorization"]) + } + + @Test + fun `the same text a second time sends no request at all`() = runBlocking { + backend.reply(200, WAV) + + val first = speak()!!.getOrThrow() + val second = speak()!!.getOrThrow() + + assertEquals(first, second) + assertEquals("第二次该命中缓存,一个请求都不发", 1, backend.requestLines.size) + } + + @Test + fun `a json envelope served with 200 is rejected instead of fed to the player`() = runBlocking { + // 头照写 audio/wav —— 恰恰证明判据不能落在 Content-Type 上。 + backend.reply(200, """{"error":{"code":"tts_failed","message":"合成失败"}}""".toByteArray()) + + val failure = speak()!!.exceptionOrNull() + + assertTrue("200 却不是 WAV 该判 SpeechNotAudioException,实际 $failure", failure is SpeechNotAudioException) + // 绝不落盘:一份不是音频的缓存会在下一次「命中」并被喂给播放器。 + assertNoCacheFile() + } + + @Test + fun `an empty body is a parse failure rather than a silent clip`() = runBlocking { + backend.reply(200, ByteArray(0)) + + val failure = speak()!!.exceptionOrNull() + + assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error) + assertNoCacheFile() + } + + @Test + fun `a persona without a voice keeps the server code instead of flattening it`() = runBlocking { + backend.reply(500, """{"error":{"code":"$CODE_PERSONA_UNMAPPED","message":"人格没有发音人映射"}}""".toByteArray()) + + val failure = speak()!!.exceptionOrNull() as CyreneException + + val server = failure.error as CyreneError.Server + assertEquals(500, server.httpStatus) + assertEquals(CODE_PERSONA_UNMAPPED, server.code) + assertNoCacheFile() + } + + @Test + fun `blank text fails locally without a round trip`() = runBlocking { + val failure = speak(text = " \n ")!!.exceptionOrNull() + + assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), (failure as CyreneException).error) + assertTrue("空文本不该发请求", backend.requestLines.isEmpty()) + } + + @Test + fun `two sessions with the same text are two different clips`() = runBlocking { + backend.reply(200, WAV) + + val web = speak(sessionId = "web")!!.getOrThrow() + val phone = speak(sessionId = "phone")!!.getOrThrow() + + // 发音人由会话的生效人格决定 ⇒ 同一段文字在不同会话里可以是两个声音,缓存名必须分开。 + assertFalse("不同会话的同一段文字不该撞同一个缓存名", web == phone) + assertEquals(2, backend.requestLines.size) + } + + @Test + fun `wav sniffing looks at the bytes and nothing else`() { + assertTrue(isWavBytes(WAV)) + assertFalse("11 字节连 RIFF 头都不完整", isWavBytes(WAV.copyOf(11))) + assertFalse("空数组", isWavBytes(ByteArray(0))) + // RIFX 是 RIFF 的大端变体,不是本端承诺的产物形状。 + assertFalse("RIFX 不是 WAVE", isWavBytes("RIFX".toByteArray() + WAV.copyOfRange(4, WAV.size))) + // 头对了但第 8 字节起不是 WAVE:RIFF 容器里装的是别的东西。 + assertFalse("RIFF 里不是 WAVE", isWavBytes("RIFF".toByteArray() + ByteArray(WAV.size - 4))) + } + + private fun assertNoCacheFile() { + val media = File(cacheRoot, "media") + val leftovers = media.listFiles()?.filter { it.name.startsWith("tts_") }.orEmpty() + assertTrue("失败时不该留下缓存文件,实际 $leftovers", leftovers.isEmpty()) + } + + /** 照着 `reply(...)` 回话的假 speech 端点:记下**请求行原文**与请求头原文。 */ + private inner class FakeSpeechBackend : AutoCloseable { + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + executor = Executors.newFixedThreadPool(2) + createContext("/api/v1/audio/speech", ::handle) + start() + } + + val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" + + val requestLines = CopyOnWriteArrayList() + + /** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */ + val headers = CopyOnWriteArrayList>() + + @Volatile + private var status = 200 + + @Volatile + private var payload = WAV + + fun reply(status: Int, body: ByteArray) { + this.status = status + this.payload = body + } + + private fun handle(exchange: HttpExchange) { + requestLines += "${exchange.requestMethod} ${exchange.requestURI}" + headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") } + exchange.requestBody.readBytes() + // 一律回 audio/wav:判据必须落在字节上,本测试**故意**让头说谎。 + exchange.responseHeaders.add("Content-Type", "audio/wav") + exchange.sendResponseHeaders(status, payload.size.toLong()) + exchange.responseBody.use { it.write(payload) } + } + + override fun close() { + server.stop(0) + } + } + + private companion object { + const val SESSION_ID = "web" + const val TOKEN = "test-token" + const val TIMEOUT_MILLIS = 10_000L + const val CODE_PERSONA_UNMAPPED = "tts_persona_unmapped" + + /** 一段形状正确的最小 WAV(`RIFF` + 长度 + `WAVE` + 40 字节的 `fmt `/`data` 区)。 */ + val WAV: ByteArray = + "RIFF".toByteArray(Charsets.US_ASCII) + + byteArrayOf(36, 0, 0, 0) + + "WAVE".toByteArray(Charsets.US_ASCII) + + ByteArray(40) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeakTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeakTest.kt new file mode 100644 index 0000000..8e45811 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceSpeakTest.kt @@ -0,0 +1,178 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.domain.parse.parse +import java.io.IOException + +/** + * 「朗读」这条路上本端要说的那几句话与纯判据([ttsTextOf] / [ttsFailureOf] / [ttsClipIdOf])。 + * + * 这个文件全是纯函数,不碰网络也不碰 Android:取字节在 `SpeechClientTest`(真 HTTP), + * 图标与播放接线在界面层。 + * + * ## 它钉住的四件事 + * 1. **念什么**:只念正文(`body`)——抬头(`head`)像字幕、旁白(`tail`)是舞台指示,都不念; + * 一条消息切成多个气泡时按 `\n` 拼,空正文不产生空行。 + * 2. **能不能念**:「这条消息有没有可念的正文」是唯一判据,`ChatViewModel.speak`(发不发请求)与 + * `MessageBubble`(画不画图标)用的必须是同一个答案;`mode` 不是 `chat` 时念的是**原文**, + * 因为那时气泡里放的就是原文、一个 markup 都不解析。 + * 3. **失败算哪一档**:只有服务端明确回了「配置缺失」类 `code` 才进 + * [TtsFailure.Unconfigured](重试永远不会成功,文案要说清缺什么、且不提重试), + * 其余一切(网络、解析、401、`voice_unavailable` / `tts_failed` / `tts_empty`)都是 + * [TtsFailure.Soft]。 + * 4. **播放键**:`tts:`,与语音副本的键(就是 `messageId`)分得开。 + */ +class VoiceSpeakTest { + + // ---- 念什么:气泡 → 文本 ------------------------------------------------ + + @Test + fun `bubbles are joined by a newline so the pause lands between paragraphs`() { + assertEquals("你好\n再见", ttsTextOf(parse("

你好

再见

"))) + } + + @Test + fun `an empty message has nothing to read out loud`() { + assertNull(ttsTextOf(parse(""))) + assertNull(ttsTextOf(emptyList())) + } + + @Test + fun `narration alone is stage direction and is not read out loud`() { + // 旁白整段进 head(首部依附),正文为空 ⇒ 没有可念的字。 + assertNull(ttsTextOf(parse("

她笑了笑

"))) + } + + @Test + fun `the speaker head is skipped and only the body is read`() { + val bubbles = parse("

她笑了笑你好

") + + assertEquals("你好", ttsTextOf(bubbles)) + } + + @Test + fun `a blank bubble does not become a blank line`() { + assertEquals("你好\n再见", ttsTextOf(parse("

你好

再见

"))) + } + + // ---- 能不能念:一条消息 → 文本 ------------------------------------------ + + @Test + fun `a chat message is read after the markup is stripped`() { + assertEquals("你好\n再见", ttsTextOf(message(content = "

你好

再见

", mode = "chat"))) + } + + @Test + fun `a message without a mode counts as a chat message`() { + assertEquals("你好", ttsTextOf(message(content = "

你好

", mode = null))) + assertEquals("你好", ttsTextOf(message(content = "

你好

", mode = " "))) + } + + @Test + fun `a non chat mode is read verbatim because the bubble shows the raw text`() { + // 非 chat 的 mode 下气泡里放的就是原文(MessageBubble 的 isChat 判据), + // 所以朗读念的也必须是那同一段字,一个标签都不许吞。 + assertEquals("

你好

", ttsTextOf(message(content = "

你好

", mode = "roleplay"))) + assertEquals("你好", ttsTextOf(message(content = " 你好 ", mode = "roleplay"))) + } + + @Test + fun `a message with nothing to say answers null in both modes`() { + assertNull(ttsTextOf(message(content = "", mode = "chat"))) + assertNull(ttsTextOf(message(content = " ", mode = "roleplay"))) + assertNull(ttsTextOf(message(content = "

她笑了笑

", mode = "chat"))) + } + + // ---- 失败算哪一档 ------------------------------------------------------ + + @Test + fun `a persona without a voice mapping says so and is not worth retrying`() { + val failure = ttsFailureOf(serverError(TTS_CODE_PERSONA_UNMAPPED)) + + assertEquals(TtsFailure.Unconfigured(TTS_CODE_PERSONA_UNMAPPED), failure) + assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, failure.message) + assertFalse("配置缺失类重试永远不会成功", failure.retryable) + } + + @Test + fun `a session without any character says so instead of blaming the network`() { + val failure = ttsFailureOf(serverError(TTS_CODE_NO_CHARACTER)) + + assertEquals(TTS_NO_CHARACTER_MESSAGE, failure.message) + assertFalse(failure.retryable) + } + + @Test + fun `an unrecognized code is not guessed into the configuration bucket`() { + // 本端认不出这个 code,就**不知道**它是配置缺失还是临时故障 ⇒ 不猜,落柔和档: + // 判成「配置没配好」等于对用户说了一句本端没有依据的话。 + val failure = ttsFailureOf(serverError("tts_something_new")) + + assertEquals(TtsFailure.Soft, failure) + assertTrue(failure.retryable) + } + + @Test + fun `a configuration failure built from an unknown code still explains itself`() { + // 兜底分支:万一将来有调用点直接构造了一个本端还不认识的配置缺失码, + // 文案仍是「缺发音人」而不是「网络不好」,且照样不提重试。 + val failure = TtsFailure.Unconfigured("tts_something_new") + + assertEquals(TTS_UNCONFIGURED_MESSAGE, failure.message) + assertFalse(failure.retryable) + } + + @Test + fun `a server that is merely unavailable right now stays in the soft bucket`() { + listOf("voice_unavailable", "tts_failed", "tts_empty").forEach { code -> + val failure = ttsFailureOf(serverError(code)) + + assertEquals("$code 该是柔和档", TtsFailure.Soft, failure) + assertEquals(TTS_SOFT_FAILURE_MESSAGE, failure.message) + assertTrue("$code 过一会儿再点是有意义的", failure.retryable) + } + } + + @Test + fun `an expired token is soft because renewal can still save it`() { + val failure = ttsFailureOf(CyreneException(CyreneError.Unauthorized(code = "", message = ""))) + + assertEquals(TtsFailure.Soft, failure) + } + + @Test + fun `a network or parse failure is soft and does not pretend to know why`() { + assertEquals(TtsFailure.Soft, ttsFailureOf(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))) + assertEquals(TtsFailure.Soft, ttsFailureOf(IOException("boom"))) + // 非 CyreneException 一律柔和档:本端确实不知道断在哪一段。 + assertEquals(TtsFailure.Soft, ttsFailureOf(IllegalStateException("boom"))) + } + + // ---- 播放键 ------------------------------------------------------------ + + @Test + fun `the clip key is namespaced so it never collides with a voice copy`() { + assertEquals("tts:m-1", ttsClipIdOf("m-1")) + assertFalse("朗读与语音副本的键必须分得开", ttsClipIdOf("m-1") == "m-1") + } + + private fun serverError(code: String) = CyreneException( + CyreneError.Server(code = code, message = "服务端说明", httpStatus = 500), + ) + + private fun message(content: String, mode: String?) = HistoryMessageDto( + id = "m-1", + sessionId = "web", + role = "assistant", + content = content, + mode = mode, + createdAt = 0L, + ) +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/TtsSpeakStateTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/TtsSpeakStateTest.kt new file mode 100644 index 0000000..22db73e --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/TtsSpeakStateTest.kt @@ -0,0 +1,192 @@ +package top.yeij.cyrene.ui.chat + +import java.io.File +import java.io.IOException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.domain.chat.TTS_CODE_NO_CHARACTER +import top.yeij.cyrene.domain.chat.TTS_CODE_PERSONA_UNMAPPED +import top.yeij.cyrene.domain.chat.TTS_NO_CHARACTER_MESSAGE +import top.yeij.cyrene.domain.chat.TTS_PERSONA_UNMAPPED_MESSAGE +import top.yeij.cyrene.domain.chat.TTS_SOFT_FAILURE_MESSAGE +import top.yeij.cyrene.domain.chat.ttsClipIdOf + +/** + * 朗读那一格([ChatUiState.ttsClip])的**状态机**,纯函数部分。 + * + * VM 本身拖不进 JVM 测试(它要 `AppContainer`、`viewModelScope`,工程里也没有 coroutines-test), + * 所以这条路上能钉、也最该钉的判据都被抽成了两个纯函数:放行与否在 [beginTtsSpeak],落位在 + * [applyTtsOutcome]。四件事各自一组: + * + * 1. **防抖**:同一条正在飞时再点一次必须被忽略(否则每点一次就是一次合成请求); + * 2. **重放 / 重试放行**:已经落好的是「再放一遍」、失败过的是「用户重试」,都不算在飞; + * 3. **过期结果丢弃**:结果回来时这一格已经换人了,一个字节都不许写回去 —— + * 否则会出现「点了 B,界面在 A 下面报错」; + * 4. **失败不抬横幅**:朗读失败只落在这一格里,`error` 必须原样是 null + * (那是发送失败 / 队列满的通道,抬上去会盖住真错误)。 + */ +class TtsSpeakStateTest { + + private val messageId = "m-1" + private val clipId = ttsClipIdOf(messageId) + private val audio = File("tts_m-1.wav") + + private fun server(code: String, status: Int = 500) = + CyreneException(CyreneError.Server(code, "boom", status)) + + // —— 放行与否 —— + + @Test + fun `a click on an idle state starts a pending clip`() { + val begun = ChatUiState().beginTtsSpeak(messageId, clipId) + + assertNotNull("空闲时点朗读必须放行", begun) + val clip = begun!!.ttsClip!! + assertEquals(messageId, clip.messageId) + assertEquals(clipId, clip.clipId) + assertNull("还没结果", clip.file) + assertNull("还没失败", clip.failure) + assertTrue("这一格必须是 pending(图标据此画成正在准备)", clip.pending) + assertTrue("字节到手那一刻要起播一次", clip.autoPlay) + } + + @Test + fun `a second click while the request is in flight is ignored`() { + val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!! + + assertNull("同一条正在飞时再点一次必须被忽略", pending.beginTtsSpeak(messageId, clipId)) + } + + @Test + fun `a click after the bytes landed is a replay, not a duplicate`() { + val played = ChatUiState().applyTtsOutcome(messageId, clipId, Result.success(audio)) + + val replay = played.beginTtsSpeak(messageId, clipId) + + assertNotNull("已经落好的是「再放一遍」,不是重复请求", replay) + assertNull("重放要重新等一次结果(缓存命中不会真发请求)", replay!!.ttsClip!!.file) + assertTrue("再放一遍也必须重新起播", replay.ttsClip.autoPlay) + } + + @Test + fun `a click after a failure is the user retrying`() { + val failed = ChatUiState().applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))) + + val retry = failed.beginTtsSpeak(messageId, clipId) + + assertNotNull("失败过的那一条必须放行(用户再点一次就是那次重试)", retry) + assertNull("重试把上一次的文案清掉,否则「准备中」下面还挂着上一句失败", retry!!.ttsClip!!.failure) + } + + @Test + fun `clicking another message replaces the single slot`() { + val other = "m-2" + val pendingA = ChatUiState().beginTtsSpeak(messageId, clipId)!! + + val pendingB = pendingA.beginTtsSpeak(other, ttsClipIdOf(other))!! + + assertEquals("这一格只有一份:换人就整格替换", ttsClipIdOf(other), pendingB.ttsClip!!.clipId) + assertEquals(other, pendingB.ttsClip.messageId) + } + + // —— 结果落位 —— + + @Test + fun `success lands the file and keeps autoplay armed`() { + val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!! + + val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio)) + val clip = done.ttsClip!! + + assertSame(audio, clip.file) + assertNull(clip.failure) + assertFalse("播放交给那条唯一的通道,这一格不需要知道走到哪了", clip.retryable) + assertTrue("渲染层的起播闸门只看这一位:置假就永远不响", clip.autoPlay) + assertFalse("有文件就不再是 pending", clip.pending) + } + + @Test + fun `a result for a message the user has already left is discarded`() { + val other = "m-2" + val pendingB = ChatUiState().beginTtsSpeak(other, ttsClipIdOf(other))!! + + // A 的结果晚到了:这一格已经是 B。 + assertSame( + "过期的成功结果不许写回去", + pendingB, + pendingB.applyTtsOutcome(messageId, clipId, Result.success(audio)), + ) + assertSame( + "过期的失败结果更不许写回去(否则会点了 B、在 A 下面报错)", + pendingB, + pendingB.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))), + ) + } + + @Test + fun `a result arriving after the slot was cleared is discarded`() { + val cleared = ChatUiState() + + assertSame(cleared, cleared.applyTtsOutcome(messageId, clipId, Result.success(audio))) + } + + @Test + fun `an unconfigured failure names what is missing and says nothing about retrying`() { + val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!! + + val unmapped = pending.applyTtsOutcome( + messageId, + clipId, + Result.failure(server(TTS_CODE_PERSONA_UNMAPPED)), + ).ttsClip!! + val noCharacter = pending.applyTtsOutcome( + messageId, + clipId, + Result.failure(server(TTS_CODE_NO_CHARACTER)), + ).ttsClip!! + + assertEquals(TTS_PERSONA_UNMAPPED_MESSAGE, unmapped.failure) + assertEquals(TTS_NO_CHARACTER_MESSAGE, noCharacter.failure) + assertFalse("配置缺失这一档重试永远不会成功", unmapped.retryable) + assertFalse(noCharacter.retryable) + assertNull("失败就是失败:不许留着上一次的文件", unmapped.file) + assertFalse("失败没有可起播的东西", unmapped.pending) + } + + @Test + fun `a transient failure stays soft and retryable`() { + val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!! + + val offline = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))).ttsClip!! + val expired = pending.applyTtsOutcome( + messageId, + clipId, + Result.failure(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"))), + ).ttsClip!! + + assertEquals(TTS_SOFT_FAILURE_MESSAGE, offline.failure) + assertTrue(offline.retryable) + // 令牌过期续期后还能用 ⇒ 也是柔和档,不许说成「配置没配好」。 + assertEquals(TTS_SOFT_FAILURE_MESSAGE, expired.failure) + assertTrue(expired.retryable) + } + + @Test + fun `neither outcome raises the page banner`() { + val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!! + + val failed = pending.applyTtsOutcome(messageId, clipId, Result.failure(IOException("offline"))) + val done = pending.applyTtsOutcome(messageId, clipId, Result.success(audio)) + + // 朗读是一次附带的小动作:它失败不该盖住真正的发送失败 / 队列满提示。 + assertNull("失败只挂在这条消息下面,不抬成横幅", failed.error) + assertNull(done.error) + } +}