W75 她的消息朗读 —— /audio/speech 只走 owner Bearer,字节嗅探 WAV,缓存落 media/

后端回执三十八 §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 <owner JWT>
  **或** 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_<sha256("sessionId\ntext")[:32]>.wav,落 VoiceStore.directory(cacheDir)
  即 <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:<messageId> 与语音副本的键
  分得开 ⇒ 「念这一条」与「放这条消息的语音副本」天然互斥,同一时刻只有一个声音。
- 起播在渲染层那个 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) 点不动,
没有做「取消在飞请求」。
This commit is contained in:
AskaEth committed 2026-10-26 23:25:42 +08:00
1 parent 53d7a9fb69
commit 504c500e20
11 files changed
+1473 -1

No files matched your search

@@ -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 <owner JWT>` **或** `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<ResponseBody>
}
@@ -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
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。
*
* ## 缓存:`<cacheDir>/media/tts_<hash>.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<File> = 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<File> = 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_<sha256(sessionId \n text)[:32]>.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"
}
}
}
@@ -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<ParsedBubble>): 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:<messageId>`。
*
* 播放通道只有一条(`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"
@@ -506,6 +506,27 @@ fun ChatScreen(
}
}
// 朗读(W75):字节一落到本机就交给**同一条**播放通道起播(键是 `tts:<messageId>`,
// 与语音副本天然互斥——点一个停另一个)。键里带 `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:<messageId>`,所以两枚
// 图标的「正在响」互不串台(判据在气泡里,这里只喂状态)。
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(
@@ -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<File>,
): 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<ChatDisplayItem>
@@ -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] 共用)。
*
@@ -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),
)
}
}
}
@@ -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:<messageId>`,见 `domain.chat.ttsClipIdOf`)。这一格只负责「字节有没有到手、
* 上一次为什么没到手」。分成两处是必须的:播放器在响的时候这一格可以纹丝不动(它不需要知道
* `MediaPlayer` 走到了哪),而用户点了另一条语音副本时,播放通道换键、这一格照旧留着 —— 两件事
* 本来就不是一件事。
*
* @property messageId 这条朗读属于哪条消息(失败文案据此只挂在那一条下面)。
* @property clipId 播放通道上的键(`tts:<messageId>`):与语音副本的键(= 它的 `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
}
@@ -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 <baseUrl>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<String>()
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
val headers = CopyOnWriteArrayList<Map<String, String>>()
val bodies = CopyOnWriteArrayList<String>()
@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)
}
}
@@ -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. **落盘位置是 `<cacheDir>/media/tts_<hash>.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<File>? = 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<String>()
/** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */
val headers = CopyOnWriteArrayList<Map<String, String>>()
@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)
}
}
@@ -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>`,与语音副本的键(就是 `messageId`)分得开。
*/
class VoiceSpeakTest {
// ---- 念什么:气泡 → 文本 ------------------------------------------------
@Test
fun `bubbles are joined by a newline so the pause lands between paragraphs`() {
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p>再见</p>")))
}
@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("<p><narration>她笑了笑</narration></p>")))
}
@Test
fun `the speaker head is skipped and only the body is read`() {
val bubbles = parse("<p><narration>她笑了笑</narration>你好</p>")
assertEquals("你好", ttsTextOf(bubbles))
}
@Test
fun `a blank bubble does not become a blank line`() {
assertEquals("你好\n再见", ttsTextOf(parse("<p>你好</p><p> </p><p>再见</p>")))
}
// ---- 能不能念:一条消息 → 文本 ------------------------------------------
@Test
fun `a chat message is read after the markup is stripped`() {
assertEquals("你好\n再见", ttsTextOf(message(content = "<p>你好</p><p>再见</p>", mode = "chat")))
}
@Test
fun `a message without a mode counts as a chat message`() {
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = null)))
assertEquals("你好", ttsTextOf(message(content = "<p>你好</p>", mode = " ")))
}
@Test
fun `a non chat mode is read verbatim because the bubble shows the raw text`() {
// 非 chat 的 mode 下气泡里放的就是原文(MessageBubble 的 isChat 判据),
// 所以朗读念的也必须是那同一段字,一个标签都不许吞。
assertEquals("<p>你好</p>", ttsTextOf(message(content = "<p>你好</p>", 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 = "<p><narration>她笑了笑</narration></p>", 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,
)
}
@@ -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)
}
}