W73 语音输入闭环 —— 录音 → voice_input → voice_output(契约 v0.96 §3.15 ①)

上行(用户按住麦克风说话):
- `AudioRecord` 16 kHz / 单声道 / 16-bit PCM → 自封 44 字节 RIFF/WAVE 头。
  不用 `MediaRecorder`:它默认出 m4a/aac,**不在**契约白名单 wav|mp3|ogg 里;
  WAV 纯手写、零新依赖(`domain/chat/VoiceClip.kt` 是纯函数,可穷举单测)。
- 体积闸门 5 MiB(解码后):契约没给 voice_input 的上限,按已知最严的那一档
  (chat.image 的 base64 硬顶)设计;录满 60 秒只用掉 36%,它是第二道闸。
  空 / 太短(<1s)/ 超限三档**明确拒绝并说一句**,绝不静默。
- `voice_input` 补上 `deviceId` / `clientMsgId`(与原骨架的四个键纯增量,
  省略时逐字节不变)。幂等键由纯函数 `voiceClientMsgIdFor` 决定:**重发复用同一个**。
- 乐观气泡与文本同源:同一个 `pending` 列表(`PendingMessage.voiceDurationMs`)、
  同一个 `rollbackPending`;失败时那段 WAV 留在 VM 私有字段里,界面上给「重发 / 丢弃」。
  语音**不进**自动补发队列(几 MB 字节不该钉在队列里等连接)。

下行(她的语音回复):
- `voice_output` 解析补齐 `sessionId`(帧壳没有,从 data 提到帧上,会话闸才真的生效)、
  `deviceId`、`provider`。
- `provider == "silent"`(或 audio 缺席)⇒ **不画播放器**,只显示 reply 文字 + 一句说明。
- `text` = 转写(那段音频被识别成什么),`reply` = **她说的话**(音频念的就是它):
  显示 `reply` 优先、缺席才退到 `text`(纯函数 `voiceReplyText`)。
- 音频 base64 解码落 `cacheDir/media/`,复用 W62 那条 `VoicePlayer` 通道播放
  (新增 `toggleClip`,同一套 MediaPlayer 生命周期与身份比对);自动播放只认第一次
  (切走再回来不会突然响一段旧语音)。

其它:Manifest 加 RECORD_AUDIO(运行时申请,被拒不再重复申请、文案指路系统设置);
UI 全部 Miuix(extended 包的 Mic),录音中整行换成「录音中 0:07 / 取消 / 发送」。

验收:assembleDebug 通过;testDebugUnitTest 全量 133 suites / 1670 tests / 0 failures
(基线 129 / 1636,新增 4 个 suite / 34 个用例)。
This commit is contained in:
AskaEth committed 2026-10-26 19:00:16 +08:00
1 parent e0e657d5ee
commit 4e822df5ed
17 files changed
+2081 -39

No files matched your search

+10
View File
@@ -15,6 +15,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
「用户按住麦克风说话」用的麦克风权限(契约 v0.96 §3.15 ①)。
它是**运行时权限**(minSdk 33 ⇒ 恒是):本端只在用户**第一次点麦克风**时申请,
被拒之后不再重复申请(Android 不会二次弹框,重复申请等于点了没反应),
改由真机设置页授权——文案见 `ui.chat.VOICE_PERMISSION_DENIED_MESSAGE`。
没有这个权限时录音机直接起不来,本端如实说一句,绝不假装在录。
-->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--
联网权限**显式**声明,不靠任何传递依赖。
@@ -0,0 +1,219 @@
package top.yeij.cyrene.data.voice
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.chat.VoiceClip
/** 录音机的两个可见态(界面只认这两个)。 */
sealed interface VoiceRecorderState {
/** 没在录。 */
data object Idle : VoiceRecorderState
/**
* 正在录。
*
* [elapsedMs] 由**已收到的字节数**反算([VoiceClip.durationMillisOf]),不是另起一个计时器——
* 两个时钟一定会漂,而屏幕上那个数字必须与最终发出去的音频长度一致。
*
* [reachedLimit] = 已经录满 [VoiceClip.MAX_RECORDING_MILLIS],此后的字节被**丢弃**(不是
* 悄悄截断已经收到的部分)。UI 看到它应当立刻收尾发送,见本类注释。
*/
data class Recording(val elapsedMs: Int, val reachedLimit: Boolean) : VoiceRecorderState
}
/**
* 麦克风录音机:`AudioRecord` 收 PCM → 交给 [VoiceClip] 封 WAV(本类不碰封装,见那里的 KDoc)。
*
* ## 为什么自己持有一个作用域,而不复用调用方的 `rememberCoroutineScope`
* 读循环必须活到 [stop] 真的收尾:调用方的组合一旦被丢弃(转屏 / 退到后台那一刻重建),
* 挂在它上面的协程会被取消,而 USB/内置麦克风已经交给系统的那一块缓冲就再也取不回来——
* 用户按下停止却拿到一个缺尾的片段。所以读循环跑在这个类自己的 `SupervisorJob + IO` 上,
* 由 [release] 明确收掉。
*
* ## 满了不自动发送,只置位
* 到 [VoiceClip.MAX_RECORDING_MILLIS] 时把 [VoiceRecorderState.Recording.reachedLimit] 置真、
* 继续把多余的字节丢掉。**发送时机由 UI 决定**(它要先把「正在录」那一行换回输入栏,再交给
* VM):本类不知道 VM 也不发帧,这条边界让它在单测里不必造一个 VM。
*
* ## 权限
* 调用方必须先拿到 `RECORD_AUDIO`(运行时权限)。本类**不申请**也不检查:`AudioRecord`
* 在没权限时会抛 `SecurityException`,[start] 把它连同「设备不支持 / 初始化失败」一起收敛成
* `false`,由 UI 按「这台设备现在开不了麦克风」说一句,**绝不静默**。
*/
class VoiceRecorder(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
/** 当前录音状态(界面据此显示「录音中 0:07」)。 */
val state: StateFlow<VoiceRecorderState> = _state.asStateFlow()
private var record: AudioRecord? = null
/** 收集到的 PCM;只有 [start] 建、[stop]/[cancel] 取走或丢掉。 */
private var collected: ByteArrayOutputStream? = null
private var reader: Job? = null
/**
* 录音代次:每次 [start] 自增。
*
* 只服务一件事:读循环的 `finally` 里**只在还属于这一代**时才把状态收回 [VoiceRecorderState.Idle]。
* 少了它,「取消 → 立刻重录」的窄窗口里,上一轮的收尾会把新那一轮的「录音中」抹成「没在录」,
* 而麦克风其实还在收字节——界面上什么都不显示,用户以为自己没在录。
*/
private var generation = 0
@Volatile
private var collecting = false
/**
* 开始录音。
*
* @return `false` = 这台设备现在起不了麦克风(没有权限 / 设备被占用 / 不支持的采样参数)。
* 调用方必须**如实告知**(不得假装在录)。
*/
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (collecting) return false
val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_MASK, ENCODING)
if (minBuffer <= 0) return false
val recorder = runCatching {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(ENCODING)
.setSampleRate(SAMPLE_RATE_HZ)
.setChannelMask(CHANNEL_MASK)
.build(),
)
// 缓冲给到最小值的两倍:读循环偶尔晚一拍时不至于立刻丢帧。
.setBufferSizeInBytes(minBuffer * 2)
.build()
}.getOrNull() ?: return false
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
runCatching { recorder.release() }
return false
}
val started = runCatching { recorder.startRecording() }.isSuccess
if (!started) {
runCatching { recorder.release() }
return false
}
record = recorder
val sink = ByteArrayOutputStream(INITIAL_PCM_BYTES)
collected = sink
collecting = true
val current = ++generation
_state.value = VoiceRecorderState.Recording(elapsedMs = 0, reachedLimit = false)
reader = scope.launch { readLoop(recorder, sink, current) }
return true
}
/**
* 停止并交出这一段 PCM;`null` = 没有可发的字节(没在录 / 读失败 / 全被丢弃)。
*
* `suspend` 是刻意的:必须等读循环真的收尾([reader] 的 `join`)才去读那份缓冲,
* 否则最后一块还没写进去就被取走——用户会听到句尾少一截。
*/
suspend fun stop(): ByteArray? {
if (!collecting) return null
collecting = false
reader?.join()
reader = null
releaseRecorder()
_state.value = VoiceRecorderState.Idle
val sink = collected ?: return null
collected = null
return sink.toByteArray().takeIf { it.isNotEmpty() }
}
/** 取消:丢掉已经收到的字节,一个都不发(用户点了「取消」)。 */
fun cancel() {
collecting = false
reader?.cancel()
reader = null
releaseRecorder()
collected = null
_state.value = VoiceRecorderState.Idle
}
/** 组合被丢弃时调(等价于 [cancel]:一段没发出去的录音不该在后台继续占着麦克风)。 */
fun release() = cancel()
/**
* 读循环:一块一块收进 [sink],顺手更新已录时长;满了之后**丢字节但不打断录音**
* (见类注释「满了不自动发送」)。
*
* 读出错(`read < 0`)就结束这一次录音:硬件被抢走、设备被拔出都会走到这里,此时
* 已经收到的部分仍然有效([stop] 照常交出它们)。
*
* @param generation 启动时那一代的编号:收尾只在自己还是「当前这一代」时才改状态。
*/
private suspend fun readLoop(recorder: AudioRecord, sink: ByteArrayOutputStream, generation: Int) {
val buffer = ByteArray(READ_BYTES)
try {
while (collecting) {
val read = recorder.read(buffer, 0, buffer.size)
if (read < 0) break
if (read == 0) continue
val used = sink.size()
if (used < VoiceClip.MAX_PCM_BYTES) {
sink.write(buffer, 0, minOf(read, VoiceClip.MAX_PCM_BYTES - used))
}
val elapsed = VoiceClip.durationMillisOf(minOf(sink.size(), VoiceClip.MAX_PCM_BYTES))
_state.value = VoiceRecorderState.Recording(
elapsedMs = elapsed,
reachedLimit = elapsed >= VoiceClip.MAX_RECORDING_MILLIS,
)
}
} finally {
// 读循环自己结束(出错 / 被取消)时也要把状态收回 Idle,否则界面会永远停在「录音中」。
// 只在自己还是当前这一代时才收:否则「取消 → 立刻重录」的窄窗口里,上一轮的收尾
// 会把新一轮的「录音中」擦掉。
if (generation == this.generation && _state.value is VoiceRecorderState.Recording) {
_state.value = VoiceRecorderState.Idle
}
}
}
/** 把 `AudioRecord` 拆干净(`stop` 在没起录时抛 `IllegalStateException`,所以都包在 `runCatching` 里)。 */
private fun releaseRecorder() {
val current = record ?: return
record = null
runCatching { current.stop() }
runCatching { current.release() }
}
private companion object {
val SAMPLE_RATE_HZ: Int = VoiceClip.SAMPLE_RATE_HZ
const val ENCODING: Int = AudioFormat.ENCODING_PCM_16BIT
const val CHANNEL_MASK: Int = AudioFormat.CHANNEL_IN_MONO
/**
* 一次读多少字节:`2048` = 16 kHz 下单声道 16-bit 的 64 ms(1024 帧)。
* 比这更小则更新频率过高(每秒几十次状态写),更大则「停止」那一刻的延迟可感。
*/
const val READ_BYTES: Int = 2048
/** 初始缓冲按 10 秒预分配(够大多数语音,长的那条会自己扩容)。 */
val INITIAL_PCM_BYTES: Int = VoiceClip.pcmBytesFor(10_000)
}
}
@@ -223,6 +223,37 @@ class ChatSocket(
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
/**
* 发送 `voice_input`(契约 v0.96 §3.15):一段 base64 音频 + 格式,走与 `chat` 同一条出站通道。
*
* ## 为什么没有「离线队列」
* 文本那条有补发队列(内容只是一小段字符串,留着不贵);语音的 audio 是**几 MB 的 base64**
* 内联在帧里,队列化它等于把这段内存钉在待发状态里,而重连之后服务端到底要不要它、要哪一段
* 又都是用户该重新决定的事。所以本层与 [sendChat] 一样:**发不出去就回 `false`**,由
* `ChatViewModel` 把这段留在内存里、让用户按「重发」(复用同一个 `clientMsgId`)。
*
* @param audio base64(单行,`Base64.NO_WRAP`)。
* @param format 契约白名单 `wav|mp3|ogg` 之一;本端恒 `"wav"`。
* @param deviceId 与 `chat` 帧同一份来源标识。
* @param clientMsgId 幂等键:同一段语音重发**必须复用**(否则服务端会把同一句话说两遍)。
* @return 未连接 / 写失败返回 `false`。
*/
fun sendVoiceInput(
sessionId: String?,
audio: String,
format: String,
deviceId: String?,
clientMsgId: String?,
): Boolean = send(
WsOutbound.VoiceInput(
audio = audio,
format = format,
sessionId = sessionId,
deviceId = deviceId,
clientMsgId = clientMsgId,
),
)
private fun send(outbound: WsOutbound): Boolean {
if (_state.value != WsState.Connected) return false
val socket = webSocket ?: return false
@@ -600,12 +600,30 @@ sealed class WsEvent {
override val type: String get() = "observability_log"
}
/** 语音合成回复(定向回请求连接)。 */
/**
* 语音合成回复(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* ## 会话归属**只在 `data` 里**
* 契约明写这帧的帧壳**没有** `sessionId`(与其它结构化流帧不同),会话 id 在载荷上。
* 所以解析层用既有口径 [`resolveSessionId`] 把它提到 [sessionId] 上:帧壳有就用帧壳,
* 否则用 `data` 里那一份。少了这一步,`isForSession` 会把帧**放行给每一个会话的 VM**
* (空值 = 无法判定),于是切到别的会话时也会响一段本属于别的会话的语音。
*
* ## 只定向回发起 `voice_input` 的那条连接、断线不补投
* 这是契约的明确边界,不是缺口:本端**不**为它做任何「重连后拉取」的补救(历史靠 REST,
* 音频只在这一次连接里存在)。所以这一帧只有「当场播放 / 当场显示」这一种用途。
*
* [text] 是**转写**(那段音频被识别成什么),[reply] 是**她说的话**——两者的取舍见
* `domain.chat.voiceReplyText`。[provider] 是降级信号(`silent` = 这一轮没有真音频),
* 判据见 `domain.chat.voiceOutputHasAudio`。
*/
data class VoiceOutput(
val audio: String?,
val format: String?,
val text: String?,
val reply: String?,
val deviceId: String? = null,
val provider: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
override val sessionId: String? = null,
@@ -890,8 +908,20 @@ sealed class WsEvent {
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
"message_deleted" -> decode<DeletedData>(data)
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data)
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
"voice_output" -> decode<VoiceOutputData>(data).let {
VoiceOutput(
audio = it.audio,
format = it.format,
text = it.text,
reply = it.reply,
deviceId = it.deviceId,
provider = it.provider,
turnId = turnId,
seq = seq,
// 帧壳**没有** sessionId(契约 v0.96),归属在 data 里 ⇒ 走既有口径提到帧上。
sessionId = resolveSessionId(sessionId, it.sessionId),
)
}
// 她主动说的话(v0.72)。帧壳**没有** `sessionId`(服务端不设),与会话归属有关的一切
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
"proactive_message" -> decode<ProactiveData>(data).let {
@@ -1129,12 +1159,22 @@ private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList(
@Serializable
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
/**
* `voice_output` 载荷(契约 v0.96 §3.15):`{audio, format, text, reply, sessionId, deviceId?, provider?}`。
*
* `sessionId` 在这里**不是可选的**(契约「帧壳没有 sessionId(在 data 里)」),但解成缺省空串
* 并交给 [WsEvent.resolveSessionId]:坏帧(真没给)时宁可退化成「无法判定」(放行、由消费方
* 兜底),也不把整帧按坏帧丢掉——丢了就是「她回了语音但屏幕上什么都没有」。
*/
@Serializable
private data class VoiceOutputData(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val reply: String? = null,
val sessionId: String = "",
val deviceId: String? = null,
val provider: String? = null,
)
/**
@@ -105,12 +105,26 @@ sealed interface WsOutbound {
}
}
/** `voice_input`:`{audio?, format?, text?, sessionId?}`。 */
/**
* `voice_input`:`{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}`(契约 v0.96 §3.15)。
*
* 五个键里 [audio] / [format] 是我们**总要给**的(一段 WAV 的 base64 + `"wav"`,见
* `domain.chat.VoiceClip`);[text] 只有本端**自己做过转写**时才给(给了服务端就跳过 STT),
* 本端不做本地转写 ⇒ 恒省略。
*
* [clientMsgId] 与 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条,重复到达回
* `chat_duplicate`、不再落库不再起回合。所以「这一段没发出去、用户点重发」必须**复用**
* 同一个键(见 `domain.chat.voiceClientMsgIdFor`),换新键就是让服务端把同一句话再答一遍。
*
* [deviceId] 的取值与 `chat` 帧同一份(`ChatViewModel.DEVICE_ID`):服务端据此标记来源。
*/
data class VoiceInput(
val audio: String? = null,
val format: String? = null,
val text: String? = null,
val sessionId: String? = null,
val deviceId: String? = null,
val clientMsgId: String? = null,
override val reqId: String? = null,
) : WsOutbound {
override val type: String get() = "voice_input"
@@ -120,6 +134,8 @@ sealed interface WsOutbound {
format?.let { put("format", it) }
text?.let { put("text", it) }
sessionId?.let { put("sessionId", it) }
deviceId?.let { put("deviceId", it) }
clientMsgId?.let { put("clientMsgId", it) }
}
}
@@ -0,0 +1,238 @@
package top.yeij.cyrene.domain.chat
/**
* 一段**要发出去的语音**:录音参数、WAV 封装、体积闸门与幂等键(纯函数,可穷举单测)。
*
* ## 为什么自己封 WAV,而不是用 `MediaRecorder`
* 契约 v0.96 给 `voice_input.format` 的白名单是 **`wav|mp3|ogg`**,而 Android `MediaRecorder`
* 的默认产物是 **`m4a/aac`**——**不在白名单里**。要用它就得另接一个编码器把 aac 转成 mp3/ogg,
* 那是新依赖(项目纪律:加依赖先问)。而 `AudioRecord` 给的 PCM + 一个 44 字节的 RIFF 头
* 就是 WAV:**零依赖、零转码、逐字节可预期**,也能在单测里把头的每个字段钉死。
*
* ## 采样参数:16 kHz / 单声道 / 16-bit
* - **采样率 16 kHz**:语音识别的行业口径就是 16 kHz(电话带宽 8 kHz 会把擦音削掉,44.1 kHz
* 对 STT 没有任何增益)。同一句话的字节数因此只有 44.1 kHz 的 **36%**——这一条在本端格外重要,
* 因为 `voice_input.audio` 是**内联 base64**(4/3 膨胀)走一条 WS 文本帧。
* - **单声道**:麦克风阵列的立体声对 STT 只有副作用(两路几乎相同,白背一倍体积)。
* - **16-bit**:`AudioRecord` 的原生 PCM 编码(`ENCODING_PCM_16BIT`),不做压缩——压缩要依赖。
*
* 由此得出一个**只有一条**的换算:`1 ms = 32 字节`,`1 s = 32 000 字节`。
*
* ## 体积闸门(为什么是 5 MiB)
* 契约 v0.96 的 `voice_input` **没写** `audio` 的体积上限(`chat.image` 写了:解码后 5 MiB /
* 字符串 6990572 字符)。本端按**已知最严的那一档**设计,也就是 [MAX_AUDIO_BYTES] =
* `ChatImageBudget.SERVER_MAX_DECODED_BYTES`(5 MiB,解码后)——同一个部署对「内联 base64 字段」
* 已经明确接受的量级,voice 没有理由比它更宽。超限**明确拒绝**([VOICE_TOO_LARGE_MESSAGE]),
* 绝不静默截断:截断后的音频在服务端听来就是「话说到一半断了」。
*
* 60 秒的录满长度([MAX_RECORDING_MILLIS])只用到这条闸门的 **36%**(1 920 044 字节),所以
* 正常路径永远撞不到它——它是**第二道**闸(硬件偶尔会多给几块缓冲,谁也说不准),不是主判据。
*
* ## 时长下限
* 按下就松手的误触会得到几十毫秒的音频:那不是「说话」,送给服务端只会换来一次空转写。
* 不足 [MIN_RECORDING_MILLIS] 的录音在**本端**就被挡下并说明([VOICE_TOO_SHORT_MESSAGE])。
*/
object VoiceClip {
/** 采样率(Hz)。16 kHz = STT 的行业口径,见类注释。 */
const val SAMPLE_RATE_HZ: Int = 16_000
/** 声道数:单声道(立体声对 STT 无益,只翻倍体积)。 */
const val CHANNEL_COUNT: Int = 1
/** 位深:`AudioRecord` 的原生 16-bit PCM。 */
const val BITS_PER_SAMPLE: Int = 16
/** 每采样的字节数。 */
const val BYTES_PER_SAMPLE: Int = BITS_PER_SAMPLE / 8
/** RIFF/WAVE 的 PCM 头长度:`RIFF`(4) + size(4) + `WAVE`(4) + `fmt `(16+8) + `data`(8)。 */
const val HEADER_BYTES: Int = 44
/** 上行 `format` 的取值。契约白名单 `wav|mp3|ogg` 里我们**只产出**这一个。 */
const val FORMAT: String = "wav"
/** 每秒的 PCM 字节数(= [SAMPLE_RATE_HZ] × [CHANNEL_COUNT] × [BYTES_PER_SAMPLE])。 */
val BYTES_PER_SECOND: Int = SAMPLE_RATE_HZ * CHANNEL_COUNT * BYTES_PER_SAMPLE
/**
* 一段语音(**含头**)的字节上限:与 `chat.image` 解码后同一条线,见类注释。
* 刻意**引用**那个常量而不是再写一遍 `5 * 1024 * 1024`:上限只留一处,改的时候不会漂。
*/
const val MAX_AUDIO_BYTES: Int = ChatImageBudget.SERVER_MAX_DECODED_BYTES
/**
* 一次录音的**最长**时长(毫秒)。60 秒取自 IM 的通行口径(微信 / QQ 都是这个量级),
* 也对得上服务端 STT 的现实:再长的语音转写质量与等待都不划算。
*
* 到点由录音机自己置「满了」标记(见 `VoiceRecorder`),UI 随即收尾发送——
* 不是悄悄截断,用户看到的是「到 60 秒了,这段发出去」。
*/
const val MAX_RECORDING_MILLIS: Int = 60_000
/** 短于这个时长的录音**不送**(按下就松手的误触),见类注释。 */
const val MIN_RECORDING_MILLIS: Int = 1_000
/** 60 秒对应的 PCM 字节数(录音机据此封顶,不在读循环里另算一遍公式)。 */
val MAX_PCM_BYTES: Int = pcmBytesFor(MAX_RECORDING_MILLIS)
/** 这段 PCM 加上 WAV 头之后的**总**字节数。 */
fun totalBytesFor(pcmBytes: Int): Int = if (pcmBytes <= 0) 0 else HEADER_BYTES + pcmBytes
/** 时长 → PCM 字节数(向下取整;`<= 0` 的时长回 0)。 */
fun pcmBytesFor(durationMillis: Int): Int =
if (durationMillis <= 0) 0 else (durationMillis.toLong() * BYTES_PER_SECOND / 1000L).toInt()
/** PCM 字节数 → 时长(毫秒,向下取整)。 */
fun durationMillisOf(pcmBytes: Int): Int =
if (pcmBytes <= 0) 0 else (pcmBytes.toLong() * 1000L / BYTES_PER_SECOND).toInt()
/** 总字节数是否在 [MAX_AUDIO_BYTES] 以内(**等于**算通过:`<=`,与 `withinServerLimit` 同口径)。 */
fun withinLimit(totalBytes: Int): Boolean = totalBytes <= MAX_AUDIO_BYTES
/**
* WAV 头(44 字节,PCM / 单块 / 无扩展)。
*
* 逐字段按 RIFF 规范写:`RIFF` + 文件长 - 8 + `WAVE` + `fmt `(16 字节,PCM=1) + 声道 /
* 采样率 / 字节率 / 块对齐 / 位深 + `data` + 数据长。全部**小端**。
*/
fun wavHeader(pcmBytes: Int): ByteArray {
val data = ByteArray(HEADER_BYTES)
putAscii(data, 0, "RIFF")
putIntLe(data, 4, HEADER_BYTES - 8 + pcmBytes)
putAscii(data, 8, "WAVE")
putAscii(data, 12, "fmt ")
putIntLe(data, 16, 16)
putShortLe(data, 20, AUDIO_FORMAT_PCM)
putShortLe(data, 22, CHANNEL_COUNT)
putIntLe(data, 24, SAMPLE_RATE_HZ)
putIntLe(data, 28, BYTES_PER_SECOND)
putShortLe(data, 32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
putShortLe(data, 34, BITS_PER_SAMPLE)
putAscii(data, 36, "data")
putIntLe(data, 40, pcmBytes)
return data
}
/** 头 + PCM = 一个可直接交给任何 `WAV` 解码器的字节数组。 */
fun toWav(pcm: ByteArray): ByteArray {
val header = wavHeader(pcm.size)
val out = ByteArray(HEADER_BYTES + pcm.size)
header.copyInto(out, 0)
pcm.copyInto(out, HEADER_BYTES)
return out
}
/**
* 这段 PCM 该不该发、发出去长什么样(**唯一**的准入判据,录音机与 VM 共用)。
*
* 判序是刻意的:**空 → 超限 → 太短 → 可发**。
* - 空:一个字节都没有(设备没给 / 权限刚被撤),连「太短」都谈不上;
* - 超限:长度或字节数已经越线,绝不能发(哪怕时长看起来正常——硬件也可能给出异常长的一块);
* - 太短:见 [MIN_RECORDING_MILLIS]。
*/
fun plan(pcm: ByteArray): VoiceClipPlan {
if (pcm.isEmpty()) return VoiceClipPlan.Empty
val total = totalBytesFor(pcm.size)
if (!withinLimit(total)) return VoiceClipPlan.TooLarge(total)
val duration = durationMillisOf(pcm.size)
if (duration < MIN_RECORDING_MILLIS) return VoiceClipPlan.TooShort(duration)
return VoiceClipPlan.Ready(toWav(pcm), duration)
}
/** RIFF 的 `audioFormat`:1 = PCM(整数)。 */
private const val AUDIO_FORMAT_PCM: Int = 1
private fun putAscii(target: ByteArray, offset: Int, text: String) {
for (index in text.indices) target[offset + index] = text[index].code.toByte()
}
private fun putIntLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
target[offset + 2] = ((value ushr 16) and 0xFF).toByte()
target[offset + 3] = ((value ushr 24) and 0xFF).toByte()
}
private fun putShortLe(target: ByteArray, offset: Int, value: Int) {
target[offset] = (value and 0xFF).toByte()
target[offset + 1] = ((value ushr 8) and 0xFF).toByte()
}
}
/** [VoiceClip.plan] 的四档结论。 */
sealed interface VoiceClipPlan {
/** 一段**可以发**的语音:已经封好 WAV,[durationMs] 是它的实际时长。 */
class Ready(val wav: ByteArray, val durationMs: Int) : VoiceClipPlan
/** 一个字节都没录到。 */
data object Empty : VoiceClipPlan
/** 短于 [VoiceClip.MIN_RECORDING_MILLIS](按下就松手那种误触)。 */
data class TooShort(val durationMs: Int) : VoiceClipPlan
/** 总字节数超过 [VoiceClip.MAX_AUDIO_BYTES]。 */
data class TooLarge(val totalBytes: Int) : VoiceClipPlan
}
/** 空录音的如实告知(**不是错误**:什么都没发生,用户重来一次即可)。 */
const val VOICE_EMPTY_MESSAGE: String = "没有录到声音,再按一次试试"
/** 太短的如实告知。 */
const val VOICE_TOO_SHORT_MESSAGE: String = "录得太短了,说满一秒再发"
/**
* 超限的如实告知。刻意说「没发出去」:那句话必须与事实上一致(这条一个字节都没上行),
* 也不能把责任推给用户(他确实就说了那么久,是本端的闸门挡下的)。
*/
const val VOICE_TOO_LARGE_MESSAGE: String = "这段语音太长,本机没有发出去"
/** 这一条被本端挡下时该说的那句话;`null` = 可以发(纯函数)。 */
fun voiceClipRejection(plan: VoiceClipPlan): String? = when (plan) {
is VoiceClipPlan.Ready -> null
VoiceClipPlan.Empty -> VOICE_EMPTY_MESSAGE
is VoiceClipPlan.TooShort -> VOICE_TOO_SHORT_MESSAGE
is VoiceClipPlan.TooLarge -> VOICE_TOO_LARGE_MESSAGE
}
/**
* 录音中 / 待重发那一段的时长文案(纯函数):`m:ss`,**从 0:00 起就有值**。
*
* 与接收侧的 [voiceDurationLabel] 差一条:那个对「不足一秒」回 `null`(服务端没给准数时宁可
* 不显示),而这里显示的是**本端自己正在数的时间**——0:00 是事实,不是冒充。
* 负数按 0 处理(时钟回拨 / 调用方算错时也不出现 `-1:-1`)。
*/
fun voiceElapsedLabel(elapsedMs: Int): String {
val totalSeconds = elapsedMs.coerceAtLeast(0) / 1000
return "${totalSeconds / 60}:${(totalSeconds % 60).toString().padStart(2, '0')}"
}
/** 语音消息的幂等键前缀:与文本那条(`local-`)/ 分享那条(`share-`)同一手法,见 [voiceClientMsgIdFor]。 */
const val VOICE_CLIENT_MSG_ID_PREFIX: String = "voice-"
/**
* 一条**录好、还没发出去**的语音在本端的记账(只有小字段,audio 不在这里)。
*
* `clientMsgId` 是契约 v0.96 §3.15 的幂等键:同一个非空值服务端**只承认第一条**,重复到达回
* `chat_duplicate`、不落库不起回合。所以「重发这一段」必须**复用**它([voiceClientMsgIdFor]),
* 换了新键就是让服务端把同一句话再答一遍。
*
* `audio`(base64)刻意**不进** [top.yeij.cyrene.ui.chat.ChatUiState]:那是一格会被反复 `copy`
* 并逐字段比较的状态(流式期间每秒几十次),几 MB 的字符串放进去等于每次比较都扫一遍它。
* 字节留在 VM 自己那个字段上(生命周期与 VM 相同,转屏不丢)。
*/
data class VoiceDraftInfo(
val clientMsgId: String,
val durationMs: Int,
val totalBytes: Int,
)
/**
* 这一段该用哪个幂等键(纯函数):
* - [draft] 非空 = **重发刚才没发出去的那一段** ⇒ 原样复用它当时的键(契约要求);
* - [draft] 为空 = 一段**新**录音 ⇒ 现铸一个:前缀 + [mint] 给的随机串(随机而不是自增,
* 理由与文本那条相同:服务端的判重窗口是整个会话历史)。
*/
fun voiceClientMsgIdFor(draft: VoiceDraftInfo?, mint: () -> String): String =
draft?.clientMsgId ?: "$VOICE_CLIENT_MSG_ID_PREFIX${mint()}"
@@ -0,0 +1,93 @@
package top.yeij.cyrene.domain.chat
/**
* `voice_output` 的**判据**(纯函数,可穷举单测):契约 v0.96 §3.15 下行帧。
*
* 帧形状(后端 `回执三十六`):
* ```json
* { "type": "voice_output",
* "data": { "audio": "<base64>", "format": "wav", "text": "...", "reply": "她的话",
* "sessionId": "web", "deviceId?": "...", "provider?": "silent|emotiontts|..." } }
* ```
*
* ## `text` 与 `reply` 是两个东西(本端怎么用)
* - **[text]**:这一轮**转写出来的话**——也就是我们那段音频被识别成什么。它与
* `voice_input.text`(「已自行转写则跳过 STT」)同一个键名、同一个语义,只是方向相反。
* - **[reply]**:**她说的话**(音频念的就是它),也就是用户真正要听/要看的那一句。
*
* 于是「屏幕上该显示哪一句」的答案是 [voiceReplyText]:**`reply` 优先**,它缺席时才退到 `text`
* (宁可显示转写也不显示空白),两者都缺席就什么都不显示(绝不编一句)。
*
* ## `provider` 是**降级信号**,不是装饰
* `provider == "silent"` = 这一轮**没有真音频**(`audio` 空,但 `text` / `reply` 有)。
* 契约明写「UI 别把它画成播放器」——画了就是一个点下去必然失败的按钮(与接收侧
* `voiceAudio` 的「四个键不齐就不画播放控件」同一条纪律)。
*
* 除此之外 `audio` 空白也按「没有音频」处理([voiceOutputHasAudio]):契约说 silent 时
* `audio` 为空,反过来「空 audio + 没给 provider」也绝不能画出一个播放器。
*
* ## 它与 `voice` 键那条(接收侧放别人发来的语音)**互不相干**
* 见 [isVoiceMessage]/[voiceAudio]:那是消息 DTO 的历史副本,这里是**本端刚发出去的那一段**的
* 实时应答——两者不共用判据,共用只会把两套字段口径搅在一起。
*/
/** `provider` 里那个**降级档**的取值(契约 v0.96)。 */
object VoiceProvider {
/** 这一轮**没有真音频**(`audio` 空、`text`/`reply` 有)。 */
const val SILENT: String = "silent"
}
/**
* 这一帧是不是「没有真音频」那一档(纯函数)。
*
* 只认字面 `silent`(忽略首尾空白与大小写:它是机器可读码,不是文案)。未知取值一律**不当**
* silent——那是另一个 provider,`audio` 在不在由 [voiceOutputHasAudio] 按字节判,不靠猜。
*/
fun isSilentVoiceProvider(provider: String?): Boolean =
provider?.trim().equals(VoiceProvider.SILENT, ignoreCase = true)
/**
* 这一轮**有没有真音频**(纯函数):`provider != silent` **且** `audio` 非空白。
*
* 两个条件都不许省:silent 档的 `audio` 契约上就是空的(万一真给了也不算数,因为服务端已经
* 明说这一轮没发音),而非 silent 档的 `audio` 也可能因为编码失败缺席。
*/
fun voiceOutputHasAudio(audio: String?, provider: String?): Boolean =
!isSilentVoiceProvider(provider) && !audio.isNullOrBlank()
/**
* 屏幕上该显示的那一句(纯函数,见文件头「`text` 与 `reply` 是两个东西」)。
* `reply` 非空白 ⇒ 它;否则 `text` 非空白 ⇒ 它;都没有 ⇒ `null`(一个字符都不显示)。
*/
fun voiceReplyText(reply: String?, text: String?): String? =
reply?.takeIf { it.isNotBlank() } ?: text?.takeIf { it.isNotBlank() }
/**
* 这张卡片下面要补的那一句(纯函数:不需要补时回 `null`)。
*
* 两档**都要解释,但说的不是同一件事**:
* - [silent]:**服务端**这一轮没有真音频(`provider == "silent"`)⇒「她只回了文字」。不解释的话,
* 用户按着麦克风说话、回来的却是一段文字,分不清「她选择只回文字」与「播放器坏了」。
* - 有音频却没落地([hasAudioFile] 为假):那是**本端**解码 / 写缓存失败 ⇒ 说「这段语音本机没
* 准备好」,绝不把本机的故障说成「她只回了文字」(那是替服务端下了一个它没下的结论)。
*/
fun voiceReplyNote(silent: Boolean, hasAudioFile: Boolean): String? = when {
silent -> VOICE_SILENT_NOTE
!hasAudioFile -> VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE
else -> null
}
/**
* 「这一轮没有音频」的说明。刻意说清是**服务端**这一轮没给音频(`provider` 的降级信号),
* 而不是「本机播不了」——后者会把人引向「换个播放器」这种无用的动作。
*/
const val VOICE_SILENT_NOTE: String = "这一轮她只回了文字,没有语音"
/**
* 「音频有、但本机没准备好」的说明(解码 / 写缓存失败)。
*
* 与 [VOICE_SILENT_NOTE] 分开是必须的:这一档的真相是**本端**的问题,而文字照样在
* ([voiceReplyText] 已经把它取出来了),所以用户至少还能读到她说的话。
*/
const val VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE: String = "这段语音本机没能准备好,先看文字"
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.chat
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@@ -47,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
@@ -64,6 +66,8 @@ import top.yeij.cyrene.core.session.ChatSessionSlots
import top.yeij.cyrene.data.repository.DocumentCacheStore
import top.yeij.cyrene.data.repository.ImageCacheStore
import top.yeij.cyrene.data.repository.VoiceStore
import top.yeij.cyrene.data.voice.VoiceRecorder
import top.yeij.cyrene.data.voice.VoiceRecorderState
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.DocumentChips
import top.yeij.cyrene.domain.chat.DocumentDelivery
@@ -76,7 +80,10 @@ import top.yeij.cyrene.domain.chat.DocumentTarget
import top.yeij.cyrene.domain.chat.DraftDocument
import top.yeij.cyrene.domain.chat.ImageSource
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.documentLoadFailureMessage
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.share.ShareRules
import top.yeij.cyrene.domain.share.SharedContent
import top.yukonga.miuix.kmp.basic.Button
@@ -93,6 +100,7 @@ import top.yukonga.miuix.kmp.icon.basic.Search
import top.yukonga.miuix.kmp.icon.basic.Sidebar
import top.yukonga.miuix.kmp.icon.extended.File
import top.yukonga.miuix.kmp.icon.extended.Image
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** 判定「贴着底部」的容差(px)。 */
@@ -110,6 +118,14 @@ private const val SHARE_WAIT_NOTICE_MILLIS = 3_000L
/** 「已定位 / 这条不在窗口里」那句告知挂多久(毫秒)。 */
private const val SEARCH_NOTICE_MILLIS = 8_000L
/**
* 麦克风相关的告知(没授权 / 起不来)挂多久(毫秒)。
*
* 与同页其它几条「动作刚发生的如实告知」取同一个值:它描述的也是刚刚那一次按键,
* 不是持续状态(权限是不是真的没有,用户再按一次麦克风就知道了)。
*/
private const val VOICE_FAILURE_NOTICE_MILLIS = 8_000L
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
private val InputBottomSpacer = 22.dp
@@ -403,6 +419,93 @@ fun ChatScreen(
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
val voicePlayback by voicePlayer.state.collectAsState()
// —— 语音输入(契约 v0.96 §3.15 ①「用户按住麦克风说话」)——
// 录音机归**这一个会话**:换会话 = 换实例(与上面那条播放通道同一个生命周期纪律),
// 离开聊天页时 `onDispose` 收掉麦克风——一段没发出去的录音不该在后台继续占着它。
//
// ⚠️ 录音中**转屏**会重建组合 ⇒ 这一段丢失。本端不为此加一个「录音仓」:录音是几秒钟的
// 动作,转屏 / 分屏重建时丢掉一次比在 VM 里维护一条 AudioRecord 生命周期便宜得多。
val recorder = remember(sessionId) { VoiceRecorder() }
DisposableEffect(recorder) {
onDispose { recorder.release() }
}
val recording by recorder.state.collectAsState()
val recordingState = recording as? VoiceRecorderState.Recording
// 麦克风起不来 / 没授权时的如实告知(本端的问题,不走 VM 的 `error` 通道:
// 那条通道的语义是「服务端 / 发送」,而这里一次都没上过路)。
var voiceFailure by remember { mutableStateOf<String?>(null) }
LaunchedEffect(voiceFailure) {
if (voiceFailure != null) {
delay(VOICE_FAILURE_NOTICE_MILLIS)
voiceFailure = null
}
}
val requestAudioPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
// 授权刚拿到手就直接开录(用户按麦克风的意思就是「现在说」,不必再按第二次)。
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = VOICE_PERMISSION_DENIED_MESSAGE
}
}
// 开始录音:有权限就直接起;没有就申请。起不来时**如实说一句**,绝不假装在录。
fun startVoiceRecording() {
// 已经在录就不重入(录音中麦克风那枚按钮本来就不在屏幕上,这里是第二道闸)。
if (recorder.state.value is VoiceRecorderState.Recording) return
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
if (!recorder.start()) voiceFailure = VOICE_UNAVAILABLE_MESSAGE
} else {
voiceFailure = null
requestAudioPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}
// 「这一次收尾已经在飞」:防双击「发送」,也防它和「录满 60 秒自动收尾」撞在同一帧上。
// 第二次进入时录音机已经不在录了,`stop()` 会回 `null`——那会被误读成「没有录到声音」。
var finishingVoice by remember { mutableStateOf(false) }
// 结束录音并交给 VM:准入判据(空 / 太短 / 超限)全在 `VoiceClip.plan` 里,这里一个字节
// 都不自己判——空录音也照旧交给它,好让用户拿到那句「没有录到声音」。
fun finishVoiceRecording() {
if (finishingVoice) return
finishingVoice = true
scope.launch {
try {
val pcm = recorder.stop()
viewModel.sendVoice(pcm ?: ByteArray(0))
} finally {
finishingVoice = false
}
}
}
// 录满 60 秒:录音机只置位不自动发送(见 VoiceRecorder 的 KDoc),收尾由这一层做。
val reachedLimit = recordingState?.reachedLimit == true
LaunchedEffect(reachedLimit) {
if (reachedLimit) finishVoiceRecording()
}
// `voice_output` 到了就放(契约:这一帧只定向回本连接、断线不补投 ⇒ 不播就永远听不到)。
// 键里带 `file`:音频先落成本地文件再进状态,落好那一刻才是真正能播的时刻。
// `autoPlay` 只用一次:这一帧到达时用户可能在别的会话,切回来时不该突然响一段旧语音
// (判据见 VoiceReply.autoPlay)。「已经在放 / 正在载入」时不重新触发——否则第二次状态
// 到达会把正在响的那条停掉。
val voiceReply = state.voiceReply
LaunchedEffect(voiceReply?.id, voiceReply?.file) {
val reply = voiceReply ?: return@LaunchedEffect
val file = reply.file ?: return@LaunchedEffect
if (!reply.autoPlay) return@LaunchedEffect
viewModel.consumeVoiceReplyAutoPlay()
if (!voicePlayer.state.value.isActiveFor(reply.id)) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
}
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
@@ -569,8 +672,29 @@ fun ChatScreen(
images = state.draftImages,
documents = state.draftDocuments,
busyText = busyLabel(state),
recordingMs = recordingState?.elapsedMs,
voiceDraft = state.voiceDraft,
voiceReply = state.voiceReply,
voicePlayback = voicePlayback,
onValueChange = viewModel::onDraftChange,
onSend = viewModel::send,
onStartRecording = { startVoiceRecording() },
onStopRecording = { finishVoiceRecording() },
onCancelRecording = { recorder.cancel() },
onRetryVoice = viewModel::retryVoice,
onDiscardVoice = viewModel::discardVoice,
// 播放 / 停止与关闭都在这一个动作里收口:关掉卡片之后再响一秒是明确的 bug。
onToggleVoiceReply = {
val reply = state.voiceReply
val file = reply?.file
if (reply != null && file != null) {
voicePlayer.toggleClip(reply.id) { Result.success(file) }
}
},
onDismissVoiceReply = {
voicePlayer.stop()
viewModel.dismissVoiceReply()
},
onPickImage = {
imagePicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
@@ -753,6 +877,14 @@ fun ChatScreen(
state.sendQueueNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 语音输入被本端挡下(没录到 / 太短 / 太大):与上面几条同档的如实告知。
state.voiceNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
// 麦克风起不来 / 没授权:这是真错误(功能整个不可用),所以用红色。
voiceFailure?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.error)
}
searchNotice?.let { message ->
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
}
@@ -906,14 +1038,43 @@ private fun Banner(text: String, color: Color, onDismiss: (() -> Unit)? = null)
}
}
/**
* 输入栏:文本 / 图片 / 附件 / **语音**(契约 v0.96 §3.15 ①)。
*
* ## 三段语音形态各占一行(从上到下)
* 1. [VoiceReplyRow]——她的语音回复(`voice_output` 的产物)。放在最上面,因为它说的是
* 「刚刚那一轮的答复」,而下面几行是用户还要做的动作;
* 2. [VoiceDraftRow]——一段录好但没发出去的语音(重发 / 丢弃);
* 3. **录音中**:整行换成 [VoiceRecordingRow](录音中 / 取消 / 发送)。
*
* ## 录音中为什么把整行换掉
* 录音是一条有去无回的动作:那一行里同时留着一个「发送」按钮(文本的)与一个「结束录音」按钮,
* 手指落错就是把半句话当成文本发出去。换掉之后**文本发送那条路一个字节都没变**(不录音时
* 逐字与之前相同),而录音中屏幕上有且只有与该动作有关的两个选择。
*
* ## 语音入口为什么放在「发送」左边
* 与图片 / 附件同一侧(都是「加内容」),发送键仍在最右(肌肉记忆不变)。麦克风常驻——
* 它是与打字并列的**另一种输入方式**,不是某个附加能力。
*/
@Composable
private fun InputBar(
value: String,
images: List<String>,
documents: List<DraftDocument>,
busyText: String,
recordingMs: Int?,
voiceDraft: VoiceDraftInfo?,
voiceReply: VoiceReply?,
voicePlayback: VoicePlaybackState,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onCancelRecording: () -> Unit,
onRetryVoice: () -> Unit,
onDiscardVoice: () -> Unit,
onToggleVoiceReply: () -> Unit,
onDismissVoiceReply: () -> Unit,
onPickImage: () -> Unit,
onRemoveImage: (Int) -> Unit,
onPickDocument: () -> Unit,
@@ -926,6 +1087,25 @@ private fun InputBar(
.imePadding()
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
) {
// 她的语音回复:最上面一行,因为它说的是「刚刚那一轮的答复」。
voiceReply?.let { reply ->
VoiceReplyRow(
reply = reply,
playback = voicePlayback,
onTogglePlay = onToggleVoiceReply,
onDismiss = onDismissVoiceReply,
modifier = Modifier.padding(bottom = 6.dp),
)
}
// 录好但没发出去的那一段:只在 `voice_input` 没能上行时出现。
voiceDraft?.let { draft ->
VoiceDraftRow(
draft = draft,
onRetry = onRetryVoice,
onDiscard = onDiscardVoice,
modifier = Modifier.padding(bottom = 6.dp),
)
}
DraftImagePreviews(images = images, onRemove = onRemoveImage)
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
@@ -934,46 +1114,64 @@ private fun InputBar(
onRemove = onRemoveDocument,
onClick = onPreviewDocument,
)
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
if (images.size < ShareRules.MAX_IMAGES) {
IconButton(onClick = onPickImage) {
if (recordingMs != null) {
VoiceRecordingRow(
elapsedMs = recordingMs,
onCancel = onCancelRecording,
onSend = onStopRecording,
)
} else {
Row(verticalAlignment = Alignment.Bottom) {
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
if (images.size < ShareRules.MAX_IMAGES) {
IconButton(onClick = onPickImage) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.Image,
contentDescription = "发送图片",
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
}
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
IconButton(onClick = onPickDocument) {
Icon(
imageVector = MiuixIcons.File,
contentDescription = "添加文件附件",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.width(4.dp))
TextField(
value = value,
onValueChange = onValueChange,
label = "和昔涟说点什么…",
useLabelAsPlaceholder = true,
maxLines = 5,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
) {
Text("发送")
Spacer(Modifier.width(4.dp))
// 麦克风(点按开始 / 点按结束,见 [VOICE_RECORD_DESCRIPTION] 的措辞)。
// 换行后的入口与图片 / 附件同档;不读偏好、不占文本发送的任何一条判据。
IconButton(onClick = onStartRecording) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = VOICE_RECORD_DESCRIPTION,
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Spacer(Modifier.width(4.dp))
Button(
onClick = onSend,
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
// 纯附件回合服务端**支持**)。
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
) {
Text("发送")
}
}
}
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
@@ -2,9 +2,11 @@ package top.yeij.cyrene.ui.chat
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Base64
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.io.File
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,9 +30,11 @@ import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer
import top.yeij.cyrene.data.image.PreparedImage
import top.yeij.cyrene.data.repository.ChatAck
import top.yeij.cyrene.data.repository.DocumentCache
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.repository.applyMessageEdit
import top.yeij.cyrene.data.repository.historyForSession
import top.yeij.cyrene.data.repository.mergeMessages
@@ -55,6 +59,9 @@ import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.chat.SharedTextPlan
import top.yeij.cyrene.domain.chat.TurnBinding
import top.yeij.cyrene.domain.chat.TurnBucket
import top.yeij.cyrene.domain.chat.VoiceClip
import top.yeij.cyrene.domain.chat.VoiceClipPlan
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.allClosed
import top.yeij.cyrene.domain.chat.closingBucketOf
import top.yeij.cyrene.domain.chat.closingTurn
@@ -65,6 +72,10 @@ 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.turnBucketOf
import top.yeij.cyrene.domain.chat.voiceClientMsgIdFor
import top.yeij.cyrene.domain.chat.voiceClipRejection
import top.yeij.cyrene.domain.chat.voiceOutputHasAudio
import top.yeij.cyrene.domain.chat.voiceReplyText
import top.yeij.cyrene.domain.chat.withSteeredMessage
import top.yeij.cyrene.domain.chat.withTurnBegin
import top.yeij.cyrene.domain.parse.ParsedBubble
@@ -109,6 +120,15 @@ data class PendingMessage(
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
*/
val documents: List<DraftDocument> = emptyList(),
/**
* 这一条是**语音**时它的时长(毫秒);`null` = 不是语音(绝大多数消息)。
*
* 语音的 [text] 恒为空:本端不做本地转写,用户说的话要等服务端 STT 之后才会作为
* user 消息的 `content` 落库。而气泡**必须立刻出现**(用户刚说完,屏幕上没有东西就是
* 「没发出去」的样子)⇒ 这一条消息在正文之外还需要一个「这是语音、多长」的可见凭据。
* 它只影响渲染(`PendingBubble` 画那一枚「语音 · 0:07」),不参与发送与指纹。
*/
val voiceDurationMs: Int? = null,
)
/**
@@ -809,6 +829,34 @@ data class ChatUiState(
* `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
*/
val sessionGone: Boolean = false,
/**
* `voice_output` 到达后的那一份「她的语音回复」(契约 v0.96 §3.15);null = 没有要显示的那一条。
*
* 它**不是一条消息**,所以不在 [messages] / [pending] 里:这一帧只定向回发起 `voice_input` 的
* 那条连接、断线不补投(契约明写),回合的正文本来也会经流式 / `message_persisted` 落进历史。
* 这里存的是「这一次语音回合的音频 + 她说的话」这一次性产物,渲染在输入栏上方一张卡片上,
* 用户可以随时关掉([ChatViewModel.dismissVoiceReply])。
*
* 音频先落成本地文件再进这一格([VoiceReply.file]):把 base64 塞进状态会让**每一次**
* 状态 `copy` 都在几 MB 的字符串上做相等比较(流式期间每秒几十次)。
*/
val voiceReply: VoiceReply? = null,
/**
* 一段**录好、但没发出去**的语音(`voice_input` 没能上行);null = 没有等着重发的。
*
* 只有小字段(键 / 时长 / 字节数),audio 本体留在 VM 的私有字段里——理由同上。
* 界面上它是输入栏上方一条带「重发 / 丢弃」的卡片:重发**复用同一个 `clientMsgId`**
* (契约:同一个非空值服务端只承认第一条),所以重发不会让同一句话落成两条。
*/
val voiceDraft: VoiceDraftInfo? = null,
/**
* 语音输入被**本端**挡下时的如实告知(没录到 / 太短 / 太大,见 `domain.chat.voiceClipRejection`);
* null = 没有要说的。
*
* 与 [error] 分开:这些都不是「服务端说不行」,而是本端的准入闸门,且用户要做的事很具体
* (重录 / 说长一点)。它到期自动收走([VOICE_NOTICE_MILLIS]),与其它几条告知同一形态。
*/
val voiceNotice: String? = null,
) {
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
val items: List<ChatDisplayItem>
@@ -918,13 +966,26 @@ class ChatViewModel(
it.draftDocuments.isNotEmpty() ||
it.pending.isNotEmpty() ||
it.queuedForConnection.isNotEmpty() ||
it.shareConfirm != null
it.shareConfirm != null ||
// 一段录好但没发出去的语音:它只在本端内存里(没有任何服务端副本),
// 交掉这一格 = 让用户重说一遍——与「还没发出去的字」同一条理由。
it.voiceDraft != null
}
private var socket: ChatSocket? = null
private var repository: MessageRepository? = null
private var oldestLoadedAt: Long? = null
/**
* 一段**录好、但没发出去**的语音的字节(`voice_input` 没能上行)。
*
* 刻意是 VM 的字段而不是 [ChatUiState] 的一格:状态会被反复 `copy` 并逐字段比较(流式期间
* 每秒几十次),几 MB 的 `ByteArray` 放在那里等于每次比较都扫一遍;而它的生命周期与 VM
* 完全相同(转屏不丢、切走会话仍在**这一格**里,见 `ChatSessionSlots`)。
* 状态里只留小字段 [ChatUiState.voiceDraft](键 / 时长 / 字节数)供渲染。
*/
private var voiceRetry: VoiceRetryClip? = null
/**
* **每一路**一个流式正文解析器(键与 `ChatUiState.streams` 同源)。
*
@@ -1486,6 +1547,219 @@ class ChatViewModel(
}
}
/**
* 语音输入(契约 v0.96 §3.15 的 ①「用户按住麦克风说话」):一段**已录好的 PCM** → `voice_input`。
*
* ## 准入判据只有一处([VoiceClip.plan],纯函数)
* 空 / 太短 / 超限三档都在这里被挡下并**明确说一句**([ChatUiState.voiceNotice],到期自己收走):
* 一段被静默吞掉的录音在用户眼里就是「按了没反应」。封 WAV 也在那里面(44 字节 RIFF 头),
* 本函数只做「发」这一件事。
*
* ## 乐观气泡与文本那条同源
* 上屏走的是**同一个** `pending` 列表([PendingMessage],只是 `text` 空、带上
* [PendingMessage.voiceDurationMs]),失败回滚走的是**同一个** `rollbackPending`——没有另造
* 一套「语音待发」的状态机。用户说完话气泡立刻出现,转写的正文之后由服务端的 user 消息
* 落库回来(按 `clientMsgId` 收编,判据在 [consumePendingFor])。
*
* ## 为什么没有补发队列
* 文本那条会进 [ChatUiState.queuedForConnection] 等连接;语音不进——audio 是几 MB 的内存,
* 钉在队列里等一个可能永远不来的连接不划算,而且「这段还发不发」应当由用户决定。
* 所以发不出去就把这一段**留着**([ChatUiState.voiceDraft]),由用户按「重发」
* ([retryVoice],**复用同一个 `clientMsgId`**)或「丢弃」([discardVoice])。
*
* @param pcm `AudioRecord` 收到的 16-bit / 16 kHz / 单声道 PCM(未封头)。
*/
fun sendVoice(pcm: ByteArray) {
viewModelScope.launch {
// 封 WAV + base64 编码都是几 MB 的活(PCM 复制 + 4/3 膨胀),放默认调度器上做,
// 别占主线程——用户按下「发送」之后这一帧就该没有可感的停顿。
val plan = withContext(Dispatchers.Default) { VoiceClip.plan(pcm) }
when (plan) {
is VoiceClipPlan.Ready -> deliverVoice(plan.wav, plan.durationMs, retry = null)
// Ready 之外的每一档都有一句人话(判定与文案都在 domain,能穷举单测)。
else -> voiceClipRejection(plan)?.let(::showVoiceNotice)
}
}
}
/**
* 重发**刚才没发出去的那一段**(复用它的 `clientMsgId`,契约要求)。
*
* 没有待重发的就什么都不做(按钮本不该出现;这里是第二道闸)。字节取的是 VM 里留着的那一份
* WAV(**不重新封装**),所以重发的是**同一个字节序列**——服务端按幂等键判重之后留下的那份
* 音频与用户眼前这段永远一致。
*/
fun retryVoice() {
val clip = voiceRetry ?: return
viewModelScope.launch { deliverVoice(clip.wav, clip.durationMs, retry = clip) }
}
/** 丢掉那段待重发的语音(用户点了「丢弃」)。 */
fun discardVoice() {
voiceRetry = null
_state.update { it.copy(voiceDraft = null) }
}
/** 关掉「她的语音回复」那张卡片(播放器由渲染层在同一个动作里停掉)。 */
fun dismissVoiceReply() {
val file = _state.value.voiceReply?.file
_state.update { it.copy(voiceReply = null) }
if (file != null) {
viewModelScope.launch { withContext(Dispatchers.IO) { file.delete() } }
}
}
/**
* 这一条语音回复的**自动播放**已经用掉了(渲染层在真的起了播放之后调)。
*
* 置位之后即使组合被重建(转屏 / 切走再回来)也不会再自动响一遍——见 [VoiceReply.autoPlay]。
* 幂等:已经置假时 `copy` 出的是同一个值,不会多一次有意义的变更。
*/
fun consumeVoiceReplyAutoPlay() {
_state.update { state ->
val reply = state.voiceReply
if (reply == null || !reply.autoPlay) state else state.copy(voiceReply = reply.copy(autoPlay = false))
}
}
/**
* 把一段 WAV 发出去([sendVoice] 与 [retryVoice] 共用)。
*
* 键由纯函数 [voiceClientMsgIdFor] 决定:**重发复用 [retry] 那个键**,新录音现铸一个。
* 判定「发出去没有」用的是 `sendVoiceInput` 的返回值 + **发出那一刻**的连接状态
* (与文本那条 [sendThroughLadder] 同一手法):连接就绪却仍写不出去,与「还没连上」
* 是两句不同的话,用户要做的事也不同。
*/
private suspend fun deliverVoice(wav: ByteArray, durationMs: Int, retry: VoiceRetryClip?) {
val draft = retry?.let { VoiceDraftInfo(it.clientMsgId, it.durationMs, it.totalBytes) }
val clientMsgId = voiceClientMsgIdFor(draft) { UUID.randomUUID().toString() }
val audio = withContext(Dispatchers.Default) { Base64.encodeToString(wav, Base64.NO_WRAP) }
val totalBytes = wav.size
// 乐观气泡:与文本消息**同一个** pending 列表(正文空、带语音时长)。
_state.update {
it.copy(
pending = it.pending + PendingMessage(
localId = clientMsgId,
text = "",
voiceDurationMs = durationMs,
),
error = null,
voiceDraft = null,
voiceNotice = null,
)
}
val ws = socket
val sent = ws?.sendVoiceInput(sessionId, audio, VoiceClip.FORMAT, DEVICE_ID, clientMsgId) ?: false
if (sent) {
voiceRetry = null
return
}
val connection = ws?.state?.value ?: WsState.Idle
val reason = if (connection == WsState.Connected) {
VOICE_SEND_FAILED_MESSAGE
} else {
VOICE_NOT_CONNECTED_MESSAGE
}
// 回滚走的是**同一个** rollbackPending(摘掉刚才那枚气泡、把原因挂进 error 通道),
// 但不走 [rollback]:语音没有可以退回输入栏的正文 / 图 / 附件,那段字节留在
// [voiceRetry] 里等用户决定(重发或丢弃)。
voiceRetry = VoiceRetryClip(clientMsgId, wav, durationMs, totalBytes)
_state.update {
it.rollbackPending(clientMsgId, reason).copy(
voiceDraft = VoiceDraftInfo(clientMsgId, durationMs, totalBytes),
)
}
}
/**
* `voice_output` 落地(契约 v0.96 §3.15):**她的语音回复**(音频 + 她说的话)。
*
* ## 判据全在 domain(纯函数)
* [isSilentVoiceProvider] / [voiceOutputHasAudio] 决定「这一轮有没有真音频」,
* [voiceReplyText] 决定显示哪一句(`reply` = 她说的,`text` = 转写)。`silent` 档
* **不画播放控件**,只显示文字 + 一句说明(见 `VoiceReplyRow`)。
*
* ## 音频先落成本地文件
* base64 解码 + 写缓存都在 IO 上做(几 MB 的活不能占主线程),解码 / 落盘失败就退化成
* 「只有文字 + 那句『本机没能准备好』」——**绝不**把本机的失败说成「她只回了文字」
* (那是替服务端下一个它没下的结论)。
*
* ## 只定向回原连接、断线不补投
* 本方法**不做任何补救**(不拉历史、不重发 `voice_input`):契约明写这一帧不补投,
* 回合的正文与转写本来就会经流式 / `message_persisted` 落到消息列表里。
*/
private fun onVoiceOutput(event: WsEvent.VoiceOutput) {
val provider = event.provider
val hasAudio = voiceOutputHasAudio(event.audio, provider)
val id = "voice-reply-${UUID.randomUUID()}"
val previous = _state.value.voiceReply?.file
_state.update {
it.copy(
voiceReply = VoiceReply(
id = id,
reply = voiceReplyText(event.reply, event.text),
transcript = event.text?.takeIf { text -> text.isNotBlank() },
format = event.format,
provider = provider,
hasAudio = hasAudio,
file = null,
),
)
}
// 这一轮没有音频 ⇒ 到这里就结束了(正文已经在上面那一格里)。
val audio = event.audio?.takeIf { hasAudio } ?: return
viewModelScope.launch {
val file = withContext(Dispatchers.IO) {
previous?.delete()
storeVoiceReply(audio, event.format)
}
_state.update { state ->
// 只有「这一格还是我刚放进去的那一条」才补文件:迟到的解码不许覆盖更新的那一条。
if (state.voiceReply?.id == id) {
state.copy(voiceReply = state.voiceReply.copy(file = file))
} else {
file?.delete()
state
}
}
}
}
/**
* 把 `voice_output` 的 base64 音频落成 `cacheDir/media/` 下一个文件(**IO 线程调用**)。
*
* 目录与写入纪律直接复用 [VoiceStore] / [DocumentCache]:同一个 `media/` 根(与接收侧的
* 语音副本同一层,与 `voice.url` 的 `/api/v1/media/` 同源)、同样的「先写 `.part` 再改名」。
* 名字是现铸的随机名(`reply-<uuid>.<ext>`)而不是内容寻址:这一份是**一次性**的实时产物,
* 没有第二个人会来按名字取它。
*
* @return 可直接交给 `MediaPlayer` 的文件;任何一步失败回 `null`(调用方据此退化成文字)。
*/
private fun storeVoiceReply(base64: String, format: String?): File? = runCatching {
val bytes = Base64.decode(base64, Base64.NO_WRAP)
if (bytes.isEmpty()) return null
// 扩展名只影响可读性:`MediaPlayer` 按文件头嗅探,不认扩展名。不认识的格式一律 `.bin`。
val extension = format?.trim()?.lowercase()?.takeIf { it in VOICE_REPLY_FORMATS } ?: "bin"
val file = File(
VoiceStore.directory(container.appContext.cacheDir),
"reply-${UUID.randomUUID()}.$extension",
)
DocumentCache.write(file, bytes)
file
}.getOrNull()
/** 记一条语音输入的如实告知,到期自动收走(与 [showAttachmentNotice] 同一形态)。 */
private fun showVoiceNotice(text: String) {
_state.update { it.copy(voiceNotice = text) }
viewModelScope.launch {
kotlinx.coroutines.delay(VOICE_NOTICE_MILLIS)
_state.update { if (it.voiceNotice == text) it.copy(voiceNotice = null) else it }
}
}
/**
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
@@ -2903,6 +3177,12 @@ class ChatViewModel(
is WsEvent.MessageDeleted -> applyMessageDeleted(event.id)
// 语音应答(契约 v0.96 §3.15):会话归属由解析层从 `data` 提到帧上(帧壳没有
// sessionId),所以上面那道 `isForSession` 闸对它**真的生效**——别的会话的语音
// 不会在本会话响起来。未知归属(坏帧)按同一道闸的既有口径放行(「无法判定」≠
// 「不属于本会话」)。判据与取舍见 [onVoiceOutput]。
is WsEvent.VoiceOutput -> onVoiceOutput(event)
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
_state.update { it.copy(busyMode = event.mode).withActiveTurns(event.activeTurns) }
}
@@ -3382,6 +3662,46 @@ private const val SHARE_TEXT_NOT_STAGED = "这段内容没能存成文件,已
/** 附件提示在界面上停留的时长(同上:描述动作,不描述持续状态)。 */
private const val ATTACHMENT_NOTICE_MILLIS = 8 * 1000L
/** 语音输入被本端挡下时那句告知停留的时长(同其它几条「动作刚发生」的告知)。 */
private const val VOICE_NOTICE_MILLIS = 8 * 1000L
/**
* 语音**还没连上**、因此一个字节都没发出去时的如实告知(走 `error` 通道,与「发送失败」同档)。
*
* 说清三件事:没连上(所以确实没发出去)、这段没丢(留在 `voiceDraft` 那一行)、下一步按「重发」。
* 刻意不写「发送失败」:失败的是连接,而这一段一次都没上过路——这与文本那条
* [SEND_QUEUED_NOTICE] 的措辞纪律同源,只是语音的字节留在本端等**用户**决定(不进自动补发队列)。
*/
private const val VOICE_NOT_CONNECTED_MESSAGE = "还没连上,这段语音没发出去;连上后点「重发」再试一次"
/**
* 连接就绪却仍写不出去(socket 对象已空 / 序列化抛异常)时的如实告知。
*
* 与 [VOICE_NOT_CONNECTED_MESSAGE] 分开:这一档说的不是连接,而是这一次写帧真的失败了,
* 所以不提「连上后」——那时候它已经连着。
*/
private const val VOICE_SEND_FAILED_MESSAGE = "这段语音没发出去,点「重发」再试一次"
/**
* `voice_output` 的音频落盘时**允许照抄**的扩展名(契约白名单 `wav|mp3|ogg`,服务端目前恒 `wav`)。
* 白名单之外的取值一律用 `.bin`:扩展名只用来让文件看得懂,**不**参与解码判定。
*/
private val VOICE_REPLY_FORMATS: Set<String> = setOf("wav", "mp3", "ogg")
/**
* 一段「录好、还没发出去」的语音的**完整**内容(只在 VM 私有字段里,不进界面状态)。
*
* [wav] 是已经封好 RIFF 头的那份字节:重发时原样再编码一次 base64,**不重新封装**——
* 重发的必须是同一段字节,否则「同一个 `clientMsgId`」在服务端判重之后留下的那份音频
* 与用户眼前这段就对不上了。
*/
private data class VoiceRetryClip(
val clientMsgId: String,
val wav: ByteArray,
val durationMs: Int,
val totalBytes: Int,
)
/**
* 「并行没排上」告知在界面上停留的时长(同上:描述刚发生的那一次发送)。
*
@@ -585,6 +585,9 @@ fun PendingBubble(
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
)
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
// 语音:正文是空的(本端不转写,用户说的话要等服务端 STT 之后才落库),
// 所以这一条消息至少要有「这是语音、多长」这一枚可见凭据——与接收侧的语音形态同一个外观。
item.voiceDurationMs?.let { durationMs -> VoiceClipLabel(durationMs) }
if (item.text.isNotEmpty()) Bubble(plainBubble(item.text), isUser = true, isLast = true)
}
}
@@ -0,0 +1,349 @@
package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.chat.VoiceDraftInfo
import top.yeij.cyrene.domain.chat.VoicePlaybackState
import top.yeij.cyrene.domain.chat.failureOf
import top.yeij.cyrene.domain.chat.isActiveFor
import top.yeij.cyrene.domain.chat.isLoadingFor
import top.yeij.cyrene.domain.chat.voiceElapsedLabel
import top.yeij.cyrene.domain.chat.voiceReplyNote
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Close
import top.yukonga.miuix.kmp.icon.extended.Mic
import top.yukonga.miuix.kmp.icon.extended.Pause
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.theme.MiuixTheme
/**
* 输入栏那几个**语音**形态(录音中 / 待重发 / 她的语音回复),以及乐观气泡里那一枚「语音」。
*
* ## 为什么单独一个文件
* 它们四个共用同一套尺寸与配色,而这个文件之外没人需要知道这些细节:`ChatScreen` 只负责接线,
* `MessageBubble` 只调用最后一枚。放在一起,改一眼就能看全。
*
* ## 图标从哪来(查过 AAR,不是猜的)
* `miuix-icons-android:0.9.3` 的 **basic** 包只有 7 枚,**没有麦克风**;**extended** 包里有真的
* `Mic` / `MicSlash`(`javap` 确认过 `MicKt.getMic(MiuixIcons)`),而本模块本来就在用 extended
* (`VoiceBubble.kt` 的 `Play` / `Pause`、`ChatScreen.kt` 的 `File` / `Image`)。
*
* ## 读屏名字即设备验收的 `content-desc`
* 四枚可点控件的 `contentDescription` 都抽成了常量([VOICE_RECORD_DESCRIPTION] 等):
* `uiautomator dump` 靠它们定位,改文案等于改验收口径,两处一起改。
*/
/** 「开始录音」那枚麦克风的读屏名字。 */
const val VOICE_RECORD_DESCRIPTION: String = "按住说话(点按开始录音)"
/** 录音中那枚麦克风(点它 = 结束并发送)。 */
const val VOICE_RECORD_STOP_DESCRIPTION: String = "结束录音并发送"
/** 录音中的「取消」。 */
const val VOICE_RECORD_CANCEL_DESCRIPTION: String = "取消这段录音"
/** 待重发那一段的「重发」。 */
const val VOICE_DRAFT_RETRY_DESCRIPTION: String = "重发这段语音"
/** 待重发那一段的「丢弃」。 */
const val VOICE_DRAFT_DISCARD_DESCRIPTION: String = "丢弃这段语音"
/** 她的语音回复的播放 / 停止。 */
const val VOICE_REPLY_PLAY_DESCRIPTION: String = "播放她的语音回复"
const val VOICE_REPLY_STOP_DESCRIPTION: String = "停止播放她的语音回复"
/** 关掉她的语音回复那一张卡片。 */
const val VOICE_REPLY_DISMISS_DESCRIPTION: String = "关闭这条语音回复"
/** 乐观气泡里那枚「语音」的字样(与接收侧的 [VOICE_LABEL] 同一个词,不另造)。 */
const val VOICE_CLIP_LABEL: String = VOICE_LABEL
/**
* 麦克风**没授权**时的如实告知。
*
* 说「到系统设置里允许」而不是再申请一次:本端只在第一次点麦克风时申请(平台规范),
* 被拒之后重复申请只会被系统静默挡回(Android 不再弹框),那样用户点十次都毫无反应。
*/
const val VOICE_PERMISSION_DENIED_MESSAGE: String =
"没有麦克风权限,到系统设置里允许后再试"
/**
* 有权限、但麦克风**现在起不来**(被别的应用占用 / 设备不支持这组采样参数)。
*
* 与 [VOICE_PERMISSION_DENIED_MESSAGE] 分开:那一档用户要去设置页,这一档要等一会或换个应用;
* 说成同一句就是把人引向一个解决不了问题的动作。
*/
const val VOICE_UNAVAILABLE_MESSAGE: String = "麦克风现在打不开,可能被别的应用占着"
/** 图标尺寸:与接收侧语音形态同一档。 */
private val VOICE_ICON_SIZE = 18.dp
/** 卡片底色与圆角:与 `VoiceMessageRow` 那一枚同形(同一个产品里的语音长一个样)。 */
private val VOICE_CARD_SHAPE = RoundedCornerShape(12.dp)
/**
* 录音中那一行:`🎤 录音中 · 0:07 [取消] [发送]`。
*
* 时长由**已收到的字节数**反算(见 `VoiceRecorder`),所以屏幕上这个数字与发出去的音频长度
* 严格一致。到 60 秒时 `VoiceRecorder` 会置「满了」标记、由调用方直接收尾——这里不做倒计时:
* 一个还在数的计时器在「已经满了」那一刻是假话。
*
* 两枚按钮分开(不是「点头像就发」那种手势):录音是一条**有去无回**的动作(这段 bytes 一旦
* 发出就落进服务端编排),取消必须和发送一样容易按到。
*/
@Composable
fun VoiceRecordingRow(
elapsedMs: Int,
onCancel: () -> Unit,
onSend: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.error,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "录音中 · ${voiceElapsedLabel(elapsedMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onCancel,
modifier = Modifier.semantics { contentDescription = VOICE_RECORD_CANCEL_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
Button(onClick = onSend) { Text("发送") }
}
}
/**
* 一段**录好但没发出去**的语音:`🎤 语音 · 0:07 [重发] [丢弃]`。
*
* 它只在一件事之后出现:`voice_input` 没能上行(连接没就绪 / 写帧失败)。此时那段字节**只在
* 内存里**,界面上必须给两条出路——重发(**复用同一个 `clientMsgId`**,服务端据此判重)或丢弃。
* 不给「退回到输入栏」那种出路:语音没有可以退回到输入框的形态。
*/
@Composable
fun VoiceDraftRow(
draft: VoiceDraftInfo,
onRetry: () -> Unit,
onDiscard: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(draft.durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.weight(1f),
)
TextButton(
text = "重发",
onClick = onRetry,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_RETRY_DESCRIPTION },
)
TextButton(
text = "丢弃",
onClick = onDiscard,
modifier = Modifier.semantics { contentDescription = VOICE_DRAFT_DISCARD_DESCRIPTION },
)
}
}
/**
* 她的语音回复:`[▶] 她的语音回复 · 0:03 [×]` + 她说的话([VoiceReply.reply])。
*
* ## 只有文字那一档
* `provider == "silent"`(或这一轮根本没有 audio)时**不画播放控件**,只画文字 + 一句
* [voiceSilentNote](「这一轮她只回了文字,没有语音」)。契约的硬要求是「UI 别把它画成播放器」;
* 补那句解释是为了让「她选择只用文字」与「播放器坏了」在屏幕上分得开。
*
* ## 可播放但播不了
* 音频在、本机解码 / 落盘失败时**照样画**播放控件:点了会走到 [failureOf] 那一行如实说
* 「播不了」——这比一个看起来什么都没有的控件诚实。
*/
@Composable
fun VoiceReplyRow(
reply: VoiceReply,
playback: VoicePlaybackState,
onTogglePlay: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val active = playback.isActiveFor(reply.id)
val loading = playback.isLoadingFor(reply.id)
val failure = playback.failureOf(reply.id)
val text = reply.reply
Column(
modifier = modifier
.fillMaxWidth()
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 10.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (reply.playable) {
// 整枚可点(不只是 18dp 的图标),读屏名字说清「点它会怎样」。
Row(
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onTogglePlay)
.semantics {
contentDescription = if (active) {
VOICE_REPLY_STOP_DESCRIPTION
} else {
VOICE_REPLY_PLAY_DESCRIPTION
}
}
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = if (active) MiuixIcons.Pause else MiuixIcons.Play,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
} else {
Text(
text = "她的语音回复",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
// 「现在在干什么」与「点它会怎样」分开说(与接收侧语音形态同一条纪律):
// 只有真的在取字节时才说「准备中…」,正在响时不冒充加载。
if (loading) {
Text(
text = "准备中…",
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = onDismiss,
modifier = Modifier.semantics { contentDescription = VOICE_REPLY_DISMISS_DESCRIPTION },
) {
Icon(imageVector = MiuixIcons.Basic.Close, contentDescription = null)
}
}
}
text?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
modifier = Modifier.padding(start = 2.dp, end = 8.dp),
)
}
// 「只有文字」与「本机没准备好」是两件事,各说各的那句(判据是纯函数)。
voiceReplyNote(silent = reply.silent, hasAudioFile = reply.file != null)?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
failure?.let {
Text(
text = it,
style = MiuixTheme.textStyles.footnote2,
color = MiuixTheme.colorScheme.error,
)
}
}
}
/**
* 乐观气泡里那一枚「语音」:`🎤 语音 · 0:07`。
*
* 用户按住说话之后,气泡**立刻**出现(与文本消息的乐观路径同一个 `pending` 列表、同一个回滚),
* 只是正文还没到——转写要等服务端。所以这里只画媒体类型与时长,**不画播放控件**:
* 本地已经发出去的那段音频不在播放通道里(它已经在服务端了,回放应当走历史消息那条路)。
*/
@Composable
fun VoiceClipLabel(durationMs: Int, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(VOICE_CARD_SHAPE)
.background(MiuixTheme.colorScheme.surfaceContainerHigh)
.padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = MiuixIcons.Mic,
contentDescription = null,
tint = MiuixTheme.colorScheme.primary,
modifier = Modifier.size(VOICE_ICON_SIZE),
)
Text(
text = "$VOICE_CLIP_LABEL · ${voiceElapsedLabel(durationMs)}",
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainerHigh,
)
}
}
@@ -108,6 +108,36 @@ class VoicePlayer(
}
}
/**
* 放一段**已经在手上**的音频(`voice_output` 那条路:base64 就在帧里,不需要下载)。
*
* 与 [toggle] 走**同一条通道**、同一套身份比对与释放纪律(同一台喇叭上永远只有一条语音在响),
* 差别只有两个:音频不是「按消息取字节」而是由调用方现给([load]),而且键是
* `voice_output` 那一帧自己铸的 [clipId](它不是历史消息,没有 `messageId` 可用)。
*
* 同样遵守「再点一下 = 停」:`clipId` 正在放 / 正在载入时,这一下是**停**。
*
* @param load 取这份本地文件(写缓存 / 解码都在里面,调用方应切到 IO)。
*/
fun toggleClip(clipId: String, load: suspend () -> Result<File>) {
if (_state.value.isActiveFor(clipId)) {
stop()
return
}
stop()
_state.value = _state.value.pressed(clipId)
loading = scope.launch {
load().fold(
onSuccess = { file ->
if (_state.value.isActiveFor(clipId)) play(clipId, file)
},
onFailure = { error ->
_state.value = _state.value.failed(clipId, voicePlaybackFailureMessage(error))
},
)
}
}
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
fun stop() {
loading?.cancel()
@@ -0,0 +1,49 @@
package top.yeij.cyrene.ui.chat
import java.io.File
/**
* `voice_output` 在界面上的那一份「她的语音回复」(只管**这一条实时应答**,不进历史)。
*
* ## 为什么它不在消息列表里
* 契约 v0.96 §3.15:这一帧**只定向回发起 `voice_input` 的那条连接**、**连接断了不补投**,
* 而且回合的正文本来就会经流式 / `message_persisted` 落进消息列表。所以它不是一条消息,
* 是「这一次语音回合的音频 + 她说的话」这一次性产物:放在输入栏上方一条卡片里,
* 用户随时可以关掉([ChatViewModel.dismissVoiceReply]),它不参与排序、不占历史。
*
* ## [hasAudio] 与 [file] 是两件事
* - [hasAudio]:**服务端**这一轮给了真音频(`provider != "silent"` 且 `audio` 非空白)。
* 为假 ⇒ 界面上**一个播放控件都不画**(画了就是点下去必然失败的按钮)。
* - [file]:音频在本机的那份文件。`hasAudio` 为真而它是 `null` = 本机解码 / 落盘失败
* ——这一档**照样画**播放控件(点了会如实报「播不了」),**绝不**把本机的故障说成
* 「她这一轮只回了文字」(见 `domain.chat.voiceReplyNote`)。
*
* @property id 播放通道的键:每条 `voice_output` 现铸一个(它不是历史消息,没有 `messageId`)。
* @property reply 她说的那一句(音频念的就是它),见 `domain.chat.voiceReplyText`。
* @property transcript 转写(载荷里的 `text`):那段音频被识别成什么;只作兜底与诊断。
* @property format 服务端给的音频格式;本端**不照抄它去解码**(`MediaPlayer` 按文件头嗅探)。
*/
data class VoiceReply(
val id: String,
val reply: String?,
val transcript: String?,
val format: String?,
val provider: String?,
val hasAudio: Boolean,
val file: File?,
/**
* 这一条还该不该**自动播放**(默认 true,播过就置假,见 [ChatViewModel.consumeVoiceReplyAutoPlay])。
*
* 为什么要这一位:这一帧**只在到达时**有意义(断线不补投),而到达的那一刻用户可能正在别的
* 会话 / 别的页面里。若只按「音频落好了就放」来触发,等他切回来时会**突然响一段旧语音**
* ——那不是他刚做的事。所以自动播放只认**第一次**落好的那一下,之后靠卡片上的播放键。
*/
val autoPlay: Boolean = true,
) {
/** 能不能画「播放」那枚控件:这一轮**有**音频,而且它已经落到本机。 */
val playable: Boolean get() = hasAudio && file != null
/** 这一轮是不是「没有音频」(= `provider == "silent"` 或 `audio` 缺席)。 */
val silent: Boolean get() = !hasAudio
}
@@ -0,0 +1,74 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 下行帧 `voice_output`(契约 v0.96 §3.15)。
*
* 两个容易踩空的地方在这里钉死:
* 1. **帧壳没有 `sessionId`**(契约明写,会话 id 在 `data` 里)——解析层必须把它提到帧上,
* 否则 `isForSession` 会把本帧**放行给每一个会话的 VM**(空值 = 无法判定),切到别的会话
* 时也会响一段本属于别的会话的语音;
* 2. `text` / `reply` / `provider` 三个键**一个都不能丢**:`reply` 是她说的话、`text` 是转写、
* `provider == "silent"` 是「这一轮没有音频」的唯一信号(判据在 `domain.chat`)。
*/
class WsEventVoiceOutputTest {
private val realFrame = """
{"type":"voice_output","data":{"audio":"UklGRg==","format":"wav","text":"你今天在吗","reply":"我在呢","sessionId":"web","deviceId":"voiceassist:cyrene:v1","provider":"emotiontts"}}
""".trimIndent()
@Test
fun `the real frame decodes with every payload key`() {
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
assertEquals("voice_output", event.type)
assertEquals("UklGRg==", event.audio)
assertEquals("wav", event.format)
assertEquals("你今天在吗", event.text)
assertEquals("我在呢", event.reply)
assertEquals("voiceassist:cyrene:v1", event.deviceId)
assertEquals("emotiontts", event.provider)
// 帧壳没有轮次 / 序号(这一帧不属任何流式段)。
assertNull(event.turnId)
assertNull(event.seq)
}
@Test
fun `the payload session id is lifted onto the frame so the session gate works`() {
val event = WsEvent.parse(realFrame) as WsEvent.VoiceOutput
assertEquals("web", event.sessionId)
assertTrue("本会话放行", event.isForSession("web"))
assertFalse("别的会话必须挡下(否则会在别的会话里响起来)", event.isForSession("web:other"))
}
@Test
fun `a silent frame keeps the marker and the text`() {
val frame = """
{"type":"voice_output","data":{"audio":"","format":"wav","text":"嗯","reply":"嗯,我在","sessionId":"web","provider":"silent"}}
""".trimIndent()
val event = WsEvent.parse(frame) as WsEvent.VoiceOutput
assertEquals("silent", event.provider)
assertEquals("", event.audio)
assertEquals("嗯,我在", event.reply)
assertEquals("web", event.sessionId)
}
@Test
fun `a frame without a payload still forms the event instead of vanishing`() {
// 坏帧(没有 data):会话归属退化成「无法判定」,但「这一帧来过」必须留下痕迹——
// 整帧丢掉就是「她回了语音而屏幕上什么都没有」,那比一张空卡片糟得多。
val event = WsEvent.parse("""{"type":"voice_output"}""") as WsEvent.VoiceOutput
assertNull(event.audio)
assertNull(event.reply)
assertEquals("", event.sessionId)
}
}
@@ -0,0 +1,52 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
/**
* 上行帧 `voice_input`(契约 v0.96 §3.15)。
*
* 两个键是**纯增量**:[WsOutbound.VoiceInput.deviceId] 与 `clientMsgId`。骨架里本来只有
* `audio` / `format` / `text` / `sessionId` 四个——那四者的线上形状在这里逐字钉死(老帧一个
* 字节都不许变),新增的两个只在有值时才出现(与后端 `omitempty` 同义)。
*
* `clientMsgId` 与文本 `chat` 帧**同形同语义**:同一个非空值服务端只承认第一条。所以真正的
* 「幂等」不在这一层(它只负责照发),而在 `ChatViewModel` + 纯函数 `voiceClientMsgIdFor`:
* 重发复用同一个键(那边有穷举单测)。
*/
class WsOutboundVoiceTest {
@Test
fun `the original four key frame is byte for byte what it was`() {
assertEquals(
"""{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web"}}""",
WsOutbound.VoiceInput(audio = "QUJD", format = "wav", sessionId = "web").toJson(),
)
}
@Test
fun `device id and client message id are carried next to the audio`() {
val frame = WsOutbound.VoiceInput(
audio = "QUJD",
format = "wav",
sessionId = "web",
deviceId = "voiceassist:cyrene:v1",
clientMsgId = "voice-9f3c",
).toJson()
val expected = """{"type":"voice_input","data":{"audio":"QUJD","format":"wav","sessionId":"web","deviceId":"voiceassist:cyrene:v1","clientMsgId":"voice-9f3c"}}"""
assertEquals(expected, frame)
}
@Test
fun `omitting the idempotency key omits the key itself rather than sending null`() {
val json = WsOutbound.VoiceInput(audio = "QUJD", format = "wav").toJson()
assertFalse("省略时不该出现 clientMsgId 键:$json", json.contains("clientMsgId"))
assertFalse("省略时不该出现 deviceId 键:$json", json.contains("deviceId"))
assertNull(WsOutbound.VoiceInput(audio = "QUJD").reqId)
}
}
@@ -0,0 +1,234 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 语音**上行**那一半的纯函数(契约 v0.96 §3.15 ①):录音参数、WAV 封装、体积闸门、幂等键。
*
* 这一组钉的是「发出去的字节长什么样」——WAV 头一旦有一个字段写错,服务端的解码器与 STT
* 会**静默**给出垃圾(或直接拒),而客户端这边完全看不出问题。所以头的每个字段都在这里逐个
* 读出来断言,而不是只比长度。
*/
class VoiceClipTest {
// —— 换算:16 kHz / 单声道 / 16-bit ⇒ 1 ms = 32 字节 ——
@Test
fun `one second of pcm is 32 000 bytes at the chosen parameters`() {
assertEquals(32_000, VoiceClip.BYTES_PER_SECOND)
assertEquals(32_000, VoiceClip.pcmBytesFor(1_000))
assertEquals(1_000, VoiceClip.durationMillisOf(32_000))
}
@Test
fun `duration and byte count round trip for typical clips`() {
assertEquals(1_920_000, VoiceClip.MAX_PCM_BYTES)
assertEquals(VoiceClip.MAX_RECORDING_MILLIS, VoiceClip.durationMillisOf(VoiceClip.MAX_PCM_BYTES))
// 不足一毫秒的零头一律向下取整(绝不把 0 字节说成 1 毫秒)。
assertEquals(0, VoiceClip.durationMillisOf(31))
assertEquals(0, VoiceClip.durationMillisOf(0))
assertEquals(0, VoiceClip.durationMillisOf(-5))
assertEquals(0, VoiceClip.pcmBytesFor(-5))
}
// —— WAV 头:逐字段 ——
@Test
fun `the wav header carries the riff wav fmt data chunks of pcm`() {
val pcmBytes = 32_000
val header = VoiceClip.wavHeader(pcmBytes)
assertEquals("RIFF-WAVE 头恒为 44 字节", 44, header.size)
assertEquals("RIFF", header.ascii(0, 4))
// RIFF 的 size 字段 = 文件总长 - 8(不含 "RIFF" 与 size 自己)。
assertEquals(44 - 8 + pcmBytes, header.intLe(4))
assertEquals("WAVE", header.ascii(8, 4))
assertEquals("fmt ", header.ascii(12, 4))
assertEquals("fmt 子块长度 = 16(PCM)", 16, header.intLe(16))
assertEquals("audioFormat = 1(PCM)", 1, header.shortLe(20))
assertEquals("单声道", 1, header.shortLe(22))
assertEquals("采样率 16 kHz", 16_000, header.intLe(24))
assertEquals("字节率 = 采样率 × 声道 × 位深/8", 32_000, header.intLe(28))
assertEquals("块对齐 = 声道 × 位深/8", 2, header.shortLe(32))
assertEquals("16-bit", 16, header.shortLe(34))
assertEquals("data", header.ascii(36, 4))
assertEquals("data 子块长度 = PCM 字节数", pcmBytes, header.intLe(40))
}
@Test
fun `toWav length is header plus pcm and the payload is untouched`() {
val pcm = ByteArray(64) { (it + 1).toByte() }
val wav = VoiceClip.toWav(pcm)
assertEquals("字节数 = 44 + PCM", 44 + pcm.size, wav.size)
assertEquals(44 - 8 + pcm.size, wav.intLe(4))
assertEquals(pcm.size, wav.intLe(40))
// 头之后一个字节都不许动(重采样 / 量化是录音机的事,这里只搬字节)。
assertArrayEquals(pcm, wav.copyOfRange(44, wav.size))
}
@Test
fun `an empty pcm still yields a self consistent 44 byte wav`() {
val wav = VoiceClip.toWav(ByteArray(0))
assertEquals(44, wav.size)
assertEquals(44 - 8, wav.intLe(4))
assertEquals(0, wav.intLe(40))
assertEquals("RIFF", wav.ascii(0, 4))
assertEquals("data", wav.ascii(36, 4))
}
// —— 准入:空 / 太短 / 超限 / 可发 ——
@Test
fun `an empty recording is rejected as empty rather than too short`() {
assertEquals(VoiceClipPlan.Empty, VoiceClip.plan(ByteArray(0)))
assertEquals(VOICE_EMPTY_MESSAGE, voiceClipRejection(VoiceClipPlan.Empty))
}
@Test
fun `a few milliseconds of audio is rejected as too short`() {
// 100 字节 = 3 ms:按下就松手的那一下。
val plan = VoiceClip.plan(ByteArray(100))
assertTrue("极短录音判 TooShort,实际 $plan", plan is VoiceClipPlan.TooShort)
assertEquals(3, (plan as VoiceClipPlan.TooShort).durationMs)
assertEquals(VOICE_TOO_SHORT_MESSAGE, voiceClipRejection(plan))
}
@Test
fun `exactly one second is the shortest acceptable clip`() {
val plan = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(1_000)))
assertTrue("满一秒就该放行,实际 $plan", plan is VoiceClipPlan.Ready)
assertEquals(1_000, (plan as VoiceClipPlan.Ready).durationMs)
assertEquals(44 + 32_000, plan.wav.size)
}
@Test
fun `one millisecond under the minimum is still rejected`() {
val pcm = ByteArray(VoiceClip.pcmBytesFor(1_000) - 32) // 32 字节 = 1 ms
val plan = VoiceClip.plan(pcm)
assertTrue("999 ms 仍算太短,实际 $plan", plan is VoiceClipPlan.TooShort)
assertEquals(999, (plan as VoiceClipPlan.TooShort).durationMs)
}
// —— 体积闸门(含「等于」那一格)——
@Test
fun `the byte ceiling equals the server's decoded inline cap`() {
// 与 chat.image 解码后同一条线:契约没给 voice_input 的上限,本端按已知最严的那一档设计。
assertEquals(ChatImageBudget.SERVER_MAX_DECODED_BYTES, VoiceClip.MAX_AUDIO_BYTES)
assertEquals(5 * 1024 * 1024, VoiceClip.MAX_AUDIO_BYTES)
}
@Test
fun `a clip exactly at the ceiling is accepted`() {
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES)
val total = VoiceClip.totalBytesFor(pcm.size)
assertEquals("上界取等于(<=)", VoiceClip.MAX_AUDIO_BYTES, total)
assertTrue(VoiceClip.withinLimit(total))
assertTrue("恰好到顶应当放行", VoiceClip.plan(pcm) is VoiceClipPlan.Ready)
}
@Test
fun `a clip one byte over the ceiling is refused with a named reason`() {
// 43 字节:total = 44 + 43 = MAX + 1。
val pcm = ByteArray(VoiceClip.MAX_AUDIO_BYTES - VoiceClip.HEADER_BYTES + 1)
val total = VoiceClip.totalBytesFor(pcm.size)
assertEquals(VoiceClip.MAX_AUDIO_BYTES + 1, total)
assertFalse(VoiceClip.withinLimit(total))
val plan = VoiceClip.plan(pcm)
assertTrue("超限判 TooLarge,实际 $plan", plan is VoiceClipPlan.TooLarge)
assertEquals(total, (plan as VoiceClipPlan.TooLarge).totalBytes)
assertEquals(VOICE_TOO_LARGE_MESSAGE, voiceClipRejection(plan))
}
@Test
fun `a 60 second recording stays well under the ceiling`() {
// 录满长度只用到闸门的 36%:撞上它说明硬件给了异常的量,不是正常路径。
val total = VoiceClip.totalBytesFor(VoiceClip.MAX_PCM_BYTES)
assertTrue(VoiceClip.withinLimit(total))
assertEquals(1_920_044, total)
}
@Test
fun `the rejection of a sendable clip is null`() {
val ready = VoiceClip.plan(ByteArray(VoiceClip.pcmBytesFor(2_000)))
assertNull(voiceClipRejection(ready))
}
// —— 录音中的时长文案 ——
@Test
fun `the elapsed label starts at zero and pads seconds`() {
assertEquals("0:00", voiceElapsedLabel(0))
assertEquals("0:00", voiceElapsedLabel(999))
assertEquals("0:07", voiceElapsedLabel(7_000))
assertEquals("1:05", voiceElapsedLabel(65_000))
// 负数(时钟回拨 / 调用方算错)不显示成 -1:-1。
assertEquals("0:00", voiceElapsedLabel(-1_000))
}
// —— 幂等键:重发必须复用(契约 v0.96:同一个非空值只承认第一条)——
@Test
fun `a fresh recording mints a new prefixed key`() {
val key = voiceClientMsgIdFor(null) { "9f3c" }
assertEquals("voice-9f3c", key)
assertTrue("前缀写进键里,好让服务端与日志一眼看出这条是语音", key.startsWith(VOICE_CLIENT_MSG_ID_PREFIX))
}
@Test
fun `retrying the same clip reuses its client message id`() {
val draft = VoiceDraftInfo(clientMsgId = "voice-keep", durationMs = 1_800, totalBytes = 57_644)
// 用户连点三次「重发」:每一次都必须拿同一个键(否则服务端会把同一句话答三遍)。
val attempts = List(3) {
voiceClientMsgIdFor(draft) { error("重发不该铸新键") }
}
assertEquals(listOf("voice-keep", "voice-keep", "voice-keep"), attempts)
}
@Test
fun `a different recording never inherits the old key`() {
val old = VoiceDraftInfo("voice-old", durationMs = 1_200, totalBytes = 38_444)
// 丢弃之后再录一段:状态里那一格已经清掉 ⇒ 现铸新键(否则新录音会被服务端当成重发吞掉)。
val fresh = voiceClientMsgIdFor(null) { "fresh" }
assertEquals("voice-fresh", fresh)
assertTrue(fresh != old.clientMsgId)
}
}
/** 从字节数组里取一段 ASCII(断言 WAV 头的四个 chunk 名用)。 */
private fun ByteArray.ascii(offset: Int, length: Int): String =
String(this, offset, length, Charsets.US_ASCII)
/** 小端有符号整数(WAV 头里全是 32 位小端)。 */
private fun ByteArray.intLe(offset: Int): Int =
(this[offset].toInt() and 0xFF) or
((this[offset + 1].toInt() and 0xFF) shl 8) or
((this[offset + 2].toInt() and 0xFF) shl 16) or
((this[offset + 3].toInt() and 0xFF) shl 24)
/** 小端 16 位无符号整数。 */
private fun ByteArray.shortLe(offset: Int): Int =
(this[offset].toInt() and 0xFF) or ((this[offset + 1].toInt() and 0xFF) shl 8)
@@ -0,0 +1,86 @@
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
/**
* `voice_output` 的呈现判据(契约 v0.96 §3.15):两件事必须判对,否则 UI 一定画错。
*
* 1. **这一轮有没有真音频**——`provider == "silent"` 是明文降级信号(`audio` 空、`text`/`reply`
* 有)。判错成「有」就会画出一个点下去必然失败的播放器;判错成「没有」则让本来能放的语音
* 变成一段文字。
* 2. **屏幕上该显示哪一句**——`reply` 是**她说的话**(音频念的就是它),`text` 是**转写**
* (我们那段音频被识别成什么)。两者的顺序反过来,用户看到的就是自己说的话冒充她的回答。
*/
class VoiceOutputTest {
// —— provider 降级信号 ——
@Test
fun `the silent provider is recognised literally`() {
assertTrue(isSilentVoiceProvider(VoiceProvider.SILENT))
// 机器可读码:容忍首尾空白与大小写,但不容忍别的词。
assertTrue(isSilentVoiceProvider(" SILENT "))
assertFalse(isSilentVoiceProvider("emotiontts"))
assertFalse(isSilentVoiceProvider(""))
assertFalse(isSilentVoiceProvider(null))
}
@Test
fun `silent frames never count as having audio`() {
// 契约:silent ⇒ audio 空。万一服务端还是塞了字节,也不能画播放器(它自己说这轮没发音)。
assertFalse(voiceOutputHasAudio(audio = null, provider = VoiceProvider.SILENT))
assertFalse(voiceOutputHasAudio(audio = "QUJD", provider = VoiceProvider.SILENT))
}
@Test
fun `a normal provider needs actual bytes to be playable`() {
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = "emotiontts"))
// 没给 provider(老服务端 / 省略该键)、但有字节 ⇒ 能放。
assertTrue(voiceOutputHasAudio(audio = "QUJD", provider = null))
// 有 provider 却没字节 ⇒ 没有音频(空串与 null 同一档)。
assertFalse(voiceOutputHasAudio(audio = "", provider = "emotiontts"))
assertFalse(voiceOutputHasAudio(audio = null, provider = "emotiontts"))
}
// —— text vs reply ——
@Test
fun `reply wins because it is what she actually said`() {
assertEquals("我在呢", voiceReplyText(reply = "我在呢", text = "你今天在吗"))
}
@Test
fun `a missing reply falls back to the transcript`() {
assertEquals("你今天在吗", voiceReplyText(reply = null, text = "你今天在吗"))
assertEquals("你今天在吗", voiceReplyText(reply = " ", text = "你今天在吗"))
}
@Test
fun `both missing means show nothing instead of inventing a line`() {
assertNull(voiceReplyText(reply = null, text = null))
assertNull(voiceReplyText(reply = "", text = " "))
}
// —— 那一句说明:两档说的不是同一件事 ——
@Test
fun `the silent note says only text this round`() {
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = false))
// silent 档即便本地碰巧有个文件也不该说「本机没准备好」。
assertEquals(VOICE_SILENT_NOTE, voiceReplyNote(silent = true, hasAudioFile = true))
}
@Test
fun `a local decode failure is reported as a local problem not as silence`() {
assertEquals(VOICE_REPLY_LOCAL_UNAVAILABLE_NOTE, voiceReplyNote(silent = false, hasAudioFile = false))
}
@Test
fun `a playable reply needs no note`() {
assertNull(voiceReplyNote(silent = false, hasAudioFile = true))
}
}