// 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" // SourceTTS 表示正文是我们自己合成的语音(出站方向,design/25 §7.1):成功时 // Name 指向那份可播副本,降级时 Status=failed 且正文照发文本、只带降级原因。 SourceTTS Source = "tts" ) // Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文 // 文案分支**:正文可能是转写文本,也可能是一行中文诚实提示,两者只能靠 status 区分。 type Status string const ( // StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写);出站方向表示 // **合成成功**(正文即这段语音的文字内容,Name 指向可播副本)。 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"` // FallbackReason 只在**出站合成降级**时非空(Source=tts、Status=failed):说明为什么 // 这条消息没能变成语音(能力未接线 / 预算超时 / 合成为空)。入站转写不使用它 —— // 入站的失败原因由正文那行诚实提示承载。 FallbackReason string `json:"fallbackReason,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) } // 降级原因只属于失败态:成功还带原因会让人以为「其实降级了」,客户端也就无从按 // status 单点分支(design/24 §10.4 的「客户端绝不按文案分支」同一条纪律)。 if r.FallbackReason != "" && r.Status != StatusFailed { return fmt.Errorf("%w: fallbackReason with status %q", ErrBadRef, r.Status) } 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 && r.FallbackReason == "" } // 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 || s == SourceTTS } func validStatus(s Status) bool { return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed }