From ede00d9928dc5f91d49baed2ba0abb579a29ecf1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 09:04:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E8=AF=AD=E9=9F=B3=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=90=BD=E5=BA=93=20+=20=E8=BD=AC=E7=A0=81=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=88=E5=88=87=E7=89=87=200020=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引 - 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装) - 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息 ⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。 --- internal/db/migrate_voice_test.go | 96 +++++ .../0020_chat_message_voice.down.sql | 12 + .../migrations/0020_chat_message_voice.up.sql | 48 +++ internal/runtime/voice/playable.go | 98 +++++ internal/runtime/voice/playable_test.go | 133 ++++++ internal/runtime/voice/prepare.go | 146 +++++++ internal/runtime/voice/prepare_test.go | 296 +++++++++++++ internal/runtime/voice/voice.go | 174 ++++++++ internal/runtime/voice/voice_test.go | 211 ++++++++++ .../services/channelbridge/channelbridge.go | 30 ++ internal/services/channelbridge/inbound.go | 36 +- .../channelbridge/inbound_documents.go | 7 +- .../services/channelbridge/inbound_voice.go | 93 +++- .../channelbridge/inbound_voice_copy.go | 194 +++++++++ .../inbound_voice_copy_honesty_test.go | 168 ++++++++ .../channelbridge/inbound_voice_copy_test.go | 396 ++++++++++++++++++ .../channelbridge/inbound_voice_test.go | 18 +- 17 files changed, 2125 insertions(+), 31 deletions(-) create mode 100644 internal/db/migrate_voice_test.go create mode 100644 internal/db/migrations/0020_chat_message_voice.down.sql create mode 100644 internal/db/migrations/0020_chat_message_voice.up.sql create mode 100644 internal/runtime/voice/playable.go create mode 100644 internal/runtime/voice/playable_test.go create mode 100644 internal/runtime/voice/prepare.go create mode 100644 internal/runtime/voice/prepare_test.go create mode 100644 internal/runtime/voice/voice.go create mode 100644 internal/runtime/voice/voice_test.go create mode 100644 internal/services/channelbridge/inbound_voice_copy.go create mode 100644 internal/services/channelbridge/inbound_voice_copy_honesty_test.go create mode 100644 internal/services/channelbridge/inbound_voice_copy_test.go diff --git a/internal/db/migrate_voice_test.go b/internal/db/migrate_voice_test.go new file mode 100644 index 0000000..ba55032 --- /dev/null +++ b/internal/db/migrate_voice_test.go @@ -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) + } +} diff --git a/internal/db/migrations/0020_chat_message_voice.down.sql b/internal/db/migrations/0020_chat_message_voice.down.sql new file mode 100644 index 0000000..a55d6af --- /dev/null +++ b/internal/db/migrations/0020_chat_message_voice.down.sql @@ -0,0 +1,12 @@ +-- 0020_chat_message_voice down: 回滚「语音事实」列。 +-- +-- voice 只被 chat_messages 自己引用(无索引、无视图、无约束牵涉),因此直接删列即可。 +-- 回滚后的状态是「服务端不再记得这条消息来自一条语音」:列里的来源事实与音频副本引用 +-- 随列一起丢弃。 +-- +-- ⚠️ 音频副本本体(/uploads/documents 里的文件与 `documents` 元数据行)**不在 +-- 本迁移的范围内**:删掉这一列等于一次性放掉这些副本的最后一份引用,它们的 ref_count +-- 不会再被消息写路径增减,此后只能由既有 GC(保留期 + ref_count = 0)收走。这是**有意** +-- 的取舍:迁移绝不代替 GC 去删用户数据(同 0015 的 down 口径)。 + +ALTER TABLE chat_messages DROP COLUMN voice; diff --git a/internal/db/migrations/0020_chat_message_voice.up.sql b/internal/db/migrations/0020_chat_message_voice.up.sql new file mode 100644 index 0000000..e31ad1a --- /dev/null +++ b/internal/db/migrations/0020_chat_message_voice.up.sql @@ -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 ''; diff --git a/internal/runtime/voice/playable.go b/internal/runtime/voice/playable.go new file mode 100644 index 0000000..2b79b18 --- /dev/null +++ b/internal/runtime/voice/playable.go @@ -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) +} diff --git a/internal/runtime/voice/playable_test.go b/internal/runtime/voice/playable_test.go new file mode 100644 index 0000000..5f653f5 --- /dev/null +++ b/internal/runtime/voice/playable_test.go @@ -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, "