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:
1 parent
e0e657d5ee
commit
4e822df5ed
17 files changed
+2081
-39
No files matched your search
@@ -15,6 +15,16 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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 =
|
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
|
||||||
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
|
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 {
|
private fun send(outbound: WsOutbound): Boolean {
|
||||||
if (_state.value != WsState.Connected) return false
|
if (_state.value != WsState.Connected) return false
|
||||||
val socket = webSocket ?: return false
|
val socket = webSocket ?: return false
|
||||||
|
|||||||
@@ -600,12 +600,30 @@ sealed class WsEvent {
|
|||||||
override val type: String get() = "observability_log"
|
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(
|
data class VoiceOutput(
|
||||||
val audio: String?,
|
val audio: String?,
|
||||||
val format: String?,
|
val format: String?,
|
||||||
val text: String?,
|
val text: String?,
|
||||||
val reply: String?,
|
val reply: String?,
|
||||||
|
val deviceId: String? = null,
|
||||||
|
val provider: String? = null,
|
||||||
override val turnId: String? = null,
|
override val turnId: String? = null,
|
||||||
override val seq: Long? = null,
|
override val seq: Long? = null,
|
||||||
override val sessionId: String? = null,
|
override val sessionId: String? = null,
|
||||||
@@ -890,8 +908,20 @@ sealed class WsEvent {
|
|||||||
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
|
// 消息删除(D3 §3.8):载荷只有 id,会话归属**只在帧壳**上(缺席时上层按全局唯一 id 兜底)。
|
||||||
"message_deleted" -> decode<DeletedData>(data)
|
"message_deleted" -> decode<DeletedData>(data)
|
||||||
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
|
.let { MessageDeleted(it.id, turnId, seq, sessionId) }
|
||||||
"voice_output" -> decode<VoiceOutputData>(data)
|
"voice_output" -> decode<VoiceOutputData>(data).let {
|
||||||
.let { VoiceOutput(it.audio, it.format, it.text, it.reply, turnId, seq, sessionId) }
|
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`(服务端不设),与会话归属有关的一切
|
// 她主动说的话(v0.72)。帧壳**没有** `sessionId`(服务端不设),与会话归属有关的一切
|
||||||
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
|
// 都在载荷里(落库行 id / 时间戳)+ 消费方自己钉「主会话」这条事实,见 [ProactiveMessage]。
|
||||||
"proactive_message" -> decode<ProactiveData>(data).let {
|
"proactive_message" -> decode<ProactiveData>(data).let {
|
||||||
@@ -1129,12 +1159,22 @@ private data class TracePushData(val spans: List<AdminTraceSpanDto> = emptyList(
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
|
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
|
@Serializable
|
||||||
private data class VoiceOutputData(
|
private data class VoiceOutputData(
|
||||||
val audio: String? = null,
|
val audio: String? = null,
|
||||||
val format: String? = null,
|
val format: String? = null,
|
||||||
val text: String? = null,
|
val text: String? = null,
|
||||||
val reply: 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(
|
data class VoiceInput(
|
||||||
val audio: String? = null,
|
val audio: String? = null,
|
||||||
val format: String? = null,
|
val format: String? = null,
|
||||||
val text: String? = null,
|
val text: String? = null,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val deviceId: String? = null,
|
||||||
|
val clientMsgId: String? = null,
|
||||||
override val reqId: String? = null,
|
override val reqId: String? = null,
|
||||||
) : WsOutbound {
|
) : WsOutbound {
|
||||||
override val type: String get() = "voice_input"
|
override val type: String get() = "voice_input"
|
||||||
@@ -120,6 +134,8 @@ sealed interface WsOutbound {
|
|||||||
format?.let { put("format", it) }
|
format?.let { put("format", it) }
|
||||||
text?.let { put("text", it) }
|
text?.let { put("text", it) }
|
||||||
sessionId?.let { put("sessionId", 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
|
package top.yeij.cyrene.ui.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
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.DocumentCacheStore
|
||||||
import top.yeij.cyrene.data.repository.ImageCacheStore
|
import top.yeij.cyrene.data.repository.ImageCacheStore
|
||||||
import top.yeij.cyrene.data.repository.VoiceStore
|
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.data.ws.WsState
|
||||||
import top.yeij.cyrene.domain.chat.DocumentChips
|
import top.yeij.cyrene.domain.chat.DocumentChips
|
||||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
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.DraftDocument
|
||||||
import top.yeij.cyrene.domain.chat.ImageSource
|
import top.yeij.cyrene.domain.chat.ImageSource
|
||||||
import top.yeij.cyrene.domain.chat.SharedTextPlan
|
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.documentLoadFailureMessage
|
||||||
|
import top.yeij.cyrene.domain.chat.isActiveFor
|
||||||
import top.yeij.cyrene.domain.share.ShareRules
|
import top.yeij.cyrene.domain.share.ShareRules
|
||||||
import top.yeij.cyrene.domain.share.SharedContent
|
import top.yeij.cyrene.domain.share.SharedContent
|
||||||
import top.yukonga.miuix.kmp.basic.Button
|
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.basic.Sidebar
|
||||||
import top.yukonga.miuix.kmp.icon.extended.File
|
import top.yukonga.miuix.kmp.icon.extended.File
|
||||||
import top.yukonga.miuix.kmp.icon.extended.Image
|
import top.yukonga.miuix.kmp.icon.extended.Image
|
||||||
|
import top.yukonga.miuix.kmp.icon.extended.Mic
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
/** 判定「贴着底部」的容差(px)。 */
|
/** 判定「贴着底部」的容差(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 SEARCH_NOTICE_MILLIS = 8_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 麦克风相关的告知(没授权 / 起不来)挂多久(毫秒)。
|
||||||
|
*
|
||||||
|
* 与同页其它几条「动作刚发生的如实告知」取同一个值:它描述的也是刚刚那一次按键,
|
||||||
|
* 不是持续状态(权限是不是真的没有,用户再按一次麦克风就知道了)。
|
||||||
|
*/
|
||||||
|
private const val VOICE_FAILURE_NOTICE_MILLIS = 8_000L
|
||||||
|
|
||||||
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
|
/** 输入栏下方预留的高度:避开屏幕圆角,同时承载忙碌提示。 */
|
||||||
private val InputBottomSpacer = 22.dp
|
private val InputBottomSpacer = 22.dp
|
||||||
|
|
||||||
@@ -403,6 +419,93 @@ fun ChatScreen(
|
|||||||
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
|
val voicePlayer = rememberVoicePlayer(sessionId) { audio -> voiceStore.load(audio) }
|
||||||
val voicePlayback by voicePlayer.state.collectAsState()
|
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 的多选形态,**不需要任何存储权限**)。
|
// 系统相册选择器(PickMultipleVisualMedia 走 Photo Picker 的多选形态,**不需要任何存储权限**)。
|
||||||
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
|
// 上限就是本端的接收上限([ShareRules.MAX_IMAGES]):系统选择器会把可选张数卡在这个数上,
|
||||||
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
|
// 并把「最多 4 张」明写在界面上——所以「超限」只会出现在少数不认这个上限的回落路径上
|
||||||
@@ -569,8 +672,29 @@ fun ChatScreen(
|
|||||||
images = state.draftImages,
|
images = state.draftImages,
|
||||||
documents = state.draftDocuments,
|
documents = state.draftDocuments,
|
||||||
busyText = busyLabel(state),
|
busyText = busyLabel(state),
|
||||||
|
recordingMs = recordingState?.elapsedMs,
|
||||||
|
voiceDraft = state.voiceDraft,
|
||||||
|
voiceReply = state.voiceReply,
|
||||||
|
voicePlayback = voicePlayback,
|
||||||
onValueChange = viewModel::onDraftChange,
|
onValueChange = viewModel::onDraftChange,
|
||||||
onSend = viewModel::send,
|
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 = {
|
onPickImage = {
|
||||||
imagePicker.launch(
|
imagePicker.launch(
|
||||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||||
@@ -753,6 +877,14 @@ fun ChatScreen(
|
|||||||
state.sendQueueNotice?.let { message ->
|
state.sendQueueNotice?.let { message ->
|
||||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
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 ->
|
searchNotice?.let { message ->
|
||||||
Banner(text = message, color = MiuixTheme.colorScheme.onSurfaceVariantSummary)
|
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
|
@Composable
|
||||||
private fun InputBar(
|
private fun InputBar(
|
||||||
value: String,
|
value: String,
|
||||||
images: List<String>,
|
images: List<String>,
|
||||||
documents: List<DraftDocument>,
|
documents: List<DraftDocument>,
|
||||||
busyText: String,
|
busyText: String,
|
||||||
|
recordingMs: Int?,
|
||||||
|
voiceDraft: VoiceDraftInfo?,
|
||||||
|
voiceReply: VoiceReply?,
|
||||||
|
voicePlayback: VoicePlaybackState,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
|
onStartRecording: () -> Unit,
|
||||||
|
onStopRecording: () -> Unit,
|
||||||
|
onCancelRecording: () -> Unit,
|
||||||
|
onRetryVoice: () -> Unit,
|
||||||
|
onDiscardVoice: () -> Unit,
|
||||||
|
onToggleVoiceReply: () -> Unit,
|
||||||
|
onDismissVoiceReply: () -> Unit,
|
||||||
onPickImage: () -> Unit,
|
onPickImage: () -> Unit,
|
||||||
onRemoveImage: (Int) -> Unit,
|
onRemoveImage: (Int) -> Unit,
|
||||||
onPickDocument: () -> Unit,
|
onPickDocument: () -> Unit,
|
||||||
@@ -926,6 +1087,25 @@ private fun InputBar(
|
|||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
|
.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)
|
DraftImagePreviews(images = images, onRemove = onRemoveImage)
|
||||||
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
|
// 待发附件:输入栏上方的一排可移除芯片(顺序即发送顺序,服务端也按这个顺序存)。
|
||||||
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
|
// 整枚也可点:点开的是**本地**那一份预览(还没上传,还没有服务端存储名)。
|
||||||
@@ -934,46 +1114,64 @@ private fun InputBar(
|
|||||||
onRemove = onRemoveDocument,
|
onRemove = onRemoveDocument,
|
||||||
onClick = onPreviewDocument,
|
onClick = onPreviewDocument,
|
||||||
)
|
)
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
if (recordingMs != null) {
|
||||||
// 图片入口在还能再选时出现(上限 [ShareRules.MAX_IMAGES]):选满之后没必要再给一个
|
VoiceRecordingRow(
|
||||||
// 必然被挡回来的入口。到顶这件事本身由 VM 管(入口藏起来只是不给他白跑一趟的机会)。
|
elapsedMs = recordingMs,
|
||||||
if (images.size < ShareRules.MAX_IMAGES) {
|
onCancel = onCancelRecording,
|
||||||
IconButton(onClick = onPickImage) {
|
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(
|
Icon(
|
||||||
imageVector = MiuixIcons.Image,
|
imageVector = MiuixIcons.File,
|
||||||
contentDescription = "发送图片",
|
contentDescription = "添加文件附件",
|
||||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
TextField(
|
||||||
// 附件入口一直在:一回合最多 5 份,到顶时由 VM 给出「已达上限」的提示(而不是把入口藏起来)。
|
value = value,
|
||||||
IconButton(onClick = onPickDocument) {
|
onValueChange = onValueChange,
|
||||||
Icon(
|
label = "和昔涟说点什么…",
|
||||||
imageVector = MiuixIcons.File,
|
useLabelAsPlaceholder = true,
|
||||||
contentDescription = "添加文件附件",
|
maxLines = 5,
|
||||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
Spacer(Modifier.width(4.dp))
|
||||||
Spacer(Modifier.width(4.dp))
|
// 麦克风(点按开始 / 点按结束,见 [VOICE_RECORD_DESCRIPTION] 的措辞)。
|
||||||
TextField(
|
// 换行后的入口与图片 / 附件同档;不读偏好、不占文本发送的任何一条判据。
|
||||||
value = value,
|
IconButton(onClick = onStartRecording) {
|
||||||
onValueChange = onValueChange,
|
Icon(
|
||||||
label = "和昔涟说点什么…",
|
imageVector = MiuixIcons.Mic,
|
||||||
useLabelAsPlaceholder = true,
|
contentDescription = VOICE_RECORD_DESCRIPTION,
|
||||||
maxLines = 5,
|
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
)
|
||||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
}
|
||||||
modifier = Modifier.weight(1f),
|
Spacer(Modifier.width(4.dp))
|
||||||
)
|
Button(
|
||||||
Spacer(Modifier.width(8.dp))
|
onClick = onSend,
|
||||||
Button(
|
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
||||||
onClick = onSend,
|
// 纯附件回合服务端**支持**)。
|
||||||
// 图片与附件都可以单独发出(契约:`text` / `image` / `documents` 任一项非空即可,
|
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
|
||||||
// 纯附件回合服务端**支持**)。
|
) {
|
||||||
enabled = value.isNotBlank() || images.isNotEmpty() || documents.isNotEmpty(),
|
Text("发送")
|
||||||
) {
|
}
|
||||||
Text("发送")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
|
// 输入栏下方预留的空间:让输入框/按钮避开屏幕圆角,同时放忙碌提示
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package top.yeij.cyrene.ui.chat
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.ChatImagePreparer
|
||||||
import top.yeij.cyrene.data.image.PreparedImage
|
import top.yeij.cyrene.data.image.PreparedImage
|
||||||
import top.yeij.cyrene.data.repository.ChatAck
|
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.MessageEditDraft
|
||||||
import top.yeij.cyrene.data.repository.MessageRepository
|
import top.yeij.cyrene.data.repository.MessageRepository
|
||||||
import top.yeij.cyrene.data.repository.PagedMessages
|
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.applyMessageEdit
|
||||||
import top.yeij.cyrene.data.repository.historyForSession
|
import top.yeij.cyrene.data.repository.historyForSession
|
||||||
import top.yeij.cyrene.data.repository.mergeMessages
|
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.SharedTextPlan
|
||||||
import top.yeij.cyrene.domain.chat.TurnBinding
|
import top.yeij.cyrene.domain.chat.TurnBinding
|
||||||
import top.yeij.cyrene.domain.chat.TurnBucket
|
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.allClosed
|
||||||
import top.yeij.cyrene.domain.chat.closingBucketOf
|
import top.yeij.cyrene.domain.chat.closingBucketOf
|
||||||
import top.yeij.cyrene.domain.chat.closingTurn
|
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.readBounded
|
||||||
import top.yeij.cyrene.domain.chat.toDocumentFailure
|
import top.yeij.cyrene.domain.chat.toDocumentFailure
|
||||||
import top.yeij.cyrene.domain.chat.turnBucketOf
|
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.withSteeredMessage
|
||||||
import top.yeij.cyrene.domain.chat.withTurnBegin
|
import top.yeij.cyrene.domain.chat.withTurnBegin
|
||||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||||
@@ -109,6 +120,15 @@ data class PendingMessage(
|
|||||||
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
|
* 用户打的字照发、附件随 `chat.documents` 一起发(见 `05` 的定案)。
|
||||||
*/
|
*/
|
||||||
val documents: List<DraftDocument> = emptyList(),
|
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`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
|
* `LaunchedEffect`(把上层那句告知立起来、并把用户带回主会话),不是「置了就完了」的一个位。
|
||||||
*/
|
*/
|
||||||
val sessionGone: Boolean = false,
|
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)。 */
|
/** 渲染顺序:已落库 → 乐观发送中 → 流式中(升序,UI 再反向喂给 reverseLayout)。 */
|
||||||
val items: List<ChatDisplayItem>
|
val items: List<ChatDisplayItem>
|
||||||
@@ -918,13 +966,26 @@ class ChatViewModel(
|
|||||||
it.draftDocuments.isNotEmpty() ||
|
it.draftDocuments.isNotEmpty() ||
|
||||||
it.pending.isNotEmpty() ||
|
it.pending.isNotEmpty() ||
|
||||||
it.queuedForConnection.isNotEmpty() ||
|
it.queuedForConnection.isNotEmpty() ||
|
||||||
it.shareConfirm != null
|
it.shareConfirm != null ||
|
||||||
|
// 一段录好但没发出去的语音:它只在本端内存里(没有任何服务端副本),
|
||||||
|
// 交掉这一格 = 让用户重说一遍——与「还没发出去的字」同一条理由。
|
||||||
|
it.voiceDraft != null
|
||||||
}
|
}
|
||||||
|
|
||||||
private var socket: ChatSocket? = null
|
private var socket: ChatSocket? = null
|
||||||
private var repository: MessageRepository? = null
|
private var repository: MessageRepository? = null
|
||||||
private var oldestLoadedAt: Long? = null
|
private var oldestLoadedAt: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一段**录好、但没发出去**的语音的字节(`voice_input` 没能上行)。
|
||||||
|
*
|
||||||
|
* 刻意是 VM 的字段而不是 [ChatUiState] 的一格:状态会被反复 `copy` 并逐字段比较(流式期间
|
||||||
|
* 每秒几十次),几 MB 的 `ByteArray` 放在那里等于每次比较都扫一遍;而它的生命周期与 VM
|
||||||
|
* 完全相同(转屏不丢、切走会话仍在**这一格**里,见 `ChatSessionSlots`)。
|
||||||
|
* 状态里只留小字段 [ChatUiState.voiceDraft](键 / 时长 / 字节数)供渲染。
|
||||||
|
*/
|
||||||
|
private var voiceRetry: VoiceRetryClip? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **每一路**一个流式正文解析器(键与 `ChatUiState.streams` 同源)。
|
* **每一路**一个流式正文解析器(键与 `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 分享进来的内容按**现有发送路径**发出
|
* 「分享到 App」的落地路径:把从别的 App 分享进来的内容按**现有发送路径**发出
|
||||||
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
|
* (短文本 → WS,长文本 / 图片 / 附件 → REST,图片先 [ChatImagePreparer] 重编码、
|
||||||
@@ -2903,6 +3177,12 @@ class ChatViewModel(
|
|||||||
|
|
||||||
is WsEvent.MessageDeleted -> applyMessageDeleted(event.id)
|
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) {
|
is WsEvent.SessionBusy -> if (event.sessionId == sessionId) {
|
||||||
_state.update { it.copy(busyMode = event.mode).withActiveTurns(event.activeTurns) }
|
_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 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) },
|
onClick = { index -> item.documents.getOrNull(index)?.let(onDocumentClick) },
|
||||||
)
|
)
|
||||||
item.imageUri?.takeIf { it.isNotBlank() }?.let { ChatImage(it, onImageClick) }
|
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)
|
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]。 */
|
/** 停下来并释放(幂等):取消取字节、拆掉播放器、状态回 [VoicePlaybackState.Idle]。 */
|
||||||
fun stop() {
|
fun stop() {
|
||||||
loading?.cancel()
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user