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:
1 parent
55612a3922
commit
2fbf24a675
20 files changed
+2330
-8
No files matched your search
@@ -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 桥的主动消息会额外
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user