- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
175 lines
8.4 KiB
Go
175 lines
8.4 KiB
Go
// 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
|
||
}
|