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
@@ -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