feat(voice): 接收侧语音消息 —— 显示 + 播放(W62 / 契约 v0.79 §4.4 与 §1.2 U19)

别的客户端(QQ / 微信)发来的语音,现在能正确显示并播放。只做接收侧,不做本端录音/发送。

契约口径(逐条落地,不自己发明键名):
- **判据 = `voice` 键存在**:绝不从 `content` 正文里认媒体类型(`[语音]` 占位标记自 v0.67 起不再
  下发,正文里也可能有用户自己打的同样字样);
- **按 `status` 分支**(`transcribed` / `empty` / `failed` 三态),正文一律照常显示、一个字不改;
- **`name` / `mime` / `size` / `url` 四者同生共死** ⇒ 凑不齐就**不画播放控件**(不给必然失败的按钮);
- **`durationMs` 缺席就不显示时长**(不反算、不显示 `0:00` 冒充);
- **不在 WS 实时帧上等 `voice`**(覆盖率:只有四个 REST 读取面填充)—— 实时到达的先按普通文本显示,
  由既有的「按 id 合并、新到的胜出」在下一次拉取时自动升级成语音气泡(新增单测钉住这条依赖)。

改了什么:
- `VoiceDto`(7 个键逐字照抄)+ `HistoryMessageDto.voice: VoiceDto? = null`(缺席与 null 同一件事);
- `domain/chat/VoiceMessage.kt`:判据与展示换算的**唯一落点**(`isVoiceMessage` / `voiceAudio` /
  `voiceDurationLabel` / `voiceNote` / `voiceMediaUrl` / `voicePlaybackFailureMessage`);
- `domain/chat/VoicePlayback.kt`:播放通道的纯状态机(Idle / Loading / Playing / Failed),
  每次转换都按 `messageId` 比对 ⇒ 迟到回调不许改状态;
- `ui/chat/VoicePlayer.kt`:`android.media.MediaPlayer`(平台 API,**不引任何新依赖**)驱动那条通道,
  三处收干净:换目标先停旧、换会话换实例、组合销毁 `onDispose` 释放;
- `ui/chat/VoiceBubble.kt`:`[播放/暂停] 语音 · 时长` + `empty` 那句补充说明 + 失败提示(一行,error 色);
  图标用 AAR 里真实存在的 `MiuixIcons.Play` / `Pause`(extended 包,basic 那 7 枚里没有);
- `data/api/MediaApi.kt` + `data/repository/VoiceStore.kt`:`GET {当前地址}{voice.url}`(自带的
  Bearer + 401 续期走 `ApiClientFactory`),字节落 `cacheDir/media/<存储名>`(复用 `DocumentCache`
  的名字判据 / 命中判定 / 原子落盘;**不落 `documents/`**,那是 FileProvider 唯一暴露的根);
- `MessageBubble` 渲染语音形态;`isContentlessUserMessage` 加一条「语音消息不算什么都没有」
  (否则空转写的语音会被扣上「内容没有被传过来」那顶**假**帽子);
- `devtools/fake-backend/`:新增语音夹具(`__control/messages?voice=&duration=` + 真 2 秒 WAV 的
  `/api/v1/media/{name}`)—— 生产库里一条带 `voice` 的真消息都没有,设备验收只能靠造。

为什么这么改:`MediaPlayer` 不 `release()` 会一直占着解码器与音频通路,而语音是唯一「会出声」的
消息形态,生命周期必须有一处说清楚(写在 `VoicePlayer` 的 KDoc);播放失败必须可见(按钮回未播 +
一行短提示),一条点了没反应的语音在用户眼里就是坏的。

验收:`assembleDebug` 通过;`testDebugUnitTest` 120 suites / 1458 tests / 0 failures / 0 errors
(基线 115 / 1405,新增 5 个套件 53 条)。
This commit is contained in:
AskaEth committed 2026-10-07 11:14:28 +08:00
1 parent 55612a3922
commit 2fbf24a675
20 files changed
+2330 -8

No files matched your search

+59 -4
View File
@@ -306,6 +306,57 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
## 8. 端点速查
### 8.1 语音夹具(接收侧:`voice` 键 + `GET /api/v1/media/{name}`)
**它解决什么问题**:生产库里**一条带 `voice` 的真消息都没有**(拉 200 条 0 命中),而语音的 `voice`
键**只在四个 REST 读取面**填充(契约 v0.79 §4.4 覆盖率)—— ⇒ 想验「语音气泡 + 播放」,必须由服务端
造一条**带 `voice` 键的历史消息**。
`POST /__control/messages` 多两个参数(其余不变):
| 参数 | 取值 | 说明 |
|---|---|---|
| `voice` | `transcribed` / `empty` / `failed` / `nocopy` / `none`(默认) | 见下表 |
| `duration` | 毫秒(默认 `2000`);`0` | `0` ⇒ **不打 `durationMs` 键**(验「缺席就不显示时长」那一档) |
| `voice=` | `source` / `status` | 音频副本 | 设备上该看到什么 |
|---|---|---|---|
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文 = 你给的 `text` |
| `empty` | `asr` / `empty` | 有 | 播放键照在;正文为空 ⇒ 多一句「没有识别出文字」 |
| `failed` | `asr` / `failed` | 有 | 播放键照在;正文由你用 `text=` 给(假后端**不**替你编服务端那句话) |
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信 `weixin-ilink` 那条路) |
服务端那一条:`GET /api/v1/media/{name}` 只认夹具名(`fakevoice0000000000000000000000.wav`),
回的是一段**真的 2 秒 440 Hz WAV**(启动时用 `wave` 现生成、16-bit PCM 单声道 8 kHz,不落盘),
其余名字一律 `404 media_not_found`。
```bash
B=http://127.0.0.1:18080
# 1) 灌一条有副本的语音(正文是转写文本)
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&text=%E6%98%8E%E5%A4%A9%E5%87%A0%E7%82%B9%E5%87%BA%E5%8F%91"
# 2) 确认库里那条真的带 voice 键(形状 = 契约 §4.4)
curl -s "$B/api/v1/sessions/web/messages?limit=1"
# 3) 副本字节本身(浏览器/播放器都能验:2 秒 440 Hz)
curl -s -o voice.wav "$B/api/v1/media/fakevoice0000000000000000000000.wav"
# 4) 另外三档(各灌一条,屏幕上应看得出四档的差别)
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=empty"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=failed&text=%E8%BF%99%E6%AE%B5%E8%AF%AD%E9%9F%B3%E6%B2%A1%E8%83%BD%E8%BD%AC%E6%88%90%E6%96%87%E5%AD%97"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=nocopy"
curl -s -X POST "$B/__control/messages?session=web&count=1&voice=transcribed&duration=0" # 不显示时长
```
**在 App 上验**(App 已按第 2 节指向本工具):灌完之后**重进聊天页**(或用 `POST /__control/ws-close`
踢一次重连触发补拉),那几条语音就会以语音气泡出现。
> ⚠️ 本工具的 `history` 握手回放**会把 `voice` 一起回放**(它直接回内存里那份消息对象),
> 而**真后端不会**(合同 §4.4:`history` 回放不带 `voice`)。所以在这里「重进页面就看到语音气泡」
> 比真后端**更容易**;真后端上「实时到达的语音先按普通文本显示、等下一次 REST 拉取才升级」那一段,
> 本工具验不到(它也不会复现「回放把已升级的气泡打回文本」那个反向效果)。
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
| 端点 | 备注 |
@@ -322,13 +373,14 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
| `GET /api/v1/personas` | 用户侧角色目录(不含 admin 专属 `thinking`) |
| `PATCH` / `DELETE /api/v1/messages/{id}` | 编辑 / 删除单条,并广播 `message_edited` / `message_deleted` |
| `POST /api/v1/chat` | **额外**实现的 REST 写路径(长文 / 图片 / 附件走这条),回 202 + `ChatAckDto` 形状 |
| `GET /api/v1/media/{name}` | 语音副本播放端点(契约 §1.2 **U19**):只认夹具名,回 2 秒 WAV;其余 `404 media_not_found`。⚠️ 真后端要 Bearer `owner\|admin`,本工具**不校验鉴权** ⇒ 这里验不到鉴权那一半(见 8.1) |
| `GET /ws/chat?token=…` | 手写 WebSocket;握手后立刻发 `history`(回放条数可配) |
`/__control/**`(同样包信封,但载荷是本工具自己的状态,不是产品契约):
| 端点 | 作用 |
|---|---|
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=` | 免费灌 N 条(`text` 支持 `{n}` 占位) |
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=&voice=&duration=` | 免费灌 N 条(`text` 支持 `{n}` 占位);`voice=` 灌一条**语音消息**(见 8.1) |
| `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
@@ -361,6 +413,7 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
| `SessionSummaryDto`(`title` / `lastMessage` / `personaId` 可整键缺席) | `app/.../data/api/dto/SessionDto.kt:16-50` |
| 会话排序 `pinned DESC, main DESC, created_at ASC` | `SessionDto.kt:8-9` + 线上夹具 `SessionDtoRealPayloadTest.kt:36-38` |
| `HistoryMessageDto` | `app/.../data/api/dto/MessageDto.kt:13-46` |
| `voice` 键(7 个键名 / 三态 / 四者同生共死)与 `GET /api/v1/media/{name}` | **契约**:`Cyrene_v2/Docs/api-client.md` §4.4「`voice` 语义」+ §1.2 **U19**(v0.79 已实现)。本工具按它造夹具,**不代表**真后端的逐个字节 |
| `after` 升序 / `before` 倒序 / `q` 与 `after` 互斥 | `app/.../data/api/SessionsApi.kt:71-101` + `MessageRepository.kt:79-101` |
| limit 默认 50 / 上限 200 | `app/.../data/repository/MessageRepository.kt:149-150` |
| `EnumTableDto` 的 6/5/9 张表与 `unknown:"display-raw"` | 线上夹具 `MetaDtoRealPayloadTest.kt:24`(逐字) |
@@ -382,9 +435,11 @@ App 侧三个判据:① 主会话(`web`)里**实时**多一条助手气泡
- **`platform` 过滤**是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传 `null`
(`MessageRepository.kt:42,99`),所以这条路径没被真正走过。
- **`q` 搜索**是 ASCII 大小写不敏感的子串匹配;`%` / `_` / `\` 不做通配(我根本不解析通配符)。
- **不实现**:admin 面全部端点、`documents` / `upload`、`voice_input` 的落库、`mode_switch` 的语义、
`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧**只记录、不处置**。因此 App 里
管理员页面、附件上传这类路径在这个假后端上跑不通。
- **不实现**:admin 面全部端点、`documents` / `upload` 的**摄取链路**、`voice_input` 的落库、
`mode_switch` 的语义、`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧
**只记录、不处置**。因此 App 里管理员页面、附件上传这类路径在这个假后端上跑不通。
⚠️ `GET /api/v1/media/{name}` **实现了**(语音播放那一半,见 8.1),但**不校验 Bearer**
—— 真后端那条是 `owner|admin` 端点(契约 U19)。
- **主动消息只能靠 `proactive_message` 帧造**:本工具不模拟 `backthink`(真后端也没有 HTTP 入口),
所以「她自己决定什么时候开口」这件**服务端**行为在这里验不到 —— 能验的是「帧到了客户端之后」那一段
(见第 7 节场景五)。同理,本工具**不会**自动补发 `message_persisted`:真后端走 IM 桥的主动消息会额外
+124 -1
View File
@@ -17,7 +17,9 @@
import argparse
import base64
import hashlib
import io
import json
import math
import os
import re
import struct
@@ -25,6 +27,7 @@ import sys
import threading
import time
import uuid
import wave
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, unquote, urlsplit
@@ -114,6 +117,76 @@ def now_ms() -> int:
return int(time.time() * 1000)
# —— 语音夹具(接收侧那条路:`voice` DTO 键 + `GET /api/v1/media/{name}`)———————
#
# 为什么要造假音频:**本端没有别的造法**。生产库里一条带 `voice` 的真消息都没有(拉 200 条 0 命中),
# 而语音的 `voice` 键**只在四个 REST 读取面**填充、实时帧与握手回放都没有(契约 v0.79 §4.4 覆盖率)
# ⇒ 想验「语音气泡 + 播放」,必须有一条**带 voice 键的历史消息**,而它只能由服务端造。
#
# 这份夹具是**按客户端解出来的形状**写的(`VoiceDto.kt` / `HistoryMessageDto.voice`),
# 不是真后端的权威形状;它只负责让那条路在设备上跑起来。
VOICE_FIXTURE_NAME = "fakevoice0000000000000000000000.wav"
VOICE_FIXTURE_MIME = "audio/wav"
VOICE_FIXTURE_DURATION_MS = 2000
def make_voice_wav(duration_ms: int = VOICE_FIXTURE_DURATION_MS, freq: float = 440.0, rate: int = 8000) -> bytes:
"""生成一段**真的能播**的 WAV(16-bit PCM 单声道,首尾各 20ms 淡入淡出)。
为什么是真音频而不是几个字节:这条路验的就是「MediaPlayer 放得出来」。给一串随便的字节,
客户端只会走到「这段语音本机播不了」那一档——那验的是失败分支,不是主路径。
纯标准库(`wave` + `math`),零依赖;每次启动现生成,不落盘(本工具不留持久数据)。
"""
frames = max(1, int(rate * duration_ms / 1000))
fade = max(1, int(rate * 0.02))
buffer = io.BytesIO()
with wave.open(buffer, "wb") as handle:
handle.setnchannels(1)
handle.setsampwidth(2)
handle.setframerate(rate)
samples = bytearray()
for index in range(frames):
gain = min(1.0, index / fade, (frames - index) / fade)
value = int(12000 * gain * math.sin(2 * math.pi * freq * index / rate))
samples += struct.pack("<h", value)
handle.writeframes(bytes(samples))
return buffer.getvalue()
def voice_fixture(kind: str, duration_ms: int = VOICE_FIXTURE_DURATION_MS):
"""按控制端点给的种类造一个 `voice` 键的值;`none` ⇒ 不打这个键(返回 None)。
`kind` 的取值(都对着契约 §4.4 那几档,供设备上一档一档看):
| kind | `source` / `status` | 音频副本 | 设备上该看到什么 |
|---|---|---|---|
| `transcribed` | `asr` / `transcribed` | 有 | 语音气泡 + 播放键 + 时长;正文即转写文本 |
| `empty` | `asr` / `empty` | 有 | 同上,但正文为空 ⇒ 多一句「没有识别出文字」 |
| `failed` | `asr` / `failed` | 有 | 播放键照在,正文是服务端那行提示(假后端不会替你编,用 `text=` 给) |
| `nocopy` | `platform` / `transcribed` | **无** | 只有「语音 · 时长」,**没有播放控件**(微信那条路) |
"""
if kind in (None, "", "none"):
return None
name = VOICE_FIXTURE_NAME if kind != "nocopy" else None
duration = duration_ms if duration_ms > 0 else None
copy_keys = (
{"name": name, "mime": VOICE_FIXTURE_MIME, "size": len(VOICE_WAV), "url": "/api/v1/media/{}".format(name)}
if name
else {}
)
source = "platform" if kind == "nocopy" else "asr"
status = {"transcribed": "transcribed", "empty": "empty", "failed": "failed", "nocopy": "transcribed"}[kind]
payload = {"source": source, "status": status}
payload.update(copy_keys)
if duration is not None:
payload["durationMs"] = duration
return payload
# 夹具音频只生成一次(启动时),`size` 与真正发出去的字节因此是同一份。
VOICE_WAV = make_voice_wav()
def iso_now() -> str:
return datetime.now().astimezone().isoformat(timespec="milliseconds")
@@ -276,10 +349,13 @@ class Store:
documents=None,
created_at=None,
message_id=None,
voice=None,
) -> dict:
"""HistoryMessageDto(MessageDto.kt:13)。可选键为空时**整键省略**,与后端 omitempty 一致。
[message_id] 非空 = 用挂起时刻先占好的号(见 [hold_message]),不再动 `message_seq`。
[voice] 非空 = 这条是**语音消息**(契约 v0.79 §4.4:🎤 判据 = `voice` 键存在)——
只有控制端点能造出来,真后端的入站语音副本链本工具不模拟。
"""
with self.lock:
session = self.sessions.get(session_id)
@@ -307,6 +383,8 @@ class Store:
message["mood"] = mood
if documents:
message["documents"] = documents
if voice:
message["voice"] = voice
session.messages.append(message)
return message
@@ -861,6 +939,7 @@ MESSAGES_RE = re.compile(r"^/api/v1/sessions/([^/]+)/messages$")
SESSION_RE = re.compile(r"^/api/v1/sessions/([^/]+)$")
SESSION_READ_RE = re.compile(r"^/api/v1/sessions/([^/]+)/read$")
MESSAGE_RE = re.compile(r"^/api/v1/messages/([^/]+)$")
MEDIA_RE = re.compile(r"^/api/v1/media/([^/]+)$")
class Handler(BaseHTTPRequestHandler):
@@ -1096,8 +1175,40 @@ class Handler(BaseHTTPRequestHandler):
self._delete_message(message_id)
return
match = MEDIA_RE.match(path)
if match and method == "GET":
self._serve_media(unquote(match.group(1)))
return
self._fail(404, "not_found", "no such endpoint: {} {}".format(method, path))
def _serve_media(self, name: str) -> None:
"""`GET /api/v1/media/{name}`(契约 v0.79 §1.2 **U19**,语音副本播放端点)。
⚠️ **真后端要 Bearer `owner|admin`**(语音是私密内容);本工具**不校验任何鉴权**(README 首条),
所以这条端点在设备上验的是「客户端有没有把请求发对地方、字节能不能播」,**不**能验鉴权。
只认夹具那一个名字:其余(含穿越形态)一律 `404 media_not_found`——与真后端
「名字非法 / 穿越 / 不存在统一同码」的取向一致,也方便顺手验一次失败分支。
"""
if name != VOICE_FIXTURE_NAME:
self._fail(404, "media_not_found", "media not found")
return
data = VOICE_WAV
try:
self.send_response(200)
self.send_header("Content-Type", VOICE_FIXTURE_MIME)
self.send_header("Content-Length", str(len(data)))
self.send_header("Content-Disposition", 'inline; filename="{}"'.format(name))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "private, max-age=86400, immutable")
self.end_headers()
self.wfile.write(data)
log("http", "media {} bytes (voice fixture)".format(len(data)))
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
self.close_connection = True
def _create_session(self) -> None:
body, raw = self._parse_json_body()
if raw is not None and body is None:
@@ -1399,13 +1510,25 @@ class Handler(BaseHTTPRequestHandler):
prefix = self._param(query, "prefix", "seed")
template = self._param(query, "text", None)
platform = self._param(query, "platform", None)
voice_kind = self._param(query, "voice", None)
try:
voice = voice_fixture(voice_kind, self._int_param(query, "duration", VOICE_FIXTURE_DURATION_MS))
except KeyError:
self._fail(400, "bad_request", "voice must be one of transcribed/empty/failed/nocopy/none")
return
if session_id not in self.store.sessions:
self._fail(404, "session_not_found", "unknown session {}".format(session_id))
return
first_id = last_id = None
for index in range(1, count + 1):
content = template.format(n=index) if template else "{} #{}".format(prefix, index)
message = self.store.add_message(session_id, role, content, platform=platform)
message = self.store.add_message(
session_id,
role,
content,
platform=platform,
voice=voice,
)
first_id = first_id or message["id"]
last_id = message["id"]
with self.store.lock: