fix(w75): 让「停止朗读」真的能停,切走不再补响旧朗读,缓存键带上 baseUrl

修 rev-w75 的三条发现(M5 / W75 TTS 朗读)。

P1-1 「停止朗读是空操作」—— 根因是三处合谋,谁都没错、合起来没反应:
MessageBubble 在播放中画暂停符、读屏名字是 TTS_STOP_DESCRIPTION,可 onSpeak 直通
viewModel::speak;而 beginTtsSpeak 的防抖只认 pending,播放中文件早就到手 ⇒ 不是
pending ⇒ 放行;整格被换成新的 pending 后缓存命中秒回,起播 effect 又因为「已经在放
同一条」跳过 toggleClip —— 唯一带停止语义的那条路径根本没走到。

改法:把「这一下是停还是念」抽成纯函数 ttsSpeakIntent(playback, messageId, clip),
判据落在那条唯一播放通道上(它是「有没有在响」的唯一真相),不再让三处各自解释
「播放中」是什么意思。ChatScreen 的 onSpeak 据它分派:Stop -> voicePlayer.stop(),
Speak -> viewModel.speak(message)。另需 clip.file != null:通道处于 Loading 时也算
active,但那一刻文件可能还没到手,判成 Stop 会停掉一次没起播的载入。

P2-1 「切走再切回补响旧朗读」—— 改成按「这一屏还在不在」判,而不是按时间:新增纯函数
ChatUiState.dropPendingTtsAutoPlay()(只对 autoPlay && file == null 的那一格置假),
由 ChatScreen 的 DisposableEffect.onDispose 调用。放弃「记发起时刻 + 超龄丢」是因为
时间不是这件事的判据 —— 合成一段长文本本来就可能花十几秒,按秒数丢会把「人就在看着
屏幕」的那一次也一起丢掉。配套回归点:applyTtsOutcome 的 autoPlay 改成从旧那一格
继承(原先硬写 false);否则作废之后结果晚到又会把闸门抬回真,等于没修。

P2-2 「TTS 缓存键缺 baseUrl」—— cacheName 的哈希输入改为
baseUrl.trimEnd('/') + "\n" + sessionId + "\n" + text。换一台服务器后,同一个
(sessionId, text) 在那边可能是另一个声音(人格映射 / 发音人 / 合成实现都是那台的),
命中上一台合成的 WAV 就是念错人。trimEnd('/') 归一,http://a/ 与 http://a 不分两份
缓存。类 KDoc 与 cacheName 的 KDoc 同步改写。

测试:TtsSpeakStateTest 11 -> 20(+9),SpeechClientTest 8 -> 10(+2)。
全量 testDebugUnitTest --rerun-tasks:138 suites / 1726 tests / 0 failures /
0 errors / 0 skipped(基线 138/1715,+11)。

仍未验:真机出声;请求在飞时没有「取消在飞请求」;(sessionId, text) 这份缓存对服务端
换发音人仍无失效手段(rev-w75 已同意这半边可接受)。
This commit is contained in:
AskaEth committed 2026-10-26 23:52:23 +08:00
1 parent 504c500e20
commit 222f691b78
6 files changed
+287 -15

No files matched your search

@@ -69,11 +69,14 @@ private val MAX_SPEECH_BYTES: Long = DocumentAttachments.MAX_FILE_BYTES
* ——**省略它不等于没有角色**,只是让服务端退到全局默认。 * ——**省略它不等于没有角色**,只是让服务端退到全局默认。
* *
* ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次 * ## 缓存:`<cacheDir>/media/tts_<hash>.wav`,同一段文字一个请求都不发第二次
* 名字按 **(sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的 * 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`, * 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的 * 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
* `documents/` 根下,不扩大暴露面一个字节)。 * `documents/` 根下,不扩大暴露面一个字节)。
* *
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
*
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的 * ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。 * 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
* 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit] * 因此这里把 `tts_` 前缀留成「本端合成缓存」的标记,命中判定仍用 [DocumentCache.isHit]
@@ -135,7 +138,7 @@ class SpeechClient(
CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)), CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)),
) )
} }
val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId)) val file = File(VoiceStore.directory(cacheDir), cacheName(trimmed, sessionId, baseUrlProvider()))
if (DocumentCache.isHit(file)) return@withContext Result.success(file) if (DocumentCache.isHit(file)) return@withContext Result.success(file)
try { try {
@@ -193,14 +196,22 @@ class SpeechClient(
const val NAME_HEX_LENGTH = 32 const val NAME_HEX_LENGTH = 32
/** /**
* 缓存名 = `tts_<sha256(sessionId \n text)[:32]>.wav`。 * 缓存名 = `tts_<sha256(baseUrl \n sessionId \n text)[:32]>.wav`。
* *
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字 * 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
* 可能是两个声音。`\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。 * 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
*
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
*/ */
fun cacheName(text: String, sessionId: String?): String { fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
.digest("${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8)) .digest(
"${baseUrl.trimEnd('/')}\n${sessionId.orEmpty()}\n$text".toByteArray(Charsets.UTF_8),
)
val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH) val hex = digest.joinToString("") { "%02x".format(it) }.take(NAME_HEX_LENGTH)
return NAME_PREFIX + hex + ".wav" return NAME_PREFIX + hex + ".wav"
} }
@@ -526,6 +526,12 @@ fun ChatScreen(
voicePlayer.toggleClip(clip.clipId) { Result.success(file) } voicePlayer.toggleClip(clip.clipId) { Result.success(file) }
} }
} }
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
DisposableEffect(viewModel) {
onDispose { viewModel.dropPendingTtsAutoPlay() }
}
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。 // 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上, // 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
@@ -800,8 +806,20 @@ fun ChatScreen(
onEdit = viewModel::startEdit, onEdit = viewModel::startEdit,
onDelete = viewModel::startDelete, onDelete = viewModel::startDelete,
onVoiceToggle = voicePlayer::toggle, onVoiceToggle = voicePlayer::toggle,
// 朗读:请求、失败分类、落盘全在 VM;这里只转一次用户意图。 // 朗读:请求、失败分类、落盘全在 VM;但**这一下是停还是念**必须在
onSpeak = viewModel::speak, // 这里分(判据是纯函数 [ttsSpeakIntent])。直通 `viewModel::speak`
// 会让「停止朗读」变成空操作(P1-1):播放中 `TtsClip.pending` 是假
// ⇒ VM 的防抖放行、整格换成新 pending、缓存命中秒回,而下面那个起播
// effect 又因为「已经在放同一条」跳过 `toggleClip`——三处各自都对,
// 合起来点了没反应。唯一带停止语义的入口在播放通道上,所以在这一层分。
onSpeak = { message ->
val clip = state.ttsClip
?.takeIf { it.messageId == message.id }
when (ttsSpeakIntent(voicePlayer.state.value, message.id, clip)) {
TtsSpeakIntent.Stop -> voicePlayer.stop()
TtsSpeakIntent.Speak -> viewModel.speak(message)
}
},
) )
is ChatDisplayItem.Pending -> PendingBubble( is ChatDisplayItem.Pending -> PendingBubble(
@@ -662,6 +662,10 @@ fun ChatUiState.beginTtsSpeak(messageId: String, clipId: String): ChatUiState? {
* 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、 * 判据是 [TtsClip.clipId]:这一格已经换成**另一条**消息(用户在这段时间里点了别的)时原样返回、
* 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。 * 一个字节都不写 —— 否则会出现「点了 B,界面在 A 下面报错」。
* *
* `autoPlay` **从旧那一格继承**而不是重置成真:用户可能在请求落地之前就离开了这一屏
* ([ChatUiState.dropPendingTtsAutoPlay] 把这一位置了假),结果晚到之后若又把它抬回真,
* 切回来时就会突然响一段刚才点的朗读 —— 那正是要拦的那件事。
*
* 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的 * 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的
* 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。 * 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。
*/ */
@@ -670,16 +674,42 @@ fun ChatUiState.applyTtsOutcome(
clipId: String, clipId: String,
result: Result<File>, result: Result<File>,
): ChatUiState { ): ChatUiState {
if (ttsClip?.clipId != clipId) return this val current = ttsClip
if (current == null || current.clipId != clipId) return this
val autoPlay = current.autoPlay
return result.fold( return result.fold(
onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false)) }, onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false, autoPlay)) },
onFailure = { error -> onFailure = { error ->
val failure = ttsFailureOf(error) val failure = ttsFailureOf(error)
copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable)) copy(ttsClip = TtsClip(messageId, clipId, null, failure.message, failure.retryable, autoPlay))
}, },
) )
} }
/**
* 这一屏要走了:把**还没到手**的那一次自动起播作废(纯函数,VM 与测试共用)。
*
* ## 为什么需要它
* 起播闸门活在渲染层那个 `LaunchedEffect` 里(见 `ChatScreen`),而它只在**这一屏在的时候**
* 才会被跑一次。用户点了朗读之后立刻切走(换会话 / 进详情页),合成还在飞;结果落地的瞬间这一屏
* 不在,闸门没被消费过;等他切回来,那个 effect 一挂上就以「文件到了 + `autoPlay` 还是真」为由
* 起播 —— 突然响一段**已经过去**的朗读,不是他此刻的动作。
*
* ## 为什么在「离开」这一刻判,而不是给 clip 记一个发起时刻再按超龄丢
* 时间不是这件事的判据:合成一段长文本本来就可能花上十几秒,按秒数丢会把**人就在看着屏幕**
* 的那一次也一起丢掉(点了却不出声)。「这一屏还在不在」才是真正的分界 —— 离开时把没消费的
* 自动起播划掉,正好只拦掉「他不在」的那些。
*
* ## 只动「还没到手」的那一格
* 文件已经在手(`file != null`)说明闸门要么已经跑过(`autoPlay` 已被 [consumeTtsAutoPlay]
* 置假)、要么就是正在起播的这一刻:这两种情况一律原样返回,绝不打断一次真的播放。
*/
fun ChatUiState.dropPendingTtsAutoPlay(): ChatUiState {
val clip = ttsClip ?: return this
if (!clip.autoPlay || clip.file != null) return this
return copy(ttsClip = clip.copy(autoPlay = false))
}
sealed interface ChatDisplayItem { sealed interface ChatDisplayItem {
val key: String val key: String
@@ -1735,6 +1765,17 @@ class ChatViewModel(
} }
} }
/**
* 这一屏走了:把**还没到手**的那一次自动起播作废(判据与取舍全在纯函数
* [dropPendingTtsAutoPlay] 里)。
*
* 由渲染层的 `DisposableEffect.onDispose` 调:起播闸门只在这一屏活着的时候跑得到,人走了
* 而结果后到,切回来时就不该突然响一段旧朗读。
*/
fun dropPendingTtsAutoPlay() {
_state.update { it.dropPendingTtsAutoPlay() }
}
/** /**
* 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造 * 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 OkHttpClient 都不多造
* (与 `AppContainer` 那些按需 getter 同一手法)。 * (与 `AppContainer` 那些按需 getter 同一手法)。
@@ -1,6 +1,9 @@
package top.yeij.cyrene.ui.chat package top.yeij.cyrene.ui.chat
import java.io.File import java.io.File
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.ttsClipIdOf
/** /**
* 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。 * 「把这一条念出来」在**本端**的那一份状态(W75,`POST /api/v1/audio/speech`)。
@@ -48,3 +51,53 @@ data class TtsClip(
/** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */ /** 请求还在飞(字节没到手、也还没有结论):界面据此把图标画成「正在准备」。 */
val pending: Boolean get() = file == null && failure == null val pending: Boolean get() = file == null && failure == null
} }
/**
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
*
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
* 判据跨了三处状态(这一格的 [TtsClip]、那条唯一播放通道的 [VoicePlaybackState]、以及
* `ttsClipIdOf` 铸的键),而它们谁都不该在自己的类里替对方下结论。
*/
sealed interface TtsSpeakIntent {
/** 这一条**正在响**,手上也有它的音频 ⇒ 这一下是「停」。 */
data object Stop : TtsSpeakIntent
/** 其余一切(没在响、响的是别的、失败过、缓存已命中想再听一遍)⇒ 交给 VM。 */
data object Speak : TtsSpeakIntent
}
/**
* 这一下点击是「停」还是「(重新)念」。
*
* ## 为什么必须有这条判据:否则「停止朗读」是空操作
* 图标在播放中画的是暂停符、读屏名字是 `TTS_STOP_DESCRIPTION`(见 `MessageBubble`),所以
* 用户点它时**意思是停**。但播放中 `TtsClip.pending` 是假的(文件早就到手了),于是
* `ChatViewModel.beginTtsSpeak` 的防抖放行、整格被换成新的 pending、缓存命中秒回、
* 起播 effect 又因为「已经在放同一条」而跳过 `toggleClip` —— 三处各自都「没错」,合起来就是
* 点了没反应。判据落在**播放通道**这一处(它是「有没有在响」的唯一真相),才不会有第二处
* 各自解释「播放中」是什么意思。
*
* ## 为什么要 `file != null`
* 「正在响」与「音频在手上」是两件事:通道处于 [VoicePlaybackState.Loading] 时它也算 active,
* 而那一刻 [TtsClip.file] 可能还是 `null`(字节在 VM 那边取)。那时把点击解释成「停」会停掉
* 一次**还没起播**的载入,而用户点它的本意是「念」——所以只有文件确实在手、通道确实在响时才判
* [TtsSpeakIntent.Stop];其余一律 [TtsSpeakIntent.Speak],由 VM 的 pending 分支去防抖。
*
* @param playback 那条唯一播放通道此刻的状态(`VoicePlayer.state`)。
* @param messageId 被点的那条消息。
* @param clip 这一条自己的朗读格(`messageId` 对不上的要当没点过,判据里再核一次)。
*/
fun ttsSpeakIntent(
playback: VoicePlaybackState,
messageId: String,
clip: TtsClip?,
): TtsSpeakIntent {
val file = clip?.takeIf { it.messageId == messageId }?.file
return if (file != null && playback.isActiveFor(ttsClipIdOf(messageId))) {
TtsSpeakIntent.Stop
} else {
TtsSpeakIntent.Speak
}
}
@@ -31,11 +31,13 @@ import java.util.concurrent.Executors
* `Content-Type: audio/wav` —— 头是**不可信**的,判据只落在字节上。 * `Content-Type: audio/wav` —— 头是**不可信**的,判据只落在字节上。
* 2. **0 字节不算「一段静音」**,判解析失败(与 `MediaRepository` 同一口径)。 * 2. **0 字节不算「一段静音」**,判解析失败(与 `MediaRepository` 同一口径)。
* 3. **落盘位置是 `<cacheDir>/media/tts_<hash>.wav`**:不在 `documents/`(那是 FileProvider 唯一 * 3. **落盘位置是 `<cacheDir>/media/tts_<hash>.wav`**:不在 `documents/`(那是 FileProvider 唯一
* 暴露出去的根),名字按 `(sessionId, text)` 内容寻址。 * 暴露出去的根),名字按 `(baseUrl, sessionId, text)` 内容寻址。
* 4. **同一段文字一个请求都不发第二次**(用请求计数断言,不是「文件存在」)。 * 4. **同一段文字一个请求都不发第二次**(用请求计数断言,不是「文件存在」)。
* 5. **空文本连请求都不发**:服务端对它只会回 `400`,本端没必要走一次往返。 * 5. **空文本连请求都不发**:服务端对它只会回 `400`,本端没必要走一次往返。
* 6. **非 2xx 保留服务端那句 `code`**:`tts_persona_unmapped` / `tts_no_character` 这两档的文案 * 6. **非 2xx 保留服务端那句 `code`**:`tts_persona_unmapped` / `tts_no_character` 这两档的文案
* 完全不同,折成一句话就说不清了。 * 完全不同,折成一句话就说不清了。
* 7. **换服务器不是命中**(P2-2):同一个 `(sessionId, text)` 在另一台服务器上可能是另一个声音,
* 而地址末尾多写一个 `/` 必须**还是**同一份缓存(`trimEnd('/')` 归一)。
*/ */
class SpeechClientTest { class SpeechClientTest {
@@ -145,6 +147,37 @@ class SpeechClientTest {
assertEquals(2, backend.requestLines.size) assertEquals(2, backend.requestLines.size)
} }
@Test
fun `the same session and text on another server is not a cache hit`() = runBlocking {
backend.reply(200, WAV)
val here = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
// 换一台服务器:会话 id 与文字都一样,但那边的人格映射 / 发音人 / 合成实现是它自己的。
val elsewhere = FakeSpeechBackend()
try {
elsewhere.reply(200, WAV)
val there = speak(client = client(baseUrl = elsewhere.baseUrl))!!.getOrThrow()
assertFalse("换服务器后不该命中上一台合成的那份 WAV(那是别人的声音)", here == there)
assertEquals("这台各请求一次", 1, backend.requestLines.size)
assertEquals("那台也请求了一次", 1, elsewhere.requestLines.size)
} finally {
elsewhere.close()
}
}
@Test
fun `a trailing slash on the base url is the same server`() = runBlocking {
backend.reply(200, WAV)
val withSlash = speak(client = client(baseUrl = backend.baseUrl))!!.getOrThrow()
val without = speak(client = client(baseUrl = backend.baseUrl.trimEnd('/')))!!.getOrThrow()
// 地址末尾多写一个 `/` 不该让整份合成缓存作废(归一在 `cacheName` 里)。
assertEquals(withSlash, without)
assertEquals(1, backend.requestLines.size)
}
@Test @Test
fun `wav sniffing looks at the bytes and nothing else`() { fun `wav sniffing looks at the bytes and nothing else`() {
assertTrue(isWavBytes(WAV)) assertTrue(isWavBytes(WAV))
@@ -16,21 +16,25 @@ 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_NO_CHARACTER_MESSAGE
import top.yeij.cyrene.domain.chat.TTS_PERSONA_UNMAPPED_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.TTS_SOFT_FAILURE_MESSAGE
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.ttsClipIdOf import top.yeij.cyrene.domain.chat.ttsClipIdOf
/** /**
* 朗读那一格([ChatUiState.ttsClip])的**状态机**,纯函数部分。 * 朗读那一格([ChatUiState.ttsClip])的**状态机**,纯函数部分。
* *
* VM 本身拖不进 JVM 测试(它要 `AppContainer`、`viewModelScope`,工程里也没有 coroutines-test), * VM 本身拖不进 JVM 测试(它要 `AppContainer`、`viewModelScope`,工程里也没有 coroutines-test),
* 所以这条路上能钉、也最该钉的判据都被抽成了两个纯函数:放行与否在 [beginTtsSpeak],落位在 * 所以这条路上能钉、也最该钉的判据都被抽成了纯函数:放行与否在 [beginTtsSpeak],落位在
* [applyTtsOutcome]。四件事各自一组: * [applyTtsOutcome],这一下点击是「停」还是「念」在 [ttsSpeakIntent],离开这一屏时作废在
* [dropPendingTtsAutoPlay]。六件事各自一组:
* *
* 1. **防抖**:同一条正在飞时再点一次必须被忽略(否则每点一次就是一次合成请求); * 1. **防抖**:同一条正在飞时再点一次必须被忽略(否则每点一次就是一次合成请求);
* 2. **重放 / 重试放行**:已经落好的是「再放一遍」、失败过的是「用户重试」,都不算在飞; * 2. **重放 / 重试放行**:已经落好的是「再放一遍」、失败过的是「用户重试」,都不算在飞;
* 3. **过期结果丢弃**:结果回来时这一格已经换人了,一个字节都不许写回去 —— * 3. **过期结果丢弃**:结果回来时这一格已经换人了,一个字节都不许写回去 ——
* 否则会出现「点了 B,界面在 A 下面报错」; * 否则会出现「点了 B,界面在 A 下面报错」;
* 4. **失败不抬横幅**:朗读失败只落在这一格里,`error` 必须原样是 null * 4. **失败不抬横幅**:朗读失败只落在这一格里,`error` 必须原样是 null
* (那是发送失败 / 队列满的通道,抬上去会盖住真错误)。 * (那是发送失败 / 队列满的通道,抬上去会盖住真错误);
* 5. **播放中再点 = 停**(P1-1):判据只认那条唯一的播放通道,不认 `pending`;
* 6. **离开这一屏就作废还没到手的自动起播**(P2-1),而且结果晚到不许把它抬回真。
*/ */
class TtsSpeakStateTest { class TtsSpeakStateTest {
@@ -189,4 +193,116 @@ class TtsSpeakStateTest {
assertNull("失败只挂在这条消息下面,不抬成横幅", failed.error) assertNull("失败只挂在这条消息下面,不抬成横幅", failed.error)
assertNull(done.error) assertNull(done.error)
} }
// —— 这一下点击是「停」还是「念」(P1-1)——
//
// 缺陷现场:图标在播放中画的是暂停符、读屏名字是「停止朗读」,可点击直通 `viewModel.speak`,
// 而 VM 的防抖只认 `pending`(播放中文件早就到手 ⇒ 不是 pending ⇒ 放行),起播 effect 又会
// 因为「已经在放同一条」跳过 `toggleClip` —— 于是「停」这一步根本不存在。
/** 已经落好文件的那一格(= 用户点过一次、字节到手了)。 */
private fun landed(): TtsClip =
ChatUiState()
.beginTtsSpeak(messageId, clipId)!!
.applyTtsOutcome(messageId, clipId, Result.success(audio))
.ttsClip!!
@Test
fun `clicking the clip that is already playing is a stop`() {
val clip = landed()
val intent = ttsSpeakIntent(VoicePlaybackState.Playing(clipId), messageId, clip)
assertEquals("播放中再点一次必须是「停」,否则停止朗读是空操作", TtsSpeakIntent.Stop, intent)
}
@Test
fun `clicking while the channel is still loading this clip is a stop too`() {
val clip = landed()
// 文件已在手、通道正把它交给 MediaPlayer:这一下的意思同样是「别放了」。
assertEquals(
TtsSpeakIntent.Stop,
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, clip),
)
}
@Test
fun `a clip that is not the one playing is a new speak`() {
val clip = landed()
// 正在响的是这条消息的**语音副本**(键 = messageId),不是它的朗读(键 = tts:messageId)。
assertEquals(
"响的是别的键 ⇒ 这一下是念(并且会停掉那一条,见 VoicePlayer.toggleClip)",
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Playing(messageId), messageId, clip),
)
}
@Test
fun `an idle channel with the file in hand is a replay`() {
val clip = landed()
assertEquals(
"放完 / 停掉之后再点同一条是「再放一遍」",
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Idle, messageId, clip),
)
}
@Test
fun `a channel loading this clip without the file yet is not a stop`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!.ttsClip!!
// 文件还没到手:交给 VM 的 pending 分支去防抖,而不是在这一层把一次没起播的载入停掉。
assertEquals(
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Loading(clipId), messageId, pending),
)
}
@Test
fun `a failure on the channel is a retry rather than a stop`() {
val clip = landed()
assertEquals(
TtsSpeakIntent.Speak,
ttsSpeakIntent(VoicePlaybackState.Failed(clipId, "boom"), messageId, clip),
)
}
// —— 离开这一屏就作废还没到手的自动起播(P2-1)——
@Test
fun `leaving the screen disarms an autoplay that has not landed yet`() {
val pending = ChatUiState().beginTtsSpeak(messageId, clipId)!!
val left = pending.dropPendingTtsAutoPlay()
assertFalse("人走了,结果回来时不该突然响", left.ttsClip!!.autoPlay)
assertEquals("作废只动闸门,请求照旧在飞", clipId, left.ttsClip.clipId)
assertTrue(left.ttsClip.pending)
}
@Test
fun `a result landing after the screen was left does not rearm autoplay`() {
val left = ChatUiState().beginTtsSpeak(messageId, clipId)!!.dropPendingTtsAutoPlay()
val done = left.applyTtsOutcome(messageId, clipId, Result.success(audio))
// 关键回归:结果落地时把 `autoPlay` 抬回真的话,切回来照样会响(等于没修)。
assertFalse("作废过的那一次,结果晚到也不许把闸门抬回真", done.ttsClip!!.autoPlay)
assertSame(audio, done.ttsClip.file)
}
@Test
fun `leaving the screen never interrupts a clip whose bytes already landed`() {
val landed = ChatUiState()
.beginTtsSpeak(messageId, clipId)!!
.applyTtsOutcome(messageId, clipId, Result.success(audio))
assertSame("文件已在手 ⇒ 闸门要么跑过了、要么正在起播:一个字节都不许动", landed, landed.dropPendingTtsAutoPlay())
val empty = ChatUiState()
assertSame("空格子同理", empty, empty.dropPendingTtsAutoPlay())
}
} }