Merge branch 'feat/w75-tts-speech-bearer-playback' into android
This commit is contained in:
commit
6aa434dad5
11 files changed
+1745
-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,219 @@
|
||||
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`,同一段文字一个请求都不发第二次
|
||||
* 名字按 **(baseUrl, sessionId, text)** 内容寻址(sha256 前 32 位十六进制),复用 [DocumentCache] 的
|
||||
* 三条规则(`isSafeName` / `isHit` / 原子 `write`),目录复用 [VoiceStore.directory](`media/`,
|
||||
* 与 `voice.url` 的 `/api/v1/media/` 同源,排查时对得上号;且它**不在** FileProvider 暴露的
|
||||
* `documents/` 根下,不扩大暴露面一个字节)。
|
||||
*
|
||||
* `baseUrl` 进哈希是 P2-2 的修法:换一台服务器后,同一个 `(sessionId, text)` 在那边可能是**另一个
|
||||
* 声音**(人格映射 / 发音人 / 合成实现都是那台服务器的),命中上一台合成的那份 WAV 就是念错人。
|
||||
*
|
||||
* ⚠️ 与语音副本的缓存**有一处关键差别**:副本的名字由服务端按内容决定 ⇒ 命中永不失效;而这里的
|
||||
* 名字由**请求参数**决定,服务端换了发音人 / 换了合成实现之后,同一段文字应该能念出不一样的声音。
|
||||
* 因此这里把 `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, baseUrlProvider()))
|
||||
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(baseUrl \n sessionId \n text)[:32]>.wav`。
|
||||
*
|
||||
* 带上 `sessionId` 是**必须的**:发音人由会话的生效人格决定,不同会话的同一段文字
|
||||
* 可能是两个声音。带上 `baseUrl` 同样是必须的:换一台服务器(同一个会话 id、同一段文字)
|
||||
* 命中的会是**上一台服务器**合成的那份 WAV —— 那一台上的人格映射 / 发音人 / 合成实现
|
||||
* 可能完全不同,播出来是别人的声音。缓存是 `cacheDir` 下的东西、系统随时可回收,
|
||||
* 换地址后重新请求一次的代价远小于「念错了声音却看不出为什么」。
|
||||
*
|
||||
* 地址先 `trimEnd('/')` 归一:`http://a/` 与 `http://a` 是同一台服务器,不该分成两份缓存。
|
||||
* `\n` 做分隔符,免得 `(a, "bc")` 与 `(ab, "c")` 撞成同一个名字。
|
||||
*/
|
||||
fun cacheName(text: String, sessionId: String?, baseUrl: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(
|
||||
"${baseUrl.trimEnd('/')}\n${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,33 @@ 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) }
|
||||
}
|
||||
}
|
||||
// 这一屏**走了**就把还没到手的那一次自动起播作废(P2-1)。上面那个 effect 只在这一屏活着的
|
||||
// 时候跑得到:人切走之后结果才落地,闸门没被消费过,切回来时一挂上就会突然响一段旧朗读。
|
||||
// 判据在纯函数 [dropPendingTtsAutoPlay] 里(只动「还没到手」的那一格,绝不打断真的播放)。
|
||||
DisposableEffect(viewModel) {
|
||||
onDispose { viewModel.dropPendingTtsAutoPlay() }
|
||||
}
|
||||
|
||||
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
|
||||
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
|
||||
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
|
||||
@@ -759,6 +786,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 +806,20 @@ fun ChatScreen(
|
||||
onEdit = viewModel::startEdit,
|
||||
onDelete = viewModel::startDelete,
|
||||
onVoiceToggle = voicePlayer::toggle,
|
||||
// 朗读:请求、失败分类、落盘全在 VM;但**这一下是停还是念**必须在
|
||||
// 这里分(判据是纯函数 [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(
|
||||
|
||||
@@ -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,76 @@ 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 下面报错」。
|
||||
*
|
||||
* `autoPlay` **从旧那一格继承**而不是重置成真:用户可能在请求落地之前就离开了这一屏
|
||||
* ([ChatUiState.dropPendingTtsAutoPlay] 把这一位置了假),结果晚到之后若又把它抬回真,
|
||||
* 切回来时就会突然响一段刚才点的朗读 —— 那正是要拦的那件事。
|
||||
*
|
||||
* 失败**只**落在 [ChatUiState.ttsClip] 上,**不写** [ChatUiState.error] 横幅:朗读是一次附带的
|
||||
* 小动作,把它抬成页面横幅会盖住真正的发送失败。分类交给 `domain.chat.ttsFailureOf`。
|
||||
*/
|
||||
fun ChatUiState.applyTtsOutcome(
|
||||
messageId: String,
|
||||
clipId: String,
|
||||
result: Result<File>,
|
||||
): ChatUiState {
|
||||
val current = ttsClip
|
||||
if (current == null || current.clipId != clipId) return this
|
||||
val autoPlay = current.autoPlay
|
||||
return result.fold(
|
||||
onSuccess = { copy(ttsClip = TtsClip(messageId, clipId, it, null, false, autoPlay)) },
|
||||
onFailure = { error ->
|
||||
val failure = ttsFailureOf(error)
|
||||
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 {
|
||||
val key: String
|
||||
|
||||
@@ -857,6 +931,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 +1707,92 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一屏走了:把**还没到手**的那一次自动起播作废(判据与取舍全在纯函数
|
||||
* [dropPendingTtsAutoPlay] 里)。
|
||||
*
|
||||
* 由渲染层的 `DisposableEffect.onDispose` 调:起播闸门只在这一屏活着的时候跑得到,人走了
|
||||
* 而结果后到,切回来时就不该突然响一段旧朗读。
|
||||
*/
|
||||
fun dropPendingTtsAutoPlay() {
|
||||
_state.update { it.dropPendingTtsAutoPlay() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「把这段字念出来」的取字节层。**懒建**:没点过朗读的会话一个 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,103 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
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`)。
|
||||
*
|
||||
* ## 为什么它住在 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点了一下那枚朗读图标,**这一下该被当成什么**。
|
||||
*
|
||||
* 只有两档,因为只有两件事会发生:要么「停」,要么「(重新)念」。把它抽成纯函数是因为
|
||||
* 判据跨了三处状态(这一格的 [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
|
||||
}
|
||||
}
|
||||
@@ -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,253 @@
|
||||
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 唯一
|
||||
* 暴露出去的根),名字按 `(baseUrl, sessionId, text)` 内容寻址。
|
||||
* 4. **同一段文字一个请求都不发第二次**(用请求计数断言,不是「文件存在」)。
|
||||
* 5. **空文本连请求都不发**:服务端对它只会回 `400`,本端没必要走一次往返。
|
||||
* 6. **非 2xx 保留服务端那句 `code`**:`tts_persona_unmapped` / `tts_no_character` 这两档的文案
|
||||
* 完全不同,折成一句话就说不清了。
|
||||
* 7. **换服务器不是命中**(P2-2):同一个 `(sessionId, text)` 在另一台服务器上可能是另一个声音,
|
||||
* 而地址末尾多写一个 `/` 必须**还是**同一份缓存(`trimEnd('/')` 归一)。
|
||||
*/
|
||||
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 `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
|
||||
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,308 @@
|
||||
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.VoicePlaybackState
|
||||
import top.yeij.cyrene.domain.chat.ttsClipIdOf
|
||||
|
||||
/**
|
||||
* 朗读那一格([ChatUiState.ttsClip])的**状态机**,纯函数部分。
|
||||
*
|
||||
* VM 本身拖不进 JVM 测试(它要 `AppContainer`、`viewModelScope`,工程里也没有 coroutines-test),
|
||||
* 所以这条路上能钉、也最该钉的判据都被抽成了纯函数:放行与否在 [beginTtsSpeak],落位在
|
||||
* [applyTtsOutcome],这一下点击是「停」还是「念」在 [ttsSpeakIntent],离开这一屏时作废在
|
||||
* [dropPendingTtsAutoPlay]。六件事各自一组:
|
||||
*
|
||||
* 1. **防抖**:同一条正在飞时再点一次必须被忽略(否则每点一次就是一次合成请求);
|
||||
* 2. **重放 / 重试放行**:已经落好的是「再放一遍」、失败过的是「用户重试」,都不算在飞;
|
||||
* 3. **过期结果丢弃**:结果回来时这一格已经换人了,一个字节都不许写回去 ——
|
||||
* 否则会出现「点了 B,界面在 A 下面报错」;
|
||||
* 4. **失败不抬横幅**:朗读失败只落在这一格里,`error` 必须原样是 null
|
||||
* (那是发送失败 / 队列满的通道,抬上去会盖住真错误);
|
||||
* 5. **播放中再点 = 停**(P1-1):判据只认那条唯一的播放通道,不认 `pending`;
|
||||
* 6. **离开这一屏就作废还没到手的自动起播**(P2-1),而且结果晚到不许把它抬回真。
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
// —— 这一下点击是「停」还是「念」(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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user