feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
This commit is contained in:
1 parent
120d926cf8
commit
ede00d9928
17 files changed
+2125
-31
No files matched your search
@@ -0,0 +1,96 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// voiceMigrationID 是「语音事实」列的迁移 id(design/24 §10.4;切片 CODE-VOICE)。
|
||||
const voiceMigrationID = "0020_chat_message_voice"
|
||||
|
||||
// TestMigrateVoiceColumnReversible 验证 0020(chat_messages.voice,语音来源事实 + 音频
|
||||
// 副本引用)可上下翻滚:up 建列、重复 up 是空操作、down 删列、再 up 恢复;并钉住列口径
|
||||
// —— 默认空串(**不是 NULL**)与「老行 = 不是语音消息」逐字等价。它用自己的内存库,
|
||||
// 绝不碰 data/cyrene.db。
|
||||
//
|
||||
// 0020 的编号**小于**既有的 0021/0022(那两条先落地),因此本测试刻意按
|
||||
// `upMigrationIDs` 的下标算回滚步数(照 0018 的写法),不假设「最后一条就是自己」。
|
||||
func TestMigrateVoiceColumnReversible(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := openMemory(t)
|
||||
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should exist after MigrateUp")
|
||||
}
|
||||
|
||||
// 重复 up:已记录的迁移被跳过,列仍在且不报错(幂等)。
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp second call: %v", err)
|
||||
}
|
||||
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should still exist after idempotent re-run")
|
||||
}
|
||||
|
||||
// 恰好回滚到 0020(含它之后新加的迁移),因此日后再加迁移也不会让本测试失效。
|
||||
ids := upMigrationIDs(t)
|
||||
idx := -1
|
||||
for i, id := range ids {
|
||||
if id == voiceMigrationID {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
t.Fatalf("%s not found in %v", voiceMigrationID, ids)
|
||||
}
|
||||
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
|
||||
}
|
||||
if columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should be dropped by the 0020 down")
|
||||
}
|
||||
|
||||
// 重新 up 恢复端到端可用。
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("re-MigrateUp: %v", err)
|
||||
}
|
||||
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
|
||||
t.Fatal("chat_messages.voice should exist again after re-MigrateUp")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateVoiceColumnDefaultIsEmptyString 钉住列口径:**用空串不用 NULL**(与
|
||||
// document / turn_id / client_msg_id 同族)。插入一条不带 voice 的消息(显式不写该列)
|
||||
// 必须读回空串 —— 「老行」与「这条消息不是语音」因此是同一个值,读侧只有一种空情形。
|
||||
//
|
||||
// 若有人把默认值改成 NULL,SQL 层 Scan 会失败或读出 NULL 语义,客户端看到的 `voice`
|
||||
// 键行为随之漂移(design/24 §10.4:非语音消息**键缺席**,不是 `{}` 也不是 `null`)。
|
||||
func TestMigrateVoiceColumnDefaultIsEmptyString(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := openMemory(t)
|
||||
|
||||
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
"m-voice-default", "web", "user", "hi", 1000); err != nil {
|
||||
t.Fatalf("insert without voice: %v", err)
|
||||
}
|
||||
// 用 *string 读回:默认值若是 NULL,Scan 会得到 nil(这里就会红)。
|
||||
var voice *string
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT voice FROM chat_messages WHERE id = ?`, "m-voice-default").Scan(&voice); err != nil {
|
||||
t.Fatalf("select voice: %v", err)
|
||||
}
|
||||
if voice == nil {
|
||||
t.Fatal("voice is NULL, want the empty-string default(绝不使用 NULL)")
|
||||
}
|
||||
if *voice != "" {
|
||||
t.Fatalf("voice = %q, want \"\"", *voice)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 0020_chat_message_voice down: 回滚「语音事实」列。
|
||||
--
|
||||
-- voice 只被 chat_messages 自己引用(无索引、无视图、无约束牵涉),因此直接删列即可。
|
||||
-- 回滚后的状态是「服务端不再记得这条消息来自一条语音」:列里的来源事实与音频副本引用
|
||||
-- 随列一起丢弃。
|
||||
--
|
||||
-- ⚠️ 音频副本本体(<DataDir>/uploads/documents 里的文件与 `documents` 元数据行)**不在
|
||||
-- 本迁移的范围内**:删掉这一列等于一次性放掉这些副本的最后一份引用,它们的 ref_count
|
||||
-- 不会再被消息写路径增减,此后只能由既有 GC(保留期 + ref_count = 0)收走。这是**有意**
|
||||
-- 的取舍:迁移绝不代替 GC 去删用户数据(同 0015 的 down 口径)。
|
||||
|
||||
ALTER TABLE chat_messages DROP COLUMN voice;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 0020_chat_message_voice: 给聊天消息挂上「语音事实」(design/24 §10.4;切片 CODE-VOICE)。
|
||||
--
|
||||
-- 一条语音消息在历史里要能被辨认成语音(客户端据此显示 🎤 与播放控件),并且要能
|
||||
-- 说清它的**正文从哪来**。这两件事都是「整条消息」的属性,不是某个内容片段的属性,
|
||||
-- 因此落在消息行的一列上(与 0015 的 document 同族):
|
||||
--
|
||||
-- {"source":"asr","status":"transcribed","name":"a1b2…ogg","mime":"audio/ogg",
|
||||
-- "size":12345,"durationMs":3200}
|
||||
--
|
||||
-- JSON **单对象**(不是数组:一条消息只有一条被归一/落副本的语音,design/24 §10.1
|
||||
-- 的 N7 明确「第 2..N 条语音既不转写也不落盘」),形状即 internal/runtime/voice 的
|
||||
-- Ref —— 它就是历史 DTO 里 `voice` 对象**减去 url** 的那份数据(url 由服务端按 name
|
||||
-- 拼出,绝不落库)。
|
||||
--
|
||||
-- 字段语义(逐条见 design/24 §10.4):
|
||||
-- source 转写文本从哪来:asr(服务端语音识别)/ platform(渠道平台自带转写)。
|
||||
-- status 转写结果三态闭集:transcribed / empty / failed。客户端按它分支,
|
||||
-- **绝不按正文文案分支**。
|
||||
-- name 音频副本的服务端存储名(内容寻址、32 位小写十六进制 + 音频白名单扩展名)。
|
||||
-- mime 保存时成对裁决所得的下发 MIME(audio/*)。
|
||||
-- size 副本字节数。name/mime/size 三者**同生共死**:有副本时全在,无副本时全空
|
||||
-- (绝不出现「有 url 指着一个不存在的 name」这种形状)。
|
||||
-- durationMs 只有渠道**如实给出**或转码产物**可确证**时才 >0,绝不估算。
|
||||
--
|
||||
-- 列口径与同族可选列(document / turn_id / client_msg_id)逐字一致:
|
||||
-- `NOT NULL DEFAULT ''`,**绝不用 NULL** —— 于是「老行」与「这条消息不是语音」是同一个
|
||||
-- 值,读侧只有一种空情形要处理(空串 ⇒ DTO 里 `voice` 键**缺席**)。
|
||||
--
|
||||
-- 这一列**不需要索引**:它只被随行读出,没有任何按它过滤/排序/聚合的查询(与 0018 的
|
||||
-- turn_id 同一口径)。引用计数的增减发生在消息写路径上按**新旧列内容**逐条解码比较
|
||||
-- (design/24 §10.5 的加粗警告),不走 SQL 过滤;因此建索引只会给每次写加一笔无收益的
|
||||
-- 维护开销。
|
||||
--
|
||||
-- ⚠️ 引用计数(design/24 §10.5,本列落地时**必须同时做**,否则音频副本会静默坏掉):
|
||||
-- `history.refs` 的 retain/release/sync 今天只认 `document` 列;voice 列里的 name 必须
|
||||
-- 并入**同一次**增减(与消息行同一事务、按新旧名字集合的差增减、保留重复),否则
|
||||
-- 音频副本的 ref_count 恒为 0 ⇒ GC 在保留期后删掉仍被消息引用的音频 ⇒ 历史里的语音
|
||||
-- 变成永远播不出来的死链,而且**没有任何错误**。
|
||||
--
|
||||
-- 回填(本迁移不做,也不该做):老行一律空串 = 「不是语音消息」,行为与加入本列之前
|
||||
-- 逐字相同。存量语音消息的正文是转写文本,但没有来源事实可考 —— 凭空补一个
|
||||
-- `{"source":"asr",...}` 就是编造(与「绝不假装」的口径冲突)。
|
||||
--
|
||||
-- 0003 的 chat_messages 表在此已存在,故 ALTER ADD COLUMN 是非破坏性的正确一步;
|
||||
-- 全新库由 internal/runtime/history.NewStore 幂等地前滚(含该列)。表名沿用复数口径
|
||||
-- (援引 AGENTS §10 已登记的例外,与既有迁移一致)。
|
||||
|
||||
ALTER TABLE chat_messages ADD COLUMN voice TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,98 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
)
|
||||
|
||||
// 本文件承载「**能不能播**」与「该转成什么」这两条**格式级**判据(design/24 §10.6、
|
||||
// design/25 §3.1/§3.2)。
|
||||
//
|
||||
// 🔴 判据纪律:可播与否必须是**格式级结论**(格式名 → 已知支持表),**绝不**靠「扩展名
|
||||
// 看起来像音频」,也绝不靠「试着播一下」。设计里的同一条话(design/24 §10.14-3)说的是
|
||||
// 同一件事:宣称能播就要有依据,播不了就照实提示。
|
||||
|
||||
// playableFormats 是「浏览器与常见原生播放器**能直接播**」的格式集合,取值口径与
|
||||
// mediafetch.AudioFormatOf 归一出的格式名同一份(小写、无点)。
|
||||
//
|
||||
// 依据 = design/25 §3.2 的 MDN《Audio codecs》支持表(规范/文档级结论):
|
||||
// wav(PCM 基线)、mp3、ogg/oga/opus、m4a/aac(MP4 家族)、flac、webm 均可播。
|
||||
//
|
||||
// `amr` / `amr-wb` **刻意不在**集合内:Chrome 的 AMR 支持为 **No**(只有 ChromeOS 支持
|
||||
// AMR-NB/WB)⇒ QQ OneBot v11 的语音在 Web 上点不开,必须转码(design/24 §10.14-2 的
|
||||
// 核实结论,唯一必需的那条路 = `amr → wav`)。
|
||||
//
|
||||
// ⚠️ **诚实边界(别当已实测)**:上表是规范/文档级结论,**不是**本项目在目标客户端上
|
||||
// 的逐浏览器实测(design/25 §3.2 已登记同一条「未确证」)。实测不符则以实测为准,
|
||||
// 照实提示、不假装。对本函数而言,判错的后果是**有界的**:判「可播」⇒ 原样落副本,
|
||||
// 客户端播不了会照实提示;判「不可播」⇒ 走转码,没有转码路就直接不落副本。
|
||||
var playableFormats = map[string]bool{
|
||||
"wav": true,
|
||||
"mp3": true,
|
||||
"ogg": true,
|
||||
"oga": true,
|
||||
"opus": true,
|
||||
"m4a": true,
|
||||
"aac": true,
|
||||
"flac": true,
|
||||
"webm": true,
|
||||
}
|
||||
|
||||
// transcodeTargets 是「不可播格式 → 该转成的目标格式」的唯一登记点(design/25 §3.1:
|
||||
// 本期只发布 `amr → wav` 一条路;新增一条路 = 这里加一行 + transcode 包加一个注册项)。
|
||||
//
|
||||
// amr-wb 也登记目标(同族、目标同样是 wav),但**本期没有实现**:让 `transcode` 层如实
|
||||
// 回答「注册表里没有这条路」(design/25 §3.3 把它后置),比在这里假称「我们不会做」
|
||||
// 更能说清问题。silk 刻意**不登记**:design/24 §10.14-1 的既有裁决是微信语音不取字节,
|
||||
// 且纯 Go SILK 解码器生态不成熟(design/25 §3.3)。
|
||||
var transcodeTargets = map[string]transcode.Format{
|
||||
"amr": transcode.FormatWAV,
|
||||
"amr-wb": transcode.FormatWAV,
|
||||
}
|
||||
|
||||
// Playable 报告某个格式名(小写、无点)的字节能否**直接被播放器消费**。格式不认识
|
||||
// ⇒ false(deny-by-default:不认识的类型绝不当成可播)。
|
||||
func Playable(format string) bool {
|
||||
return playableFormats[normalize(format)]
|
||||
}
|
||||
|
||||
// TranscodeTarget 给出「该格式要落可播副本时应当转成什么」。ok=false 表示**没有登记的
|
||||
// 目标**(格式可播、或这条路我们不做)——调用方据此如实提示,绝不自己发明一条路。
|
||||
func TranscodeTarget(format string) (transcode.Format, bool) {
|
||||
target, ok := transcodeTargets[normalize(format)]
|
||||
return target, ok
|
||||
}
|
||||
|
||||
// NeedsTranscode 报告这个格式要落副本是否**必须先转码**(= 不可播且登记了目标)。
|
||||
// 它刻意不等价于 `!Playable`:不可播但没登记目标的格式(如 silk)属于「做不了」,
|
||||
// 走 ErrNotPlayable,而不是走一次注定失败的转码。
|
||||
func NeedsTranscode(format string) bool {
|
||||
if Playable(format) {
|
||||
return false
|
||||
}
|
||||
_, ok := TranscodeTarget(format)
|
||||
return ok
|
||||
}
|
||||
|
||||
// PlayableFormats 返回当前认定为可播的格式名(**发现**用;顺序无保证,调用方不得依赖
|
||||
// 顺序)。它存在的理由是让「这份表是活的」有据可查(面板/日志能列出判据面)。
|
||||
func PlayableFormats() []string {
|
||||
out := make([]string, 0, len(playableFormats))
|
||||
for f := range playableFormats {
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalize 把格式名归一成小写、去空白、去开头的点(`.amr` → `amr`)。归一只有这一处,
|
||||
// 与 `transcode.FormatOf` 同口径(design/25 §6.2:三处格式名必须同源)。
|
||||
func normalize(format string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), "."))
|
||||
}
|
||||
|
||||
// errNotPlayable 给 ErrNotPlayable 附上格式,便于日志与测试按 errors.Is + 文案双判。
|
||||
func errNotPlayable(format string) error {
|
||||
return fmt.Errorf("voice: prepare: format %q: %w", format, ErrNotPlayable)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
)
|
||||
|
||||
// TestPlayableIsFormatLevelVerdict 把「能不能播」的判据钉成**格式级**结论表(design/25
|
||||
// §3.2 的 MDN 支持表)。AMR 是唯一必须转码的常见入站格式(Chrome/Firefox 的 AMR 支持为
|
||||
// No,只有 ChromeOS 支持)——若有人把 amr 也标成可播,「能点开听」这条验收会静默失效
|
||||
// (Web 上点开播不了,而服务端以为自己给了一份能播的副本)。
|
||||
func TestPlayableIsFormatLevelVerdict(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{"wav", true, "<audio> 的基线格式"},
|
||||
{"mp3", true, "KOOK 语音的实际格式"},
|
||||
{"ogg", true, "Telegram 语音(Ogg/Opus)"},
|
||||
{"oga", true, "Ogg 的同义扩展名"},
|
||||
{"opus", true, "裸 Opus(按 Ogg 家族放行)"},
|
||||
{"m4a", true, "MP4/AAC 家族"},
|
||||
{"aac", true, "AAC 家族"},
|
||||
{"flac", true, "无损容器"},
|
||||
{"webm", true, "WebM/Opus"},
|
||||
{"amr", false, "Chrome 的 AMR 支持为 No(唯一必需的转码路 amr→wav 的来源)"},
|
||||
{"amr-wb", false, "同族不可播,且本期没有转码实现(后置 P2)"},
|
||||
{"silk", false, "不在音频白名单内(微信按既有裁决不取字节)"},
|
||||
{"", false, "空格式:deny-by-default"},
|
||||
{"xyz", false, "不认识的格式:deny-by-default"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.format+"/"+tc.why, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := Playable(tc.format); got != tc.want {
|
||||
t.Fatalf("Playable(%q) = %v, want %v(%s)", tc.format, got, tc.want, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayableNormalizesFormatName 钉住归一:大小写、首尾空白、开头的点都要吃得下
|
||||
// (渠道给的扩展名/MIME 归一后可能带点或大写),否则「可播」会在同一格式上给出两个答案。
|
||||
func TestPlayableNormalizesFormatName(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, in := range []string{"WAV", " wav ", ".wav", ".WAV"} {
|
||||
if !Playable(in) {
|
||||
t.Fatalf("Playable(%q) = false, want true(归一后是 wav)", in)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"AMR", " amr ", ".AMR"} {
|
||||
if Playable(in) {
|
||||
t.Fatalf("Playable(%q) = true, want false(归一后是 amr,不可播)", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscodeTargetIsRegisteredOnlyForAMR 钉住「哪些不可播格式有登记的转码目标」:
|
||||
// 本期只发布 `amr → wav`(design/25 §3.1)。可播格式**不是**转码目标(白转一圈没收益),
|
||||
// silk 也**没有**目标(design/25 §3.3:不引入纯 Go SILK 解码器)。
|
||||
func TestTranscodeTargetIsRegisteredOnlyForAMR(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want transcode.Format
|
||||
ok bool
|
||||
}{
|
||||
{"amr", transcode.FormatWAV, true},
|
||||
{"amr-wb", transcode.FormatWAV, true},
|
||||
{"wav", "", false},
|
||||
{"mp3", "", false},
|
||||
{"silk", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := TranscodeTarget(tc.format)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("TranscodeTarget(%q) = (%q, %v), want (%q, %v)",
|
||||
tc.format, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNeedsTranscodeDistinguishesCannotDoFromMustTranscode 钉住一条容易混的边界:
|
||||
// NeedsTranscode 不是 `!Playable`。silk 不可播但**没登记目标** ⇒ false(做不了,
|
||||
// 走 ErrNotPlayable),而不是「要转码」(那会去问一条注定不存在的路)。
|
||||
func TestNeedsTranscodeDistinguishesCannotDoFromMustTranscode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want bool
|
||||
}{
|
||||
{"amr", true},
|
||||
{"amr-wb", true},
|
||||
{"wav", false},
|
||||
{"mp3", false},
|
||||
{"silk", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := NeedsTranscode(tc.format); got != tc.want {
|
||||
t.Fatalf("NeedsTranscode(%q) = %v, want %v", tc.format, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayableFormatsMatchesDesignTable 把「可播集合」的**字面成员**钉死(discovery 面
|
||||
// 的作者容易顺手加一项而没有依据)。design/25 §3.2 的支持表 + design/24 §10.6 的音频
|
||||
// 白名单(10 项扩展名)⇒ 可播名集合恰好是这 9 个(ogg/oga/opus 三名同容器)。
|
||||
func TestPlayableFormatsMatchesDesignTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := []string{"aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "webm"}
|
||||
got := PlayableFormats()
|
||||
sort.Strings(got)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("PlayableFormats() = %v(%d 项), want %v(%d 项)", got, len(got), want, len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("PlayableFormats() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
)
|
||||
|
||||
// 本文件是「**可播归一化**」的实现(design/25 §7.1 第 ④ 步):把一条入站语音的原始
|
||||
// 字节变成一份**可以落盘、可以播放**的副本。
|
||||
//
|
||||
// 位置与时机(design/24 §10.2 第 4 步 + design/25 §7.1):
|
||||
// - **闸门之后**:被黑白名单 / 最小打扰拦下的语音不产生任何字节与 CPU 开销;
|
||||
// - **转写之后**:ASR 用的是**原始字节**(转码产物对它无用且有害:AMR→WAV 放大 10 倍);
|
||||
// - **落盘之前**:这是唯一决定「副本是什么格式」的位置,与内容寻址同一处 ⇒
|
||||
// 同字节去重 ⇒ 同一段语音只转一次;
|
||||
// - 只在**入站**发生,不在读端点(播放)发生 ⇒ 没有「读端点被轮询打爆」这个面。
|
||||
|
||||
// Prep 是一次可播归一化的输入。
|
||||
type Prep struct {
|
||||
// Data 是入站拿到的原始音频字节(mediafetch 内联所得)。
|
||||
Data []byte
|
||||
// Format 是源格式名(小写无点;与 mediafetch.AudioFormatOf 同一口径)。平台给了
|
||||
// 文件名就按扩展名,只给 MIME 就按 MIME 归一 —— 「拿不到」时调用方不该走到这里。
|
||||
Format string
|
||||
// DurationMs 是渠道**如实给出**的时长;0 = 不知道(绝不估)。负数按「不知道」处理
|
||||
// (渠道给了个无意义的值,我们既不采信也不编造)。
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
// Copy 是可落盘、可播放的一份副本。
|
||||
type Copy struct {
|
||||
// Data 是要落盘的字节(可播格式)。
|
||||
Data []byte
|
||||
// Format 是落盘格式名(= 存储名的扩展名口径)。
|
||||
Format string
|
||||
// DurationMs 是时长的**最好来源**:转码产物能确证时取它,否则沿用渠道如实给出的值;
|
||||
// 两者都没有就是 0(落库/DTO 里该键缺席,绝不估算)。
|
||||
DurationMs int64
|
||||
// Transcoded 报告这一份是不是**转码产物**(false = 源格式本就可播、原样落盘)。
|
||||
// 它进日志与(将来的)DTO,使「为什么这条副本的格式和渠道给的不一样」可追溯。
|
||||
Transcoded bool
|
||||
}
|
||||
|
||||
// Prepare 把一条入站语音归一成可播副本(design/24 §10.14-3 的新口径:**不可播即转码;
|
||||
// 转码不可用才诚实提示**)。三态:
|
||||
//
|
||||
// ① 源格式可播 ⇒ 原样返回(Transcoded=false,零 CPU、零转码依赖);
|
||||
// ② 不可播且转码可用 ⇒ 返回转码产物(Transcoded=true,目标来自 TranscodeTarget);
|
||||
// ③ 不可播且转码不可用 ⇒ 返回错误(未接线 / 没这条路 / 越限 / 解码失败 / 忙 / 超时),
|
||||
// **绝不**返回一个播不了却当作可播的结果 —— 调用方据此不落副本、不打假 url。
|
||||
//
|
||||
// 错误分类一律可 `errors.Is` 判定:本包的 ErrNoBytes / ErrNotPlayable / ErrNoTranscoder,
|
||||
// 或原样包裹的 `transcode` 哨兵。文案纪律照 design/25 §2.4:上游错误原文只进日志,
|
||||
// 绝不进用户可见正文(这里只包哨兵,不回显解码器文本)。
|
||||
func Prepare(ctx context.Context, tr transcode.Transcoder, in Prep) (Copy, error) {
|
||||
if len(in.Data) == 0 {
|
||||
return Copy{}, fmt.Errorf("voice: prepare: %w", ErrNoBytes)
|
||||
}
|
||||
format := normalize(in.Format)
|
||||
duration := in.DurationMs
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
|
||||
// ① 本就可播:不碰转码(design/25 §7.1 的「源格式可播 ⇒ 不转码、直接落原件」)。
|
||||
if Playable(format) {
|
||||
return Copy{Data: in.Data, Format: format, DurationMs: duration}, nil
|
||||
}
|
||||
|
||||
// ② / ③ 不可播:先问「这条路有没有」,再决定走转码还是诚实拒绝(设计 §4.4 的能力门,
|
||||
// 与 LiteLLM 的 supports_* 同范式:绝不「试一下就假成功」)。
|
||||
target, ok := TranscodeTarget(format)
|
||||
if !ok {
|
||||
return Copy{}, errNotPlayable(format)
|
||||
}
|
||||
if tr == nil {
|
||||
return Copy{}, fmt.Errorf("voice: prepare: route %s→%s: %w",
|
||||
format, transcode.FormatName(target), ErrNoTranscoder)
|
||||
}
|
||||
source := transcode.FormatOf(format)
|
||||
if !tr.Supports(source, target) {
|
||||
return Copy{}, fmt.Errorf("voice: prepare: route %s→%s: %w",
|
||||
format, transcode.FormatName(target), transcode.ErrUnsupportedFormat)
|
||||
}
|
||||
res, err := tr.Transcode(ctx, transcode.Request{Data: in.Data, Source: source, Target: target})
|
||||
if err != nil {
|
||||
// 原样包裹:调用方按 transcode 的哨兵分类(越限 / 解码失败 / 忙 / 超时…),
|
||||
// 本函数不吞、不改写原因。
|
||||
return Copy{}, fmt.Errorf("voice: prepare: transcode %s→%s: %w",
|
||||
format, transcode.FormatName(target), err)
|
||||
}
|
||||
if len(res.Data) == 0 {
|
||||
// 转码器自称成功却没给字节:这是**目标侧构造失败**,绝不能当成一份空副本落盘
|
||||
// (空文件播放不了,而且 documents 侧会按「空文件」拒掉 —— 两头都说不清)。
|
||||
return Copy{}, fmt.Errorf("voice: prepare: transcode %s→%s produced no bytes: %w",
|
||||
format, transcode.FormatName(target), transcode.ErrEncodeFailed)
|
||||
}
|
||||
outFormat := transcode.FormatName(res.Format)
|
||||
if outFormat == "" {
|
||||
outFormat = transcode.FormatName(target)
|
||||
}
|
||||
if res.DurationMs > 0 {
|
||||
// 转码产物可从帧数**确证**时长 ⇒ 它比渠道给的更可信(渠道常常给不出)。
|
||||
duration = res.DurationMs
|
||||
}
|
||||
return Copy{Data: res.Data, Format: outFormat, DurationMs: duration, Transcoded: true}, nil
|
||||
}
|
||||
|
||||
// Category 把 Prepare 的错误归成一个**稳定的日志类别**(小写、无空格、不含任何上游
|
||||
// 错误原文),供调用方按类记 WARN、按类排查,并在将来需要时细分客户端展示。
|
||||
//
|
||||
// design/24 §10.12-9 说「三态之外的原因分类本期不下发 DTO,只进日志」——本函数就是
|
||||
// 「只进日志」的那一份分类,因此它是**机器可读**的(脚本/面板可按它聚合)。
|
||||
func Category(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, ErrNoBytes):
|
||||
return "no_bytes"
|
||||
case errors.Is(err, ErrNotPlayable):
|
||||
return "not_playable"
|
||||
case errors.Is(err, ErrNoTranscoder):
|
||||
return "transcode_not_wired"
|
||||
case errors.Is(err, transcode.ErrUnsupportedFormat):
|
||||
return "unsupported_format"
|
||||
case errors.Is(err, transcode.ErrInputTooLarge):
|
||||
return "input_too_large"
|
||||
case errors.Is(err, transcode.ErrOutputTooLarge):
|
||||
return "output_too_large"
|
||||
case errors.Is(err, transcode.ErrDecodeFailed):
|
||||
return "decode_failed"
|
||||
case errors.Is(err, transcode.ErrEncodeFailed):
|
||||
return "encode_failed"
|
||||
case errors.Is(err, transcode.ErrTimeout):
|
||||
return "timeout"
|
||||
case errors.Is(err, transcode.ErrBusy):
|
||||
return "busy"
|
||||
case errors.Is(err, transcode.ErrNoBackend):
|
||||
return "no_backend"
|
||||
default:
|
||||
return "failed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
)
|
||||
|
||||
// fakeTranscoder 是 Prepare 的能力门与转码调用的替身:它记录是否被调用、被问了哪条路,
|
||||
// 让「可播格式**绝不**碰转码」这类纪律成为可断言的事实(而不是靠读代码相信)。
|
||||
type fakeTranscoder struct {
|
||||
// routes 是「源>目标」的可用集合;nil 表示按 DefaultCodecs 的口径只认 amr>wav。
|
||||
routes map[string]bool
|
||||
result transcode.Result
|
||||
err error
|
||||
calls int
|
||||
lastReq transcode.Request
|
||||
}
|
||||
|
||||
func routeKey(src, dst transcode.Format) string {
|
||||
return transcode.FormatName(src) + ">" + transcode.FormatName(dst)
|
||||
}
|
||||
|
||||
func newFakeTranscoder() *fakeTranscoder {
|
||||
return &fakeTranscoder{routes: map[string]bool{routeKey(transcode.FormatAMR, transcode.FormatWAV): true}}
|
||||
}
|
||||
|
||||
func (f *fakeTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) {
|
||||
f.calls++
|
||||
f.lastReq = req
|
||||
if f.err != nil {
|
||||
return transcode.Result{}, f.err
|
||||
}
|
||||
res := f.result
|
||||
res.Source = req.Source
|
||||
if res.Format == "" {
|
||||
res.Format = req.Target
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (f *fakeTranscoder) Supports(src, dst transcode.Format) bool {
|
||||
return f.routes[routeKey(src, dst)]
|
||||
}
|
||||
|
||||
func (f *fakeTranscoder) Formats() []transcode.Format {
|
||||
return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV}
|
||||
}
|
||||
|
||||
// TestPrepareKeepsPlayableBytesAsIs 覆盖三态之①:源格式可播 ⇒ **原样落盘**,一个字节都
|
||||
// 不改、也不碰转码(design/25 §7.1:不可播才转码;可播却转一圈等于白烧 CPU 还把音质
|
||||
// 往下拽)。
|
||||
func TestPrepareKeepsPlayableBytesAsIs(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
in := []byte("RIFF....WAVEfmt ")
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: in, Format: "WAV", DurationMs: 1500})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got.Data, in) {
|
||||
t.Fatalf("字节被改动了:%q", got.Data)
|
||||
}
|
||||
if got.Format != "wav" || got.Transcoded {
|
||||
t.Fatalf("产物 = %+v, want 原样 wav / Transcoded=false", got)
|
||||
}
|
||||
if got.DurationMs != 1500 {
|
||||
t.Fatalf("时长 = %d, want 1500(渠道如实给出的值)", got.DurationMs)
|
||||
}
|
||||
if tr.calls != 0 {
|
||||
t.Fatalf("转码被调用 %d 次, want 0(可播格式绝不转码)", tr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareTranscodesNonPlayableFormat 覆盖三态之②:不可播且转码可用 ⇒ 落**可播副本**,
|
||||
// 并且时长优先取转码产物**可确证**的值(渠道常给不出;转码按帧数算得出来)。
|
||||
func TestPrepareTranscodesNonPlayableFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV, DurationMs: 500}
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n..."), Format: "amr"})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if string(got.Data) != "RIFF-out" || got.Format != "wav" || !got.Transcoded {
|
||||
t.Fatalf("产物 = %+v, want 转码产物 wav/Transcoded=true", got)
|
||||
}
|
||||
if got.DurationMs != 500 {
|
||||
t.Fatalf("时长 = %d, want 500(转码产物可确证)", got.DurationMs)
|
||||
}
|
||||
if tr.calls != 1 {
|
||||
t.Fatalf("转码调用次数 = %d, want 1", tr.calls)
|
||||
}
|
||||
if tr.lastReq.Source != transcode.FormatAMR || tr.lastReq.Target != transcode.FormatWAV {
|
||||
t.Fatalf("请求 = %+v, want amr→wav", tr.lastReq)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreparePrefersTranscodedDuration 覆盖时长来源的优先级:转码产物给不出时长时,
|
||||
// **沿用渠道如实给出的值**(绝不因为转码成功就把已知的时长抹成 0)。
|
||||
func TestPreparePrefersTranscodedDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV}
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr", DurationMs: 3200})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if got.DurationMs != 3200 {
|
||||
t.Fatalf("时长 = %d, want 3200(转码没给出 ⇒ 沿用渠道值)", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareNegativeDurationIsTreatedAsUnknown 钉住「渠道给了个无意义的值」的处理:
|
||||
// 负数按**不知道**处理(0),既不采信也不编造 —— 落库前 Ref.Validate 会拒绝负数。
|
||||
func TestPrepareNegativeDurationIsTreatedAsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := Prepare(context.Background(), nil, Prep{Data: []byte("RIFF"), Format: "wav", DurationMs: -5})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if got.DurationMs != 0 {
|
||||
t.Fatalf("时长 = %d, want 0(负值 = 不知道)", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// prepareFailureCases 是「不可播且转码不可用」的全部失败面(design/25 §2.4 / §5.4):
|
||||
// 每一条都要求「有错、可判类、无字节」。表放在用例之外,好让用例本体只表达纪律。
|
||||
var prepareFailureCases = []struct {
|
||||
name string
|
||||
tr transcode.Transcoder
|
||||
in Prep
|
||||
wantErr error
|
||||
category string
|
||||
}{
|
||||
{
|
||||
name: "没有字节", tr: newFakeTranscoder(),
|
||||
in: Prep{Data: nil, Format: "wav"},
|
||||
wantErr: ErrNoBytes,
|
||||
category: "no_bytes",
|
||||
},
|
||||
{
|
||||
name: "不可播且没有登记目标(silk)", tr: newFakeTranscoder(),
|
||||
in: Prep{Data: []byte("x"), Format: "silk"},
|
||||
wantErr: ErrNotPlayable,
|
||||
category: "not_playable",
|
||||
},
|
||||
{
|
||||
name: "不可播且格式不认识", tr: newFakeTranscoder(),
|
||||
in: Prep{Data: []byte("x"), Format: ""},
|
||||
wantErr: ErrNotPlayable,
|
||||
category: "not_playable",
|
||||
},
|
||||
{
|
||||
name: "转码能力未接线(amr 但组合根没注入)", tr: nil,
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: ErrNoTranscoder,
|
||||
category: "transcode_not_wired",
|
||||
},
|
||||
{
|
||||
name: "注册表里没有这条路(amr-wb 本期未实现)",
|
||||
tr: newFakeTranscoder(),
|
||||
in: Prep{Data: []byte("#!AMR-WB\n"), Format: "amr-wb"},
|
||||
wantErr: transcode.ErrUnsupportedFormat,
|
||||
category: "unsupported_format",
|
||||
},
|
||||
{
|
||||
name: "输入越限", tr: failingTranscoder(transcode.ErrInputTooLarge),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrInputTooLarge,
|
||||
category: "input_too_large",
|
||||
},
|
||||
{
|
||||
name: "产物越限", tr: failingTranscoder(transcode.ErrOutputTooLarge),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrOutputTooLarge,
|
||||
category: "output_too_large",
|
||||
},
|
||||
{
|
||||
name: "解码失败(字节损坏)", tr: failingTranscoder(transcode.ErrDecodeFailed),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrDecodeFailed,
|
||||
category: "decode_failed",
|
||||
},
|
||||
{
|
||||
name: "并发槽满(快速失败,不排队)", tr: failingTranscoder(transcode.ErrBusy),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrBusy,
|
||||
category: "busy",
|
||||
},
|
||||
{
|
||||
name: "超时", tr: failingTranscoder(transcode.ErrTimeout),
|
||||
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
|
||||
wantErr: transcode.ErrTimeout,
|
||||
category: "timeout",
|
||||
},
|
||||
}
|
||||
|
||||
// TestPrepareFailsHonestly 覆盖三态之③与全部失败分类(design/25 §2.4 / §5.4):
|
||||
// **不可播且转码不可用**时绝不返回一份播不了却当作可播的结果;错误必须可 errors.Is
|
||||
// 判定、可归类(Category 是「只进日志」的那份机器可读分类,design/24 §10.12-9)。
|
||||
func TestPrepareFailsHonestly(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range prepareFailureCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := Prepare(context.Background(), tc.tr, tc.in)
|
||||
if err == nil {
|
||||
t.Fatalf("Prepare(%+v) = %+v, want 错误(绝不返回播不了却当作可播的结果)", tc.in, got)
|
||||
}
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("错误 = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
if cat := Category(err); cat != tc.category {
|
||||
t.Fatalf("Category(%v) = %q, want %q", err, cat, tc.category)
|
||||
}
|
||||
if len(got.Data) != 0 {
|
||||
t.Fatalf("失败时仍返回了字节:%q", got.Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// failingTranscoder 返回一个自称有 amr→wav 这条路、但一转就报指定哨兵的替身。
|
||||
func failingTranscoder(err error) *fakeTranscoder {
|
||||
f := newFakeTranscoder()
|
||||
f.err = err
|
||||
return f
|
||||
}
|
||||
|
||||
// TestPrepareRejectsEmptyTranscodeOutput 覆盖「转码器自称成功却没给字节」:那是目标侧
|
||||
// 构造失败,绝不能当成一份空副本落盘(空文件播放不了,documents 侧也会按空文件拒掉)。
|
||||
func TestPrepareRejectsEmptyTranscodeOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newFakeTranscoder()
|
||||
tr.result = transcode.Result{Format: transcode.FormatWAV}
|
||||
_, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr"})
|
||||
if !errors.Is(err, transcode.ErrEncodeFailed) {
|
||||
t.Fatalf("错误 = %v, want transcode.ErrEncodeFailed", err)
|
||||
}
|
||||
if cat := Category(err); cat != "encode_failed" {
|
||||
t.Fatalf("Category = %q, want encode_failed", cat)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCategoryOfNilIsEmpty 钉住 Category(nil) 的空串:调用方按它决定「要不要记一条
|
||||
// 失败日志」,因此成功必须是空串(否则每次成功转码都会刷一条 WARN)。
|
||||
func TestCategoryOfNilIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if cat := Category(nil); cat != "" {
|
||||
t.Fatalf("Category(nil) = %q, want 空串", cat)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareWithRealTranscodeEngine 是**真走**用例(design/25 §8-T 的「复用既有能力」
|
||||
// 验收):用真实 `transcode.Engine` + `DefaultCodecs()` 把一份真 AMR-NB 夹具走通
|
||||
// `amr → wav`,断言产物是 RIFF/WAVE 且时长**可确证**。
|
||||
//
|
||||
// 夹具取自 transcode 包自己的 testdata(同一份,绝不再造一份):它是 opencore-amrnb
|
||||
// 编出的 0.5 秒合成信号,因此本用例不需要装 ffmpeg。若该目录被搬走,本用例会**红**而不是
|
||||
// 静默跳过(「跳过」会让这条唯一的真走证据消失而没人发现)。
|
||||
func TestPrepareWithRealTranscodeEngine(t *testing.T) {
|
||||
t.Parallel()
|
||||
const fixture = "../../capability/transcode/testdata/mode12200.amr"
|
||||
data, err := os.ReadFile(fixture)
|
||||
if err != nil {
|
||||
t.Fatalf("读夹具 %s: %v(真走用例依赖 transcode 包自己的 testdata)", fixture, err)
|
||||
}
|
||||
tr := transcode.NewEngine(transcode.DefaultLimits(),
|
||||
transcode.WithCodecs(transcode.DefaultCodecs()...))
|
||||
got, err := Prepare(context.Background(), tr, Prep{Data: data, Format: "amr"})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare(真 amr 夹具): %v", err)
|
||||
}
|
||||
if !got.Transcoded || got.Format != "wav" {
|
||||
t.Fatalf("产物 = %+v, want wav 转码产物", got)
|
||||
}
|
||||
if len(got.Data) < 44 || string(got.Data[:4]) != "RIFF" || string(got.Data[8:12]) != "WAVE" {
|
||||
t.Fatalf("产物不是 RIFF/WAVE:前 12 字节 = %q", head(got.Data, 12))
|
||||
}
|
||||
if got.DurationMs <= 0 {
|
||||
t.Fatalf("时长 = %d, want >0(AMR 帧数可确证)", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// head 返回 data 的前 n 字节(越界时返回全部),只用于失败信息。
|
||||
func head(data []byte, n int) []byte {
|
||||
if len(data) < n {
|
||||
return data
|
||||
}
|
||||
return data[:n]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package voice
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEncodeDecodeRoundTrip 覆盖落库形状的核心往返:编码 → 解码逐字段还原。
|
||||
// 三种真实形态各一条:有副本的 asr 真转写、无副本的 platform 转写(微信按既有裁决
|
||||
// 不取字节)、无副本的失败(正文是那行中文诚实提示)。
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ref Ref
|
||||
}{
|
||||
{"asr 真转写 + 可播副本", Ref{
|
||||
Source: SourceASR, Status: StatusTranscribed,
|
||||
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
|
||||
Size: 12345, DurationMs: 3200,
|
||||
}},
|
||||
{"platform 自带转写 + 无副本", Ref{
|
||||
Source: SourcePlatform, Status: StatusTranscribed,
|
||||
}},
|
||||
{"转写失败 + 无副本(但原件仍可能落盘)", Ref{
|
||||
Source: SourceASR, Status: StatusFailed, Name: "ffeeddccbbaa99887766554433221100.wav",
|
||||
MIME: "audio/wav", Size: 160044,
|
||||
}},
|
||||
{"转写为空 + 有副本", Ref{
|
||||
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
|
||||
MIME: "audio/mpeg", Size: 4321,
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(tc.ref)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
got, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q): %v", raw, err)
|
||||
}
|
||||
if got != tc.ref {
|
||||
t.Fatalf("往返 = %+v, want %+v", got, tc.ref)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeJSONShapeMatchesDesign 把**字面 JSON** 钉在设计上(design/24 §10.4 的示例):
|
||||
// 字段顺序、键名、以及 durationMs 缺席(0 ⇒ omitempty)都必须逐字如此。
|
||||
//
|
||||
// 为什么需要字面量钉桩:这份 JSON 会落进 chat_messages.voice 列,是客户端与历史 DTO
|
||||
// 共同的形状。字段顺序/键名漂移不会让任何单测变红(往返测试两侧同时漂移仍然相等),
|
||||
// 只有把**值**钉死才能挡住(design/24 §9.7 的同一条测试质量教训)。
|
||||
func TestEncodeJSONShapeMatchesDesign(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(Ref{
|
||||
Source: SourceASR, Status: StatusTranscribed,
|
||||
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
|
||||
Size: 12345, DurationMs: 3200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
want := `{"source":"asr","status":"transcribed","name":"a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",` +
|
||||
`"mime":"audio/ogg","size":12345,"durationMs":3200}`
|
||||
if raw != want {
|
||||
t.Fatalf("落库 JSON = %s\n want %s", raw, want)
|
||||
}
|
||||
|
||||
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死),durationMs 缺席(= 0)。
|
||||
raw, err = Encode(Ref{Source: SourcePlatform, Status: StatusTranscribed})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(no copy): %v", err)
|
||||
}
|
||||
if raw != `{"source":"platform","status":"transcribed"}` {
|
||||
t.Fatalf("无副本 JSON = %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeZeroRefIsEmptyColumn 钉住「不是语音消息」的落库形状:零值编码成**空串**,
|
||||
// 于是列里空串 ⇒ DTO 里 voice 键缺席(不是 {} 也不是 null)。
|
||||
func TestEncodeZeroRefIsEmptyColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(Ref{})
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(零值): %v", err)
|
||||
}
|
||||
if raw != "" {
|
||||
t.Fatalf("零值编码 = %q, want 空串", raw)
|
||||
}
|
||||
got, err := Decode("")
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(\"\"): %v", err)
|
||||
}
|
||||
if !got.IsZero() {
|
||||
t.Fatalf("Decode(\"\") = %+v, want 零值", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeRejectsBrokenRef 覆盖「同生共死」与闭集两条纪律的**写侧**执行:坏形状必须在
|
||||
// 写库前报错,绝不悄悄补字段(那会让库里记着的东西与客户端读到的对不上)。
|
||||
func TestEncodeRejectsBrokenRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ref Ref
|
||||
}{
|
||||
{"来源不在闭集", Ref{Source: "cloud", Status: StatusTranscribed}},
|
||||
{"状态不在闭集", Ref{Source: SourceASR, Status: "ok"}},
|
||||
{"来源为空", Ref{Status: StatusTranscribed}},
|
||||
{"状态为空", Ref{Source: SourceASR}},
|
||||
{"有 mime 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, MIME: "audio/ogg"}},
|
||||
{"有 size 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, Size: 12}},
|
||||
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
|
||||
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
|
||||
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := Encode(tc.ref)
|
||||
if err == nil {
|
||||
t.Fatalf("Encode(%+v) = %q, want 错误(坏形状绝不入库)", tc.ref, raw)
|
||||
}
|
||||
if !errors.Is(err, ErrBadRef) {
|
||||
t.Fatalf("错误 = %v, want ErrBadRef", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeRejectsGarbage 覆盖读侧:坏列内容**报错**而不是当成「不是语音」。
|
||||
// 静默当成空 = 客户端永远看不到这条语音,而库里明明有引用(正是要消灭的静默)。
|
||||
func TestDecodeRejectsGarbage(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"截断的 JSON", `{"source":"asr"`},
|
||||
{"数组形状(本列从诞生就是单对象)", `[{"source":"asr","status":"transcribed"}]`},
|
||||
{"字面量 null", `null`},
|
||||
{"空对象(两个闭集字段都缺)", `{}`},
|
||||
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
|
||||
{"闭集外的状态", `{"source":"asr","status":"done"}`},
|
||||
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := Decode(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("Decode(%s) = %+v, want 错误", tc.raw, got)
|
||||
}
|
||||
if !errors.Is(err, ErrBadRef) {
|
||||
t.Fatalf("错误 = %v, want ErrBadRef", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
|
||||
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
|
||||
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
|
||||
func TestRefIsVoiceAndHasCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ref Ref
|
||||
isVoice bool
|
||||
hasCopy bool
|
||||
}{
|
||||
{"零值(不是语音消息)", Ref{}, false, false},
|
||||
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
|
||||
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
|
||||
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tc.ref.IsVoice(); got != tc.isVoice {
|
||||
t.Fatalf("IsVoice = %v, want %v", got, tc.isVoice)
|
||||
}
|
||||
if got := tc.ref.HasCopy(); got != tc.hasCopy {
|
||||
t.Fatalf("HasCopy = %v, want %v", got, tc.hasCopy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeTrimsWhitespace 钉住宽容度只到「首尾空白」为止:列里带换行的内容(人手工
|
||||
// 改过、或将来某处写入时多了换行)仍可读回,坏内容仍然报错。
|
||||
func TestDecodeTrimsWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := "\n {\"source\":\"asr\",\"status\":\"transcribed\"}\t \n"
|
||||
got, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q): %v", raw, err)
|
||||
}
|
||||
if got.Source != SourceASR || got.Status != StatusTranscribed {
|
||||
t.Fatalf("Decode = %+v", got)
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
t.Fatal("用例前提坏了:raw 不该是空串")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
@@ -157,6 +158,14 @@ type Options struct {
|
||||
// VoiceSTTTimeout 是一次 IM 入站语音转写的超时上限(≤0 时取默认 20s)。入站流水线
|
||||
// 会为这次调用举着一个并发槽,因此上游卡住**不能**拖死整条流水线。
|
||||
VoiceSTTTimeout time.Duration
|
||||
// VoiceTranscoder 是**入站语音副本的可播归一化**所用的转码能力(design/25 §7.1 第 ④ 步;
|
||||
// 组合根注入 `a.transcoder`,与工具面用的是同一个实例)。为 nil 时:
|
||||
// 可播格式的语音照旧落副本(不需要转码),**不可播格式**(QQ 的 AMR)走
|
||||
// 「不落副本 + 带分类的 WARN」的诚实路径 —— 绝不落一份播不了的字节并假装能播。
|
||||
VoiceTranscoder transcode.Transcoder
|
||||
// VoiceAudioStore 是入站语音副本的**落盘面**(design/24 §10.9-2)。nil 时按消息构造
|
||||
// 共享文档存储并在它实现了音频分支时直接用它(见 VoiceAudioStore 的说明)。
|
||||
VoiceAudioStore VoiceAudioStore
|
||||
// DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1):
|
||||
// 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜
|
||||
// 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为
|
||||
@@ -174,6 +183,19 @@ type Options struct {
|
||||
EventStore events.Store
|
||||
}
|
||||
|
||||
// VoiceAudioStore 是入站语音**可播副本**的落盘面(design/24 §10.9-2 的「共享存储音频
|
||||
// 分支」)。它与 `documents.Store.Save` 同形:内容寻址 + 去重 + 元数据行,但走另一份
|
||||
// 裁决(音频白名单,见 design/24 §10.6)。
|
||||
//
|
||||
// 生产装配**可以留 nil**:那时桥接器按消息构造共享文档存储,并在它实现了本接口时直接
|
||||
// 用它(`documents.Store.SaveAudio`)—— 也就是说,只要存储侧补上那个方法,无需改组合根。
|
||||
// 注入面优先于上面那条发现路径,供测试与「用别的落盘实现」的部署方使用。
|
||||
//
|
||||
// 两者都没有 ⇒ 走「副本没落成」的诚实路径(带来源 WARN + 不打假 url),绝不假装落成了。
|
||||
type VoiceAudioStore interface {
|
||||
SaveAudio(ctx context.Context, origName string, data []byte) (documents.Ref, error)
|
||||
}
|
||||
|
||||
// Bridge 掌管渠道适配器生命周期与入站消息流水线。它
|
||||
// 可安全并发使用。
|
||||
type Bridge struct {
|
||||
@@ -248,10 +270,16 @@ type Bridge struct {
|
||||
// voiceSTT / voiceSTTTimeout / voiceCircuit 是入站语音转写面(切片 IM-ASR;
|
||||
// design/06 §1.1):注入点、独立短超时,以及按原因分类的**临时熔断**。
|
||||
// voiceLog 是带 `services/channelbridge/voice` 来源的子功能日志器。
|
||||
// voiceTranscoder 是入站语音**副本**的可播归一化所用的转码能力(切片 CODE-VOICE;
|
||||
// design/25 §7.1 第 ④ 步)—— 与转写面**彼此独立**:转写只认原始字节,转码只服务回放。
|
||||
voiceSTT VoiceSTT
|
||||
voiceSTTTimeout time.Duration
|
||||
voiceCircuit voiceBreaker
|
||||
voiceLog *slog.Logger
|
||||
voiceTranscoder transcode.Transcoder
|
||||
// voiceAudioStore 是**显式注入**的音频落盘面(切片 CODE-VOICE;design/24 §10.9-2)。
|
||||
// nil = 退回「共享文档存储的音频分支」发现路径(见 inboundVoiceStore)。
|
||||
voiceAudioStore VoiceAudioStore
|
||||
// proactiveCursor 是主动推送「无信号」兜底时的轮转游标
|
||||
// (channelbridge/proactive.go),使默认渠道不会总是
|
||||
// 白名单中的第一个主人渠道。由 mu 保护。
|
||||
@@ -333,6 +361,8 @@ func New(opts Options) (*Bridge, error) {
|
||||
voiceSTT: opts.VoiceSTT,
|
||||
voiceSTTTimeout: voiceSTTTimeout(opts.VoiceSTTTimeout),
|
||||
voiceLog: logger.WithSource(log, "services/channelbridge/voice"),
|
||||
voiceTranscoder: opts.VoiceTranscoder,
|
||||
voiceAudioStore: opts.VoiceAudioStore,
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
|
||||
@@ -137,7 +137,10 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content
|
||||
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与
|
||||
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
|
||||
b.normalizeInboundVoice(ctx, &msg)
|
||||
//
|
||||
// 它同时给出这条语音的**来源与三态结论**(切片 CODE-VOICE;design/24 §10.2 第 2 步),
|
||||
// 由本函数的调用方一路带到闸门之后的落副本步与落库步 —— 结论与正文同源,绝不重算。
|
||||
inboundVoice := b.normalizeInboundVoice(ctx, &msg)
|
||||
|
||||
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」——
|
||||
// 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的
|
||||
@@ -224,7 +227,7 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
}
|
||||
}
|
||||
|
||||
b.runInboundTurn(ctx, msg, a, text, actor, sess)
|
||||
b.runInboundTurn(ctx, msg, a, text, actor, sess, inboundVoice)
|
||||
}
|
||||
|
||||
// blacklistDrop 对非主人消息应用黑白名单,返回
|
||||
@@ -270,19 +273,28 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
|
||||
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
|
||||
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
|
||||
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
|
||||
// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表(再前置**该会话自己**最近的
|
||||
// 历史转录:主人私聊取主会话、群聊/陌生人取该隔离会话,见 transcript.go),在轮次之前持久化入站
|
||||
// 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地,
|
||||
// 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行
|
||||
// 循环并把结果交给 deliverReply。
|
||||
// (切片 IM-IMG;design/24 §4.2 末)、把那条被归一过的语音的**可播副本**落盘并
|
||||
// 产出 `voice` 引用(design/24 §10.2 第 4 步;切片 CODE-VOICE)、构建模型可见的
|
||||
// 消息列表(再前置**该会话自己**最近的历史转录:主人私聊取主会话、群聊/陌生人取
|
||||
// 该隔离会话,见 transcript.go),在轮次之前持久化入站
|
||||
// 用户消息**连同它们的 `document` 引用列、`image` 列与 `voice` 列**(因此即使渠道
|
||||
// 回复失败它也已落地,客户端也有附件芯片、图片与 🎤 可看),并在轮次之后把抽取结论
|
||||
// 补回同一 document 列,运行循环并把结果交给 deliverReply。
|
||||
//
|
||||
// voiceTurn(切片 CODE-VOICE)是那条语音的归一结论 —— **只在放行之后**才可能落副本:
|
||||
// 被黑白名单或最小打扰拦下的语音,盘上不留任何文件(与附件同一纪律)。
|
||||
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
|
||||
text string, actor adapter.Actor, sess session.Session) {
|
||||
text string, actor adapter.Actor, sess session.Session, voiceIn voiceTurn) {
|
||||
target := inboundTargetFor(sess, msg.Platform)
|
||||
|
||||
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
|
||||
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
|
||||
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
|
||||
attachments := b.ingestInboundDocuments(ctx, msg)
|
||||
// 语音副本落盘与附件摄取**并列**、同处闸门之后:不可播的格式先转码(复用
|
||||
// internal/capability/transcode 的既有能力),转码不可用就**不落副本**并如实记分类。
|
||||
// 它拿到的字节是**原始字节**(转写早已在流水线最前端用同一份原始字节做完)。
|
||||
voiceRef := b.ingestInboundVoiceCopy(ctx, msg, voiceIn)
|
||||
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
|
||||
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
|
||||
images := b.ingestInboundImages(msg)
|
||||
@@ -312,10 +324,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
attachInboundContent(loopMsgs, attachments, images)
|
||||
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
|
||||
|
||||
// 附件引用与图片必须同批落进用户消息各自的列(design/24 §2.4 / design/16 §4.4):
|
||||
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
|
||||
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**)。这里写的是
|
||||
// 引用与身份,文档结论要等回合跑完(下一段)。
|
||||
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**),🎤 只认
|
||||
// `voice` 列。这里写的是引用与身份,文档结论要等回合跑完(下一段)。
|
||||
document := b.inboundDocumentColumn(msg, attachments)
|
||||
image := inboundImageColumn(images)
|
||||
|
||||
@@ -330,6 +341,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
} else {
|
||||
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID)
|
||||
}
|
||||
// 语音事实与用户消息**同源落库**(design/24 §10.2 第 5 步):写进刚落的那一行,
|
||||
// 客户端因此能按结构化字段显示 🎤 与播放控件(绝不从正文里认媒体类型)。
|
||||
b.persistMessageVoice(userMsg, voiceRef)
|
||||
|
||||
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,7 +18,12 @@ package channelbridge
|
||||
// `channelbridge/inbound_images.go` 内联成 data: URI 部件、落消息 DTO 的 `image`
|
||||
// 字段,既不进共享文档存储也不进 `document` 列;**语音也不走这里** —— 入站语音由
|
||||
// `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 `Content`
|
||||
// (切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算;
|
||||
// (切片 IM-ASR;design/06 §1.1),其**音频副本**另走
|
||||
// `inbound_voice_copy.go`(闸门之后、内容寻址、`voice` 列;切片 CODE-VOICE):
|
||||
// 语音**不进文档的摄取/注入链路**(不抽取、不进 `documents` DTO 数组、不占注入
|
||||
// 预算、不出现在附件清单行里),但它**复用同一份存储与 GC**(同一个
|
||||
// `<DataDir>/uploads/documents` 目录与 `documents` 元数据表,design/24 §10.5)——
|
||||
// 也就是说「不走这里」指的是**摄取语义**,不是「不落盘」;
|
||||
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
|
||||
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
|
||||
// 被拒的消息因此绝不会在盘上留下文件;
|
||||
|
||||
@@ -12,9 +12,13 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
// 语音**副本**的领域事实(来源 / 三态 / 副本引用):与上一行的 STT 链同名不同事,
|
||||
// 因此这里必须显式起别名(design/24 §10)。
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1):
|
||||
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1),
|
||||
// 并在归一时记下这条语音的**来源与转写三态**(切片 CODE-VOICE;design/24 §10.2 第 2 步):
|
||||
// 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写
|
||||
// `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份
|
||||
// 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。
|
||||
@@ -29,6 +33,9 @@ import (
|
||||
// `voice.STTOutcome.Transcribed()`。
|
||||
// 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写
|
||||
// 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。
|
||||
//
|
||||
// 归一**不落盘、不转码**(design/24 §10.2:转写要的是原始字节;落副本必须等闸门之后):
|
||||
// 本函数只产出 `voiceTurn`(来源 + 三态 + 被归一的那条媒体),字节一直留在内存里。
|
||||
|
||||
// VoiceSTT 是入站语音转写的注入点(组合根注入 `*voice.Chain`)。
|
||||
//
|
||||
@@ -50,7 +57,7 @@ const (
|
||||
voiceNoticeCircuit = "[语音消息:这条语音没有转写(云端语音识别暂时不可用,已临时熔断退避 %s)。你可以把内容用文字发一遍。]"
|
||||
voiceNoticeEmpty = "[语音消息:识别完成,但这段音频里没有识别出文字(可能是静音、太短或环境噪声)。]"
|
||||
voiceNoticeNoAudio = "[语音消息:服务端没有拿到音频字节(渠道只给了平台内部引用,或代取失败),这条语音没有转写。]"
|
||||
voiceNoticeFormat = "[语音消息:这条语音的音频格式不支持转写(需要先转码,当前未做)。]"
|
||||
voiceNoticeFormat = "[语音消息:这条语音的音频格式不在服务端可转写的白名单内(服务端只把白名单内的音频送语音识别),这条语音没有转写。]"
|
||||
)
|
||||
|
||||
// 原因短语(用户可见,绝不包含上游错误原文/密钥)。
|
||||
@@ -65,47 +72,94 @@ const (
|
||||
voiceReasonFailed = "云端语音识别失败"
|
||||
)
|
||||
|
||||
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`。没有语音
|
||||
// 媒体时是空操作;任何走完本函数的语音消息都会留下文本或一行诚实提示。
|
||||
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) {
|
||||
// voiceTurn 是一条入站语音消息的**归一结论**(design/24 §10.2 第 2 步的产物)。它把
|
||||
// 「这条消息来自一条语音 + 正文是怎么来的」这件事从归一步带到**闸门之后**的落副本步
|
||||
// (inbound_voice_copy.go),中间不读任何全局状态 —— 因此并发入站的多条语音互不串味。
|
||||
//
|
||||
// 它同时是「模型侧标记」的来源事实:`Ref.Source` / `Ref.Status` 就是 design/24 §10.4
|
||||
// 的两个闭集字段(渲染成独立 system 行的那一步在编排侧,本包不渲染提示词)。
|
||||
type voiceTurn struct {
|
||||
// Found 报告这条消息携带语音媒体(不论有没有字节)。false ⇒ 后面全部空操作。
|
||||
Found bool
|
||||
// Media 是被归一的那**一条**语音(`inboundVoiceTarget` 的产出:优先带字节的第一条)。
|
||||
// 设计刻意只处理一条(design/24 §10.1 N7:同一条消息里的第 2..N 条语音既不转写也
|
||||
// 不落盘),因此这里只有一条。
|
||||
Media adapter.Media
|
||||
// Format 是这条媒体的音频格式名(可转写门归一所得;空 = 认不出)。
|
||||
Format string
|
||||
// DurationMs 是渠道**如实给出**的时长;0 = 不知道(绝不估)。
|
||||
DurationMs int64
|
||||
// Ref 是来源与转写三态的结论;副本字段(name/mime/size)由闸门之后的落盘步回填。
|
||||
Ref voiceref.Ref
|
||||
}
|
||||
|
||||
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`,同时返回这条
|
||||
// 语音的来源与三态结论(`voiceTurn`)。没有语音媒体时是空操作(零值结论);任何走完本
|
||||
// 函数的语音消息都会留下文本或一行诚实提示,且**正文与结论永远同源**(不会出现「正文是
|
||||
// 转写文本、结论说没转写」这种自相矛盾)。
|
||||
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) voiceTurn {
|
||||
target, found := inboundVoiceTarget(*msg)
|
||||
if !found {
|
||||
return
|
||||
return voiceTurn{}
|
||||
}
|
||||
turn := voiceTurn{Found: true, Media: target, DurationMs: channelVoiceDurationMs(target)}
|
||||
if format, ok := mediafetch.AudioFormatOf(target); ok {
|
||||
turn.Format = format
|
||||
}
|
||||
|
||||
if mediafetch.ChatText(msg.Content) != "" {
|
||||
// 平台已经给了文字(微信自带转写)或这条消息本来就带正文:不调 ASR。
|
||||
// 结论照 design/24 §10.3 的已登记口径「status=transcribed(真转写或平台自带)」:
|
||||
// 正文确是一段由渠道送达的文字,来源记 platform。
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourcePlatform, Status: voiceref.StatusTranscribed}
|
||||
b.voiceLogger().Debug("inbound voice: chat text already present, asr skipped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "content_type", msg.ContentType)
|
||||
return
|
||||
if len(target.Data) > 0 {
|
||||
// ⚠️ 设计未覆盖的情形(留痕,别当已定案):正文与**内联字节**同时存在 ——
|
||||
// 平台自带转写的真实形态是「有文字但没字节」(微信按既有裁决不取字节),
|
||||
// 因此这里更可能是「附了配文的语音」(例如 Telegram 的 audio + caption)。
|
||||
// 服务端**无法**从结构上区分二者(需要适配器声明),故按已登记口径记 source,
|
||||
// 但把这条不确定性留在日志里:设计上它是一个待裁决项,绝不静默。
|
||||
b.voiceLogger().Warn("inbound voice has both chat text and inlined bytes; source recorded as platform",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", target.Name, "mime", target.MIMEType, "size", target.Size)
|
||||
}
|
||||
return turn
|
||||
}
|
||||
|
||||
audio, ok := voiceAudioOf(target)
|
||||
if !ok {
|
||||
msg.Content = voiceNoticeFormat
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
|
||||
b.voiceLogger().Warn("inbound voice not transcribed: audio format not transcribable",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", target.Name, "mime", target.MIMEType, "size", target.Size)
|
||||
return
|
||||
return turn
|
||||
}
|
||||
if len(audio.Data) == 0 {
|
||||
msg.Content = voiceNoticeNoAudio
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
|
||||
b.voiceLogger().Warn("inbound voice not transcribed: no audio bytes",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", audio.Format, "mime", target.MIMEType, "size", target.Size)
|
||||
return
|
||||
return turn
|
||||
}
|
||||
b.transcribeInboundVoice(ctx, msg, audio)
|
||||
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: b.transcribeInboundVoice(ctx, msg, audio)}
|
||||
return turn
|
||||
}
|
||||
|
||||
// transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本,
|
||||
// 其余一律=诚实提示)。熔断打开的短路路径也在这里:**不再发起上游调用**。
|
||||
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) {
|
||||
// 其余一律=诚实提示),返回这次转写的**三态结论**(design/24 §10.4 的闭集):真转写
|
||||
// ⇒ transcribed、服务正常的空结果 ⇒ empty、没拿到文本 ⇒ failed。熔断打开的短路路径
|
||||
// 也在这里:**不再发起上游调用**,且结论同样是 failed(正文是那行熔断提示)。
|
||||
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) voiceref.Status {
|
||||
now := b.now()
|
||||
if !b.voiceCircuit.allowed(now) {
|
||||
left := b.voiceCircuit.retryAfter(now)
|
||||
msg.Content = fmt.Sprintf(voiceNoticeCircuit, left.Truncate(time.Second))
|
||||
b.voiceLogger().Warn("inbound voice not transcribed: circuit open",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "retry_after", left.String())
|
||||
return
|
||||
return voiceref.StatusFailed
|
||||
}
|
||||
out, ctxErr := b.callVoiceSTT(ctx, audio)
|
||||
if out.Transcribed() {
|
||||
@@ -116,12 +170,12 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
|
||||
b.voiceLogger().Info("inbound voice transcribed",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"provider", out.Provider, "chars", utf8.RuneCountInString(text))
|
||||
return
|
||||
return voiceref.StatusTranscribed
|
||||
}
|
||||
msg.Content = voiceNoticeEmpty
|
||||
b.voiceLogger().Info("inbound voice transcribed to empty text",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider)
|
||||
return
|
||||
return voiceref.StatusEmpty
|
||||
}
|
||||
// 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面),
|
||||
// 无论如何都落一行诚实提示 —— 消息**绝不**留空。
|
||||
@@ -133,8 +187,17 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
|
||||
b.voiceLogger().Warn("inbound voice not transcribed",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"provider", out.Provider, "category", category, "circuit", !b.voiceCircuit.allowed(now))
|
||||
return voiceref.StatusFailed
|
||||
}
|
||||
|
||||
// channelVoiceDurationMs 返回渠道**如实给出**的语音时长(毫秒),拿不到就是 0。
|
||||
//
|
||||
// ⚠️ 接线状态(诚实登记):适配器侧的字段(design/24 §10.9-6 的 `adapter.Media.DurationMs`)
|
||||
// **尚未落地**,因此今天恒定返回 0 —— 也就是「时长未知」,与「渠道没给」的落库/DTO 形状
|
||||
// 逐字相同(键缺席,绝不估)。它留在这里是为了让那一步只有**一处**要改(design/24 §10.4
|
||||
// 的「渠道如实给出时才 >0」)。
|
||||
func channelVoiceDurationMs(_ adapter.Media) int64 { return 0 }
|
||||
|
||||
// callVoiceSTT 调一次注入的 STT,并为这次调用套上**独立短超时**(父 ctx 取消同样立即
|
||||
// 生效)。未接线时直接返回「未配置」结果:不调上游、不等超时,走同一条诚实提示路径。
|
||||
//
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件承载**入站语音副本的落盘与引用回填**(design/24 §10.2 第 4/5 步;design/25
|
||||
// §7.1 第 ④ 步;切片 CODE-VOICE)。
|
||||
//
|
||||
// 位置纪律(两条都不能挪):
|
||||
// - **闸门之后**:本函数只在消息被放行后调用 ⇒ 被黑白名单 / 最小打扰拦下的语音
|
||||
// 绝不在盘上留下任何文件(与 IM 附件的既有纪律逐字相同);
|
||||
// - **落库之前**:副本必须在消息行写进 `chat_messages` 之前拿到引用,这样引用与
|
||||
// 摘要(来自内容寻址)落的是同一份事实。转写则相反,它已经在**最前端**用**原始
|
||||
// 字节**做完了(design/10 那句「转码产物对 ASR 无用且有害」)。
|
||||
//
|
||||
// 三条口径:
|
||||
// 1. **只落可播副本**(design/25 §9-4 的保守默认):源格式可播 ⇒ 原样落盘;不可播 ⇒
|
||||
// 先转码(`voice.Prepare`)再落;转码不可用 ⇒ **不落副本、绝不给假 url**,只留一条
|
||||
// 带分类的 WARN 与已定的 source/status(正文与 🎤 标记都不受影响)。
|
||||
// 2. **拿不到字节就不落**:微信按既有裁决连字节都不取(design/24 §10.14-1)⇒ 这条
|
||||
// 语音只有 `source:"platform"` 的 🎤 标记,没有可回放副本 —— 这是**刻意**的边界。
|
||||
// 3. **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志,
|
||||
// 且副本字段要么全在、要么全不在(`voice.Ref` 的「同生共死」)。
|
||||
//
|
||||
// 两个**可选窄视图**(与 inbound_documents_persist.go 的 documentStatusWriter 同一手法:
|
||||
// 用类型断言发现,未实现就走「如实报告没做成」,绝不因此让回合失败):
|
||||
// - `VoiceAudioStore`(channelbridge.go,**导出**,因此也能显式注入)= 共享存储的**音频
|
||||
// 分支**(design/24 §10.9-2 的 `documents.SaveAudio`,与 `Store.Save` 同形:内容寻址 +
|
||||
// 去重 + 元数据行);
|
||||
// - `voiceColumnWriter` = 历史存储 `voice` 列的写入视图(design/24 §10.9-4,与既有的
|
||||
// `UpdateMessageDocument` 同形)。
|
||||
//
|
||||
// ⚠️ 这两个视图由**别片**落地(见交付报告「跨片接缝」):在那之前,本文件的两条路都会
|
||||
// 走「未接线」的诚实分支(WARN + 不写库),而**不是**假装落成了。
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// voiceColumnWriter 是历史存储 `voice` 列的写入视图:把这条语音的来源事实与副本引用
|
||||
// 写进刚落库的那一行(JSON 单对象,形状见 voiceref.Ref)。
|
||||
type voiceColumnWriter interface {
|
||||
UpdateMessageVoice(ctx context.Context, id, voice string) error
|
||||
}
|
||||
|
||||
// ingestInboundVoiceCopy 在闸门之后把这条语音的原件(必要时先转码成可播副本)落进共享
|
||||
// 存储,并把副本事实回填进结论(design/24 §10.2 第 4/5 步)。它**不改正文、不改标记**:
|
||||
// 返回的 Ref 的 source/status 来自归一结论(正文是那次转写的事实),只有副本三字段
|
||||
// (name/mime/size)由这里填。
|
||||
//
|
||||
// 任何一步失败都**只影响副本**(不落副本、不打假 url),绝不反过来影响消息本身:
|
||||
// 「这条语音转写出来了但播不了」是一份完整、可理解的事实,而「把整条消息丢掉」不是。
|
||||
func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.UnifiedMessage, turn voiceTurn) voiceref.Ref {
|
||||
if !turn.Found {
|
||||
return voiceref.Ref{}
|
||||
}
|
||||
log := b.voiceLogger()
|
||||
if len(turn.Media.Data) == 0 {
|
||||
// 没有字节就没有副本:这不是故障,是渠道侧的既定事实(微信按 design/06 §1.1
|
||||
// 的裁决不取字节)。留 DEBUG(可预期),`voice` 契约里 name/mime/size/url 全缺席。
|
||||
log.Debug("inbound voice copy skipped: no audio bytes",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", turn.Format, "mime", turn.Media.MIMEType, "size", turn.Media.Size)
|
||||
return turn.Ref
|
||||
}
|
||||
prepared, err := voiceref.Prepare(ctx, b.voiceTranscoder, voiceref.Prep{
|
||||
Data: turn.Media.Data,
|
||||
Format: turn.Format,
|
||||
DurationMs: turn.DurationMs,
|
||||
})
|
||||
if err != nil {
|
||||
// 诚实路径(design/25 §5.4):**不可播且转码不可用** ⇒ 不落副本。
|
||||
// 分类进日志、原因方向进下一版的面板/契约(design/24 §10.12-9:本期不下发细分)。
|
||||
log.Warn("inbound voice copy not stored: not playable",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", turn.Format, "size", len(turn.Media.Data),
|
||||
"category", voiceref.Category(err), "error", err)
|
||||
return turn.Ref
|
||||
}
|
||||
store := b.inboundVoiceStore(msg)
|
||||
if store == nil {
|
||||
log.Warn("inbound voice copy not stored: audio store not wired",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", prepared.Format, "transcoded", prepared.Transcoded)
|
||||
return turn.Ref
|
||||
}
|
||||
ref, err := store.SaveAudio(ctx, voiceStorageName(prepared.Format), prepared.Data)
|
||||
if err != nil {
|
||||
log.Warn("inbound voice copy not stored: save failed",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"format", prepared.Format, "size", len(prepared.Data), "error", err)
|
||||
return turn.Ref
|
||||
}
|
||||
if ref.Name == "" || ref.MIME == "" || ref.Size <= 0 {
|
||||
// 「同生共死」的前提:拿不到完整的副本事实就**不给副本**(给一个没有 MIME 的
|
||||
// name 会让客户端拼出一个它读不懂的 voice 对象)。这是不该发生的状态(存储侧
|
||||
// 成功即应给全三项),因此按 ERROR 报,绝不静默。
|
||||
log.Error("inbound voice copy stored but ref is incomplete",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", ref.Name, "mime", ref.MIME, "size", ref.Size)
|
||||
return turn.Ref
|
||||
}
|
||||
turn.Ref.Name, turn.Ref.MIME, turn.Ref.Size = ref.Name, ref.MIME, ref.Size
|
||||
turn.Ref.DurationMs = prepared.DurationMs
|
||||
log.Info("inbound voice copy stored",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"source_format", turn.Format, "format", prepared.Format,
|
||||
"transcoded", prepared.Transcoded, "name", ref.Name, "mime", ref.MIME,
|
||||
"size", ref.Size, "duration_ms", prepared.DurationMs, "deduped", ref.Deduped)
|
||||
return turn.Ref
|
||||
}
|
||||
|
||||
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列
|
||||
// (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此
|
||||
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
|
||||
//
|
||||
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
|
||||
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
|
||||
//
|
||||
// 写入使用全新的后台 context(与 persistTurnDocumentStatus 同一理由):被取消回合的
|
||||
// ctx 绝不该让这次补写半途而废。
|
||||
//
|
||||
// ⚠️ 与设计的差异(**必须知道**):design/24 §10.5 的首选口径是「voice 列的名字并入
|
||||
// **同一次**引用计数、与消息行**同一事务**」(即随 INSERT 写入 `history.Message.Voice`)。
|
||||
// 本实现走的是**两段式**(落库后补写同一列),与 document 列的既有手法逐字同族
|
||||
// (`UpdateMessageDocument` 也是两段式)。代价:引用计数的增减必须由**那个**写路径
|
||||
// (在它自己的事务里)完成 —— 这正是本文件只依赖一个窄视图、而不自己碰 ref_count 的原因。
|
||||
// 接线方二选一即可(两处都在报告里给了确切落点):① 让历史存储实现 `UpdateMessageVoice`
|
||||
// (与 UpdateMessageDocument 同形);② 或按设计首选口径给 `history.Message` 加 `Voice`
|
||||
// 字段并随 INSERT 写入,那时本函数应改为不再调用(避免同一事实两处写)。
|
||||
func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
|
||||
if msg.ID == "" || !ref.IsVoice() {
|
||||
return
|
||||
}
|
||||
raw, err := voiceref.Encode(ref)
|
||||
if err != nil {
|
||||
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
|
||||
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
|
||||
b.voiceLogger().Error("inbound voice ref not persisted: encode failed",
|
||||
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
|
||||
return
|
||||
}
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
writer, ok := b.history.(voiceColumnWriter)
|
||||
if !ok {
|
||||
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
|
||||
// 如实报告「没写成」,绝不假装写了。
|
||||
b.voiceLogger().Warn("inbound voice ref not persisted",
|
||||
"reason", "history store has no voice column path", "message", msg.ID,
|
||||
"source", ref.Source, "status", ref.Status)
|
||||
return
|
||||
}
|
||||
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
|
||||
b.voiceLogger().Warn("inbound voice ref update",
|
||||
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// inboundVoiceStore 给出音频落盘面:优先用组合根**显式注入**的那个(`Options.VoiceAudioStore`),
|
||||
// 否则退回「按消息构造共享文档存储,并在它实现了音频分支时直接用它」这条发现路径。
|
||||
//
|
||||
// 两条路都取不到时返回 nil —— 调用方据此走「没落成」的诚实路径,而不是假装落成了。
|
||||
// 发现路径复用附件那一套构造(inboundDocumentStore):目录 / 上限 / 元数据写入面来自
|
||||
// 组合根注入的配置,上传者身份按消息给出 —— 因此语音副本在 `documents` 元数据里同样
|
||||
// 记着「是谁发来的」(与附件同一口径,身份只来自渠道元数据)。
|
||||
func (b *Bridge) inboundVoiceStore(msg adapter.UnifiedMessage) VoiceAudioStore {
|
||||
if b.voiceAudioStore != nil {
|
||||
return b.voiceAudioStore
|
||||
}
|
||||
store := b.inboundDocumentStore(msg)
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
audio, ok := any(store).(VoiceAudioStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return audio
|
||||
}
|
||||
|
||||
// voiceStorageName 给出交给共享存储的**提议名**。存储侧只取它的扩展名决定「按哪种格式
|
||||
// 裁决与下发」,主名一律由内容寻址算出(sha256[:32])—— 因此原始文件名永不构成存储身份
|
||||
// (与附件同一口径:名字来自服务端,不来自渠道)。用我们自己拼的名字(而不是渠道给的
|
||||
// 文件名)还有一个理由:转码产物的格式**与源文件名无关**(`amr → wav`),拿原名会把
|
||||
// `.amr` 扩展名带进一份 WAV 字节里,成对裁决必然失败。
|
||||
func voiceStorageName(format string) string {
|
||||
if format == "" {
|
||||
return "voice"
|
||||
}
|
||||
return "voice." + format
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件是本片(CODE-VOICE)**诚实路径**的用例:副本落不成、标记缺一块、消息被闸门拦下
|
||||
// 这几种情形都必须**照实**表现 —— 报错分类进日志、库里不出现假副本、消息本身一个字不丢。
|
||||
// 夹具与替身(voiceCopyEnv / fakeAudioStore / fakeVoiceTranscoder)在同目录的
|
||||
// inbound_voice_copy_test.go 里,两半共用一套。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// TestInboundVoiceCopyRejectedWhenStoreRefIncomplete 钉住「同生共死」在**跨模块边界**上的
|
||||
// 执行:存储侧若给了一份残缺引用(有 name 没有 mime),我们**不给副本**(那是客户端读不懂
|
||||
// 的形状),并按 ERROR 报(这是不该发生的状态,绝不静默)。
|
||||
func TestInboundVoiceCopyRejectedWhenStoreRefIncomplete(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{incomplete: true}
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 无副本(残缺引用绝不进库)", ref)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "ref is incomplete") {
|
||||
t.Fatalf("缺少残缺引用的 ERROR:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceMarkFollowsTranscriptionStatus 钉住三态与正文的**同源性**:服务正常的
|
||||
// 空结果 ⇒ `empty`;没拿到文本 ⇒ `failed`。两种情况下副本照样落(能不能回放与能不能转写
|
||||
// 是两件事)。
|
||||
func TestInboundVoiceMarkFollowsTranscriptionStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
out mmvoice.STTOutcome
|
||||
status voiceref.Status
|
||||
}{
|
||||
{
|
||||
name: "识别成功但没人说话",
|
||||
out: mmvoice.STTOutcome{Text: " ", Provider: "cloud"},
|
||||
status: voiceref.StatusEmpty,
|
||||
},
|
||||
{
|
||||
name: "上游失败",
|
||||
out: mmvoice.STTOutcome{Text: "", Provider: "cloud", Cause: mmvoice.ErrAuthFailed},
|
||||
status: voiceref.StatusFailed,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
env := newVoiceCopyEnv(t, &fakeVoiceSTT{reply: tc.out}, newFakeVoiceTranscoder(), store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Status != tc.status {
|
||||
t.Fatalf("status = %q, want %q", ref.Status, tc.status)
|
||||
}
|
||||
if ref.Source != voiceref.SourceASR {
|
||||
t.Fatalf("source = %q, want asr", ref.Source)
|
||||
}
|
||||
if !ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 有副本(回放与转写无关)", ref)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn 钉住「未实现该视图」的诚实:
|
||||
// 历史存储还没有 voice 列写路径时,**如实报 WARN**(不假装写了),消息与正文照旧落库。
|
||||
func TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
// 只实现 HistoryStore + document 列视图(documentHistory 会给每条消息分配 id),
|
||||
// 但**没有** UpdateMessageVoice。
|
||||
hist := &documentHistory{captureHistory: &captureHistory{}}
|
||||
store := &fakeAudioStore{}
|
||||
b, err := New(Options{
|
||||
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
History: hist,
|
||||
VoiceSTT: transcodingSTT("语音内容"),
|
||||
VoiceTranscoder: newFakeVoiceTranscoder(),
|
||||
VoiceAudioStore: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(&fakeAdapter{name: "telegram"})
|
||||
|
||||
b.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
if got := userContentIn(t, hist.captureHistory); got != "语音内容" {
|
||||
t.Fatalf("落库正文 = %q, want 转写文本", got)
|
||||
}
|
||||
if l := logs.String(); !strings.Contains(l, "history store has no voice column path") {
|
||||
t.Fatalf("缺少「无 voice 列写路径」的 WARN:%q", l)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredForRejectedMessage 钉住位置纪律:副本落盘必须在**闸门之后**。
|
||||
// 被黑白名单拦下的群消息**绝不在盘上留下任何文件**(与 IM 附件同一纪律),也因此不产生
|
||||
// 任何转码 CPU 开销。
|
||||
func TestInboundVoiceCopyNotStoredForRejectedMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
tr := newFakeVoiceTranscoder()
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
|
||||
b, err := New(Options{
|
||||
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1"}},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
History: hist,
|
||||
VoiceSTT: transcodingSTT("群里的语音"),
|
||||
VoiceTranscoder: tr,
|
||||
VoiceAudioStore: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// 该群被显式拉黑 ⇒ 消息在闸门处被丢弃。
|
||||
b.Add(&fakeAdapter{name: "telegram", cfg: map[string]any{"denyGroups": []string{"-20"}}})
|
||||
|
||||
msg := voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw"))
|
||||
msg.ChannelType = adapter.ChannelGroup
|
||||
msg.ChannelID = "-20"
|
||||
msg.GroupID = "-20"
|
||||
msg.Sender = adapter.Actor{Role: adapter.RoleMember, IsGroup: true, GroupID: "-20"}
|
||||
msg.SenderID = "9"
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
if store.callCount() != 0 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 0(被拦下的消息绝不落文件)", store.callCount())
|
||||
}
|
||||
if tr.callCount() != 0 {
|
||||
t.Fatalf("转码调用次数 = %d, want 0(闸门之后才谈转码)", tr.callCount())
|
||||
}
|
||||
if len(hist.records()) != 0 {
|
||||
t.Fatalf("被拦下的消息不该落库:%+v", hist.records())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件锁定**入站语音副本的落盘与 `voice` 列回填**(design/24 §10.2 第 4/5 步;
|
||||
// design/25 §7.1 第 ④ 步;切片 CODE-VOICE)。钉住四件事:
|
||||
//
|
||||
// 1. 可播格式(ogg/wav/mp3…)**原样**落副本,一个字节不改、**不碰转码**;
|
||||
// 2. 不可播格式(QQ 的 AMR)在落盘**之前**转码(复用 internal/capability/transcode),
|
||||
// 落的是可播副本,存储名带的是**目标扩展名**(不是渠道原文件名);
|
||||
// 3. 转码不可用(未接线 / 没这条路 / 越限 / 解码失败…)⇒ **不落副本、不打假 url**,
|
||||
// 只留一条带**分类**的 WARN —— 而**消息本身一个字都不丢**(正文仍是转写文本)。
|
||||
// 4. `voice` 列的 JSON 与 design/24 §10.4 的落库形状逐字一致:非语音消息空串、
|
||||
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死)。
|
||||
//
|
||||
// 为什么这些断言必须存在:副本这一路最容易「静默坏掉」——落了一份播不了的字节、
|
||||
// 或者给了一个指向不存在文件的 url,客户端只会看到一个转不完的圈,而日志里什么都没有。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||||
)
|
||||
|
||||
// voiceHistory 是记录型历史存储(在 documentHistory 之上)再加**本片新增的 `voice` 列
|
||||
// 写入视图**(design/24 §10.9-4)。真存储上线后由 `history.Store` 实现同一签名。
|
||||
type voiceHistory struct {
|
||||
*documentHistory
|
||||
voiceMu sync.Mutex
|
||||
voices map[string]string
|
||||
}
|
||||
|
||||
func (h *voiceHistory) UpdateMessageVoice(_ context.Context, id, voice string) error {
|
||||
h.voiceMu.Lock()
|
||||
defer h.voiceMu.Unlock()
|
||||
if h.voices == nil {
|
||||
h.voices = make(map[string]string)
|
||||
}
|
||||
h.voices[id] = voice
|
||||
return nil
|
||||
}
|
||||
|
||||
// voiceColumnOf 返回 id 所指消息当前的 `voice` 列(被回写过就是回写值)。
|
||||
func (h *voiceHistory) voiceColumnOf(id string) string {
|
||||
h.voiceMu.Lock()
|
||||
updated, ok := h.voices[id]
|
||||
h.voiceMu.Unlock()
|
||||
if ok {
|
||||
return updated
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lastUser 返回最近落库的那条用户消息(一个回合只有一条用户消息)。
|
||||
func (h *voiceHistory) lastUser(t *testing.T) history.Message {
|
||||
t.Helper()
|
||||
var out history.Message
|
||||
found := false
|
||||
for _, m := range h.records() {
|
||||
if m.Role == history.RoleUser {
|
||||
out, found = m, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("本回合没有落库任何用户消息")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeAudioStore 是音频落盘面的替身:它记录每次保存的**提议名与字节**(好让「转码产物
|
||||
// 才是落盘字节」这类纪律可断言),并按真存储的口径给出内容寻址名与下发 MIME。
|
||||
type fakeAudioStore struct {
|
||||
mu sync.Mutex
|
||||
calls []audioSaveCall
|
||||
err error
|
||||
// incomplete 为 true 时返回一份**残缺**的引用(有 name 没 mime),用来钉住
|
||||
// 「同生共死被破坏时不给副本」。
|
||||
incomplete bool
|
||||
}
|
||||
|
||||
type audioSaveCall struct {
|
||||
name string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (s *fakeAudioStore) SaveAudio(_ context.Context, origName string, data []byte) (documents.Ref, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls = append(s.calls, audioSaveCall{name: origName, data: append([]byte(nil), data...)})
|
||||
if s.err != nil {
|
||||
return documents.Ref{}, s.err
|
||||
}
|
||||
if s.incomplete {
|
||||
return documents.Ref{Name: "aabbccddeeff00112233445566778899.ogg"}, nil
|
||||
}
|
||||
ext := documents.ExtOf(origName)
|
||||
return documents.Ref{
|
||||
Name: contentAddressedName + ext,
|
||||
MIME: "audio/" + strings.TrimPrefix(ext, "."),
|
||||
Size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *fakeAudioStore) callCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.calls)
|
||||
}
|
||||
|
||||
func (s *fakeAudioStore) lastCall(t *testing.T) audioSaveCall {
|
||||
t.Helper()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.calls) == 0 {
|
||||
t.Fatal("音频存储从未被调用")
|
||||
}
|
||||
return s.calls[len(s.calls)-1]
|
||||
}
|
||||
|
||||
// contentAddressedName 是一份合法的存储主名(32 位小写十六进制),与真存储的口径一致。
|
||||
const contentAddressedName = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// fakeVoiceTranscoder 是转码替身:按 routes 回答能力门,按 out/err 回答转换结果。
|
||||
type fakeVoiceTranscoder struct {
|
||||
routes map[string]bool
|
||||
out transcode.Result
|
||||
err error
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func newFakeVoiceTranscoder() *fakeVoiceTranscoder {
|
||||
return &fakeVoiceTranscoder{routes: map[string]bool{"amr>wav": true}}
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) {
|
||||
f.mu.Lock()
|
||||
f.calls++
|
||||
f.mu.Unlock()
|
||||
if f.err != nil {
|
||||
return transcode.Result{}, f.err
|
||||
}
|
||||
res := f.out
|
||||
if res.Format == "" {
|
||||
res.Format = req.Target
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) Supports(src, dst transcode.Format) bool {
|
||||
return f.routes[transcode.FormatName(src)+">"+transcode.FormatName(dst)]
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) Formats() []transcode.Format {
|
||||
return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV}
|
||||
}
|
||||
|
||||
func (f *fakeVoiceTranscoder) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
// voiceCopyEnv 是本片用例的装配:真流水线(`process`)+ 假 STT + 可注入的转码器与音频
|
||||
// 存储 + 记录型历史(含 voice 列视图)。
|
||||
type voiceCopyEnv struct {
|
||||
bridge *Bridge
|
||||
hist *voiceHistory
|
||||
store *fakeAudioStore
|
||||
tr *fakeVoiceTranscoder
|
||||
logs *safeBuffer
|
||||
}
|
||||
|
||||
func newVoiceCopyEnv(t *testing.T, stt VoiceSTT, tr *fakeVoiceTranscoder, store *fakeAudioStore) *voiceCopyEnv {
|
||||
t.Helper()
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log})
|
||||
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
|
||||
opts := Options{
|
||||
Log: log, Loop: runner,
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: testOwnerProfile("telegram", "100"),
|
||||
History: hist, VoiceSTT: stt,
|
||||
}
|
||||
// 传 nil 与传具体值必须区别对待:`VoiceTranscoder` 是接口,「没接线」正是本片要
|
||||
// 覆盖的一条路径(转码不可用 ⇒ 不落副本),装了 nil 指针会让那条判据失效。
|
||||
if tr != nil {
|
||||
opts.VoiceTranscoder = tr
|
||||
}
|
||||
if store != nil {
|
||||
opts.VoiceAudioStore = store
|
||||
}
|
||||
b, err := New(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(&fakeAdapter{name: "telegram"})
|
||||
return &voiceCopyEnv{bridge: b, hist: hist, store: store, tr: tr, logs: logs}
|
||||
}
|
||||
|
||||
// voiceRefOf 返回本回合用户消息的 `voice` 事实(列空 ⇒ 零值,即「不是语音消息」)。
|
||||
func (e *voiceCopyEnv) voiceRefOf(t *testing.T) voiceref.Ref {
|
||||
t.Helper()
|
||||
msg := e.hist.lastUser(t)
|
||||
raw := e.hist.voiceColumnOf(msg.ID)
|
||||
ref, err := voiceref.Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解码 voice 列 %q: %v", raw, err)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// contentOf 返回本回合落库的用户消息正文。
|
||||
func (e *voiceCopyEnv) contentOf(t *testing.T) string {
|
||||
t.Helper()
|
||||
return e.hist.lastUser(t).Content
|
||||
}
|
||||
|
||||
// userContentIn 返回记录型历史里最后一条用户消息的正文(没有则判失败)。
|
||||
func userContentIn(t *testing.T, hist *captureHistory) string {
|
||||
t.Helper()
|
||||
content := ""
|
||||
found := false
|
||||
for _, m := range hist.records() {
|
||||
if m.Role == history.RoleUser {
|
||||
content, found = m.Content, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("没有落库任何用户消息")
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// transcodingSTT 是一个「转写成功」的假 STT,供不需要关心转写细节的用例使用。
|
||||
func transcodingSTT(text string) *fakeVoiceSTT {
|
||||
return &fakeVoiceSTT{reply: mmvoice.STTOutcome{Text: text, Provider: "cloud"}}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyStoredAsIsForPlayableFormat 覆盖设计口径①(design/25 §7.1):
|
||||
// 源格式可播 ⇒ **原样落副本**,一个字节不改,且**一次转码都不发生**(可播还转一圈等于
|
||||
// 白烧 CPU、把音质往下拽)。同时钉住落库 JSON 的逐字形状。
|
||||
func TestInboundVoiceCopyStoredAsIsForPlayableFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
tr := newFakeVoiceTranscoder()
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("你好"), tr, store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
if store.callCount() != 1 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 1", store.callCount())
|
||||
}
|
||||
call := store.lastCall(t)
|
||||
if call.name != "voice.ogg" {
|
||||
t.Fatalf("存储提议名 = %q, want voice.ogg(扩展名决定按哪种格式裁决)", call.name)
|
||||
}
|
||||
if string(call.data) != "OGGDATA" {
|
||||
t.Fatalf("落盘字节 = %q, want 原样(可播格式不转码)", call.data)
|
||||
}
|
||||
if tr.callCount() != 0 {
|
||||
t.Fatalf("转码调用次数 = %d, want 0(可播格式绝不转码)", tr.callCount())
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed {
|
||||
t.Fatalf("voice 事实 = %+v, want asr/transcribed", ref)
|
||||
}
|
||||
if ref.Name != contentAddressedName+".ogg" || ref.MIME != "audio/ogg" || ref.Size != 7 {
|
||||
t.Fatalf("副本事实 = %+v, want 服务端存储名 + audio/ogg + 7 字节", ref)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "inbound voice copy stored") ||
|
||||
!strings.Contains(log, "source=services/channelbridge/voice") {
|
||||
t.Fatalf("落副本日志缺少子功能来源:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyTranscodesAMRBeforeStoring 覆盖设计口径②(design/24 §10.14-2 的
|
||||
// 核实结论:QQ 的 AMR 浏览器播不了 ⇒ 必须转码):落的是**转码产物**,存储名带的是**目标**
|
||||
// 扩展名(`.wav`),且时长取转码产物可确证的值。
|
||||
func TestInboundVoiceCopyTranscodesAMRBeforeStoring(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
tr := newFakeVoiceTranscoder()
|
||||
tr.out = transcode.Result{Data: []byte("RIFFwavdata"), Format: transcode.FormatWAV, DurationMs: 3200}
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), tr, store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw")))
|
||||
|
||||
if tr.callCount() != 1 {
|
||||
t.Fatalf("转码调用次数 = %d, want 1(AMR 不可播)", tr.callCount())
|
||||
}
|
||||
call := store.lastCall(t)
|
||||
if call.name != "voice.wav" {
|
||||
t.Fatalf("存储提议名 = %q, want voice.wav(转码产物的扩展名,不是渠道原文件名)", call.name)
|
||||
}
|
||||
if string(call.data) != "RIFFwavdata" {
|
||||
t.Fatalf("落盘字节 = %q, want 转码产物", call.data)
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Name != contentAddressedName+".wav" || ref.DurationMs != 3200 {
|
||||
t.Fatalf("副本事实 = %+v, want .wav + durationMs=3200(转码产物可确证)", ref)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "transcoded=true") {
|
||||
t.Fatalf("日志没有标出转码事实:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable 覆盖设计口径③(design/25 §5.4):
|
||||
// **不可播且转码不可用** ⇒ 不落副本、不打假 url,只留一条带**分类**的 WARN;而**消息本身
|
||||
// 一个字都不丢**(正文仍是转写文本)—— 这是本片「转码失败要可分类且不丢消息」的验收。
|
||||
func TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
// 组合根没把转码能力交进来(VoiceTranscoder 为 nil)——今天生产装配就是这个状态。
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), nil, store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw")))
|
||||
|
||||
if store.callCount() != 0 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 0(不可播就不落,绝不给播不了的 url)", store.callCount())
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if !ref.IsVoice() || ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref)
|
||||
}
|
||||
if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed {
|
||||
t.Fatalf("voice 事实 = %+v, want asr/transcribed(正文确实是转写文本)", ref)
|
||||
}
|
||||
if got := env.contentOf(t); got != "语音内容" {
|
||||
t.Fatalf("落库正文 = %q, want 转写文本(消息绝不因为转码不可用而丢)", got)
|
||||
}
|
||||
log := env.logs.String()
|
||||
if !strings.Contains(log, "category=transcode_not_wired") || !strings.Contains(log, "copy not stored") {
|
||||
t.Fatalf("缺少带分类的诚实日志:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted 覆盖微信那条既有裁决
|
||||
// (design/24 §10.14-1):平台自带转写 ⇒ 连字节都不取 ⇒ **没有可回放副本**,但 🎤 标记
|
||||
// (`source:"platform"`)必须照落 —— 客户端据此显示「这是一条语音」,只是不显示播放控件。
|
||||
func TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{}
|
||||
env := newVoiceCopyEnv(t, &fakeVoiceSTT{}, newFakeVoiceTranscoder(), store)
|
||||
msg := voiceMessage("v.silk", "audio/silk", nil)
|
||||
msg.Content = "平台自带转写" // 微信 iLink 的 voice_item.text
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if store.callCount() != 0 {
|
||||
t.Fatalf("音频存储调用次数 = %d, want 0(没有字节就没有副本)", store.callCount())
|
||||
}
|
||||
ref := env.voiceRefOf(t)
|
||||
if ref.Source != voiceref.SourcePlatform || ref.Status != voiceref.StatusTranscribed {
|
||||
t.Fatalf("voice 事实 = %+v, want platform/transcribed", ref)
|
||||
}
|
||||
if ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 无副本(name/mime/size 全缺席)", ref)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundVoiceCopyNotStoredWhenSaveFails 钉住「存储侧失败」的诚实处理:正文与标记
|
||||
// 都在,只是没有副本 —— 绝不因为落副本失败就把消息当成失败。
|
||||
func TestInboundVoiceCopyNotStoredWhenSaveFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &fakeAudioStore{err: errors.New("磁盘满了")}
|
||||
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
|
||||
|
||||
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
|
||||
|
||||
ref := env.voiceRefOf(t)
|
||||
if !ref.IsVoice() || ref.HasCopy() {
|
||||
t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref)
|
||||
}
|
||||
if got := env.contentOf(t); got != "语音内容" {
|
||||
t.Fatalf("落库正文 = %q, want 转写文本", got)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "inbound voice copy not stored") {
|
||||
t.Fatalf("缺少落副本失败的 WARN:%q", log)
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,13 @@ func TestInboundVoiceTimeoutIsHonest(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节
|
||||
// (渠道只给平台内部引用 / 代取失败)与格式不支持 —— 都落诚实提示,且**不调上游**。
|
||||
// (渠道只给平台内部引用 / 代取失败)与格式不在**可转写白名单**内 —— 都落诚实提示,
|
||||
// 且**不调上游**。
|
||||
//
|
||||
// ⚠️ 文案留痕(2026-10-06,切片 CODE-VOICE):本用例原先钉的是「格式不支持(需要先转码,
|
||||
// 当前未做)」——那句在转码能力(design/25)落地后**就是一句假话**(design/25 §5.4 明令
|
||||
// 同步改)。因此这里改钉新口径:格式**不在可转写白名单内**。这不是「改测试迁就代码」,
|
||||
// 而是被断言保护的行为**已按设计变更**(与 QQ `[语音]` 占位标记那条同一性质)。
|
||||
func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("没有字节", func(t *testing.T) {
|
||||
@@ -404,7 +410,7 @@ func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||||
t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got)
|
||||
}
|
||||
})
|
||||
t.Run("格式不支持", func(t *testing.T) {
|
||||
t.Run("格式不在可转写白名单内", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := &fakeVoiceSTT{}
|
||||
env := newVoiceEnv(t, stt, 0)
|
||||
@@ -415,8 +421,12 @@ func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
|
||||
if stt.callCount() != 0 {
|
||||
t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount())
|
||||
}
|
||||
if got := lastUserContent(env); !strings.Contains(got, "格式不支持") {
|
||||
t.Fatalf("落库正文 = %q, want 格式不支持的诚实提示", got)
|
||||
got := lastUserContent(env)
|
||||
if !strings.Contains(got, "不在服务端可转写的白名单内") {
|
||||
t.Fatalf("落库正文 = %q, want 白名单外的诚实提示", got)
|
||||
}
|
||||
if strings.Contains(got, "当前未做") {
|
||||
t.Fatalf("落库正文 = %q:转码能力已落地(design/25),这句「当前未做」是假话", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user