Files
Cyrene_v2/internal/runtime/voice/voice.go
T
AskaEth ede00d9928 feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

175 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package voice 承载**语音副本**(回放侧)的领域事实与可播归一化(design/24 §10、
// design/25 §7.1)。它回答三个问题:
//
// - 这条消息**来自一条语音吗**、它的正文是哪来的(`Ref.Source`);
// - 那次转写**成功了没有**(`Ref.Status`,三态闭集);
// - 盘上**有没有一份能播的副本**(`Ref.Name`;没有就整键缺席,绝不给假 url)。
//
// ⚠️ 与 `internal/runtime/multimodal/voice` 的分工(**别混**):那个包是**转写/合成**
// 的提供方链(STT/TTS,design/06);本包是「消息里那条语音的**来源事实 + 可播副本**」
// (design/24 §10)。两者同名不同事,进口时务必看清路径。
//
// 依赖刻意极轻(stdlib + `internal/capability/transcode`):`history` 与
// `channelbridge` 都要引用它,而 `documents` 已经 import `history` —— 本包一旦 import
// `documents` 就会让「history → voice」成环。因此这里**不引用** `documents.Ref`,
// 副本事实用本包自有的 `Copy` 承载,由调用方负责映射(channelbridge 一处)。
package voice
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
// Source 是转写文本的**来源**(design/24 §10.4 的闭集)。它只描述「这段文字是怎么来的」,
// **绝不**描述说话人是谁(身份仍由 channel 层 actor 元数据 + 白名单决定)。
type Source string
const (
// SourceASR 表示服务端语音识别所得(含云端 DashScope 那条链)。
SourceASR Source = "asr"
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
SourcePlatform Source = "platform"
)
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
// 文案分支**:正文可能是转写文本,也可能是一行中文诚实提示,两者只能靠 status 区分。
type Status string
const (
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。
StatusTranscribed Status = "transcribed"
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
// 的空结果,**不是**故障。
StatusEmpty Status = "empty"
// StatusFailed 表示没拿到转写文本(正文是那行中文诚实提示)。
StatusFailed Status = "failed"
)
// 本包自有的哨兵(转码失败的分类一律复用 `transcode` 的哨兵,见 prepare.go)。
var (
// ErrBadRef 报告 voice 列 / DTO 的内容不是合法的语音事实(乱码、非法 JSON、
// 来源或状态不在闭集内、副本字段「同生共死」被破坏)。
ErrBadRef = errors.New("voice: malformed voice ref")
// ErrNoBytes 报告要归一化的那条语音**没有字节**(渠道只给了平台内部引用,或
// 代取失败)——没有字节就没有可落盘的副本,调用方据此走诚实路径。
ErrNoBytes = errors.New("voice: no audio bytes")
// ErrNotPlayable 报告源格式**不可播**且**没有已知的转码目标**(或格式不认识):
// 这条路我们做不了,调用方据此不落副本、不打假 url。
ErrNotPlayable = errors.New("voice: source format is not playable")
// ErrNoTranscoder 报告转码能力**未接线**(组合根没有把 transcode 交给本模块)。
// 它与「格式不支持」刻意分开:一个是运维缺口,一个是能力边界,日志与排查路径不同。
ErrNoTranscoder = errors.New("voice: transcode capability not wired")
)
// Ref 是一条语音消息的**落库与 DTO 事实**(design/24 §10.4):它就是
// `chat_messages.voice` 列里的 JSON **单对象**,同时是历史 DTO 里 `voice` 对象
// **减去 `url`** 的那份数据 —— `url` 是唯一的派生字段,由服务端按 `Name` 一处拼出
// (路径是服务端契约,绝不由客户端拼)。
//
// 字段顺序刻意与 design/24 §10.4 的 JSON 示例一致(编码产物逐字可比对)。
type Ref struct {
// Source 是转写文本从哪来(闭集,见 Source 常量)。
Source Source `json:"source"`
// Status 是转写结果三态(闭集,见 Status 常量)。
Status Status `json:"status"`
// Name 是音频副本的服务端存储名(内容寻址:32 位小写十六进制 + 音频白名单扩展名)。
// 空串 = **没有副本**(例:微信按既有裁决不取字节)。它与 MIME/Size 三者同生共死。
Name string `json:"name,omitempty"`
// MIME 是**保存时成对裁决所得**的下发 MIME(`audio/ogg` 等),不是客户端声明。
MIME string `json:"mime,omitempty"`
// Size 是副本的字节数。
Size int64 `json:"size,omitempty"`
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
DurationMs int64 `json:"durationMs,omitempty"`
}
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
// 它是「非语音消息」与「语音消息」的分界:false ⇒ `voice` 键缺席,客户端不显示 🎤。
func (r Ref) IsVoice() bool {
return validSource(r.Source) && validStatus(r.Status)
}
// HasCopy 报告盘上是否有一份可回放的副本(= Name 非空)。name/mime/size 三者同生共死,
// 因此 Name 是这三者的代表。
func (r Ref) HasCopy() bool { return r.Name != "" }
// Validate 检查这条事实是否自洽:两个闭集成员合法、副本三个字段**同生共死**、时长非负。
// 破坏规则的写**报错**而不是就地裁剪 —— 悄悄补一个空的 mime 或砍掉 size,会让库里
// 记着的东西与客户端读到的东西对不上(正是本列要消灭的静默)。
func (r Ref) Validate() error {
if !validSource(r.Source) {
return fmt.Errorf("%w: source %q", ErrBadRef, r.Source)
}
if !validStatus(r.Status) {
return fmt.Errorf("%w: status %q", ErrBadRef, r.Status)
}
if r.DurationMs < 0 {
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
}
switch {
case r.Name == "" && (r.MIME != "" || r.Size != 0):
return fmt.Errorf("%w: mime/size without name", ErrBadRef)
case r.Name != "" && r.MIME == "":
return fmt.Errorf("%w: name without mime", ErrBadRef)
case r.Name != "" && r.Size <= 0:
return fmt.Errorf("%w: name without size", ErrBadRef)
}
return nil
}
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
func (r Ref) IsZero() bool {
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
r.Size == 0 && r.DurationMs == 0
}
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
// `voice` 键缺席),与 document 列对「没有附件」的口径一致。
//
// 非零但自洽性被破坏时报错:写进一列**读不回来**的 JSON,等于让客户端永远看不到这条
// 语音(静默坏掉),所以这里 fail loud。
func Encode(r Ref) (string, error) {
if r.IsZero() {
return "", nil
}
if err := r.Validate(); err != nil {
return "", fmt.Errorf("voice: encode ref: %w", err)
}
b, err := json.Marshal(r)
if err != nil {
return "", fmt.Errorf("voice: encode ref: %w", err)
}
return string(b), nil
}
// Decode 解码落库的语音事实。空串(这条消息不是语音)返回零值;坏内容返回包装了
// ErrBadRef 的错误,由调用方**可见地**报告 —— 解析失败绝不静默当成「不是语音」。
//
// 它只接受**单对象**形状:本列自 0020 起从诞生就是单对象(design/24 §10.4),没有
// 需要兼容的旧形状(与 document 列的「早期单对象 → 数组」演进史不同)。
func Decode(raw string) (Ref, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return Ref{}, nil
}
var r Ref
if err := json.Unmarshal([]byte(trimmed), &r); err != nil {
return Ref{}, fmt.Errorf("voice: decode ref: %w", ErrBadRef)
}
if err := r.Validate(); err != nil {
return Ref{}, fmt.Errorf("voice: decode ref: %w", err)
}
return r, nil
}
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform }
func validStatus(s Status) bool {
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
}