From 4e822df5edf7b854971f082b0c1c14093c7687be Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 18:59:11 +0800 Subject: [PATCH] =?UTF-8?q?W73=20=E8=AF=AD=E9=9F=B3=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=20=E2=80=94=E2=80=94=20=E5=BD=95=E9=9F=B3=20?= =?UTF-8?q?=E2=86=92=20voice=5Finput=20=E2=86=92=20voice=5Foutput=EF=BC=88?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=20v0.96=20=C2=A73.15=20=E2=91=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上行(用户按住麦克风说话): - `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 个用例)。 --- app/src/main/AndroidManifest.xml | 10 + .../yeij/cyrene/data/voice/VoiceRecorder.kt | 219 +++++++++++ .../top/yeij/cyrene/data/ws/ChatSocket.kt | 31 ++ .../kotlin/top/yeij/cyrene/data/ws/WsEvent.kt | 46 ++- .../top/yeij/cyrene/data/ws/WsOutbound.kt | 18 +- .../top/yeij/cyrene/domain/chat/VoiceClip.kt | 238 ++++++++++++ .../yeij/cyrene/domain/chat/VoiceOutput.kt | 93 +++++ .../top/yeij/cyrene/ui/chat/ChatScreen.kt | 266 +++++++++++-- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 322 +++++++++++++++- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 3 + .../top/yeij/cyrene/ui/chat/VoiceInputBar.kt | 349 ++++++++++++++++++ .../top/yeij/cyrene/ui/chat/VoicePlayer.kt | 30 ++ .../top/yeij/cyrene/ui/chat/VoiceReply.kt | 49 +++ .../cyrene/data/ws/WsEventVoiceOutputTest.kt | 74 ++++ .../cyrene/data/ws/WsOutboundVoiceTest.kt | 52 +++ .../yeij/cyrene/domain/chat/VoiceClipTest.kt | 234 ++++++++++++ .../cyrene/domain/chat/VoiceOutputTest.kt | 86 +++++ 17 files changed, 2081 insertions(+), 39 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/voice/VoiceRecorder.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceClip.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/VoiceOutput.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceInputBar.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/VoiceReply.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventVoiceOutputTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/ws/WsOutboundVoiceTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceClipTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/VoiceOutputTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4ff5407..7cb32eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,16 @@ + + +