Add bounded in-memory WAV capture for callflow

This commit is contained in:
2026-09-30 01:16:32 +08:00
parent 1cc2d12b73
commit 79c701dfd1
5 changed files with 260 additions and 2 deletions
@@ -64,8 +64,9 @@
- Dispatcher 的无录音最终结果隔离组件:`CurrentStore.RecordCallResult` 仅在确认通话结束后,按持久任务快照校验任务、被叫、主叫和已选线路,并以源执行事件固定生成唯一最终结果身份;消息通过严格 MQ Schema 校验后与 outbox 在同一事务写入。同内容重投/重启只恢复原消息,冲突结果和 SQLite 写入失败均不会产生第二份结果。这里只验证隔离组件,Agent 实际回报尚未连通。
- Dispatcher 的原始 OSS 目标及已上传结果隔离组件:新 SQLite 布局把一次通话的 upload_id、bucket、object_key、录音格式/时长/大小和 SHA-256 唯一绑定到已保留的执行;不保存临时 URL 或 TOKEN。旧布局拒绝启动并原样保留待交付 outbox,不自动迁移或清理。已签发录音目标不能通过空录音结果绕过上传;已有空录音最终结果不能再签发录音目标。`RecordUploadedCallResult` 仅接受与持久绑定完全一致的录音事实及 Agent 所报告的成功 PUT 状态,录音确认与唯一最终结果 outbox 同事务提交;丢失回报或 MQ 投递时重用原消息,已确认后拒绝再次签发 PUT 授权。Mock 证明的是本地状态约束,不是独立 OSS 校验或真实 Agent 身份验证。
- Dispatcher 的终结与外呼回执顺序竞争隔离修复:原流程在 Agent 接受执行的 RPC 返回后才写入外呼回执,快速结束或 RPC 超时可能先到;现以一次 SQLite 事务在确认通话已结束后补齐原回执并释放占用,未知执行仅在确认结束后释放。迟到的执行响应、超时和重复结束不会产生第二份回执;注入 outbox 写入失败保留原占用。并发竞争及结束后立即生成唯一最终结果有单元测试;主入口真实 Agent 会话注入与通话执行仍未接线。
- Dispatcher 录音事实 Unary RPC 隔离服务:`RequestRecordingUpload`、`ReportCallEnded`、`ReportCallResult` 均要求已配置本 D、核验 mTLS 指纹及当前 Agent 会话、数字租户和已保留的执行;复用官方 SDK 仅对原始录音签发固定 15 分钟授权,显式重申请仍用相同 bucket/object_key。结束事实可先于外呼响应而持久化原回执;录音结果核对 D 已存目标和 Agent 报告的成功 PUT,再与唯一结果 outbox 同事务提交。Mock 覆盖会话/租户拒绝、同资产重申请、上传前结果拒绝、坏 JSON、已上传与无录音结果及重复回报。隔离测试还通过本地双向 TLS 的 gRPC 实际传输:Agent `RecordingClient` 每次读取并克隆当前会话元数据,经受控 D 客户端领取授权、上报结束和唯一结果;未配置客户端明确拒绝。不包含实际 OSS PUT、媒体录音或主入口接线。
- 已验证:`go test ./... -count=1`、`go test -race ./internal/agent ./internal/rpc ./internal/store -count=1`(分批执行)、`go vet ./...`、`go build ./...`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-current-contracts.sh`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`git diff --check`。尚未完成实际录音到直传/失败恢复的接线、无录音与生成失败的真实调用、主入口 Agent↔Dispatcher 实际会话与录音执行接线(隔离 mTLS gRPC 已测)及上传事实/最终结果交付、MQ/端到端验收,不能宣称 P06 通过。
- Dispatcher 录音事实 Unary RPC 隔离服务:`RequestRecordingUpload`、`ReportCallEnded`、`ReportCallResult` 均要求已配置本 D、核验 mTLS 指纹及当前 Agent 会话、数字租户和已保留的执行;复用官方 SDK 仅对原始录音签发固定 15 分钟授权,显式重申请仍用相同 bucket/object_key。结束事实可先于外呼响应而持久化原回执;录音结果核对 D 已存目标和 Agent 报告的成功 PUT,再与唯一结果 outbox 同事务提交。Mock 覆盖会话/租户拒绝、同资产重申请、上传前结果拒绝、坏 JSON、已上传与无录音结果及重复回报。隔离测试还通过本地双向 TLS 的 gRPC 实际传输:Agent `RecordingClient` 每次读取并克隆当前会话元数据,经受控 D 客户端领取授权、上报结束和唯一结果;未配置客户端明确拒绝。不包含实际 OSS PUT 或主入口批准执行的媒体录音接线。
- 内存录音隔离组件:`RecordingSession` 仅复制共享通话流程实际读到和成功发送的 16-kHz PCM16,`EncodeMonoWAV` 直接在内存生成有界单声道 WAV;空音频、奇数字节、超过上限及未成功发送的音频都不能伪造成可上传录音。单元与 race 测试未产生业务文件。批准执行入口尚未接入该组件,且 Mock 中观测到的帧不等于真实 Asterisk 通话的全量媒体验收。
- 已验证:`go test ./... -count=1`、`go test -race ./internal/media ./internal/callflow -count=1`、`go test -race ./internal/agent ./internal/rpc ./internal/store -count=1`(分批执行)、`go vet ./...`、`go build ./...`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-current-contracts.sh`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`git diff --check`。尚未完成实际录音到直传/失败恢复的接线、无录音与生成失败的真实调用、主入口 Agent↔Dispatcher 实际会话与录音执行接线(隔离 mTLS gRPC 已测)及上传事实/最终结果交付、MQ/端到端验收,不能宣称 P06 通过。
## 验收台账
+81
View File
@@ -0,0 +1,81 @@
package callflow
import (
"context"
"errors"
"fmt"
"sync"
"git.ipao.vip/rogee/go-sip/internal/media"
)
// RecordingSession records only PCM16 frames the approved callflow actually
// reads or successfully sends. It never writes a business file or encodes RTP.
// Capture failure is retained and returned by WAV; the call can still finish.
type RecordingSession struct {
MediaSession
mu sync.Mutex
pcm []byte
maxBytes int64
failure error
}
func NewRecordingSession(session MediaSession, maxWAVBytes int64) (*RecordingSession, error) {
if session == nil || maxWAVBytes <= 44 {
return nil, errors.New("recording requires a media session and a positive bounded WAV allowance")
}
return &RecordingSession{MediaSession: session, maxBytes: maxWAVBytes}, nil
}
func (r *RecordingSession) ReadPayload(ctx context.Context) ([]byte, error) {
payload, err := r.MediaSession.ReadPayload(ctx)
if err == nil {
r.capture(payload)
}
return payload, err
}
func (r *RecordingSession) SendPCM16(ctx context.Context, pcm []byte, sampleRateHz int) error {
if sampleRateHz != 16000 {
return fmt.Errorf("recording requires 16-kHz PCM16, received %d Hz", sampleRateHz)
}
if err := r.MediaSession.SendPCM16(ctx, pcm, sampleRateHz); err != nil {
return err
}
r.capture(pcm)
return nil
}
func (r *RecordingSession) capture(pcm []byte) {
if len(pcm) == 0 {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.failure != nil {
return
}
if len(pcm)%2 != 0 {
r.failure = media.ErrWAVInvalidPCM
r.pcm = nil
return
}
if int64(len(pcm)) > r.maxBytes-44-int64(len(r.pcm)) {
r.failure = media.ErrWAVTooLarge
r.pcm = nil
return
}
r.pcm = append(r.pcm, pcm...)
}
// WAV returns an in-memory 16-kHz mono recording and its duration. The caller
// must inspect its error after the call; a failed capture must not be reported
// as a successful upload or a healthy no-recording outcome.
func (r *RecordingSession) WAV() ([]byte, int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.failure != nil {
return nil, 0, r.failure
}
return media.EncodeMonoWAV(r.pcm, r.maxBytes)
}
+94
View File
@@ -0,0 +1,94 @@
package callflow
import (
"bytes"
"context"
"errors"
"os"
"testing"
"git.ipao.vip/rogee/go-sip/internal/media"
)
func TestRecordingSessionCapturesReadAndSentMediaInMemory(t *testing.T) {
inbound := bytes.Repeat([]byte{1, 0}, 320)
outbound := bytes.Repeat([]byte{2, 0}, 320)
root := t.TempDir()
source := NewMemorySession(inbound)
recording, err := NewRecordingSession(source, 44+int64(len(inbound)+len(outbound)))
if err != nil {
t.Fatal(err)
}
if err := recording.SendPCM16(context.Background(), outbound, 16000); err != nil {
t.Fatal(err)
}
got, err := recording.ReadPayload(context.Background())
if err != nil || !bytes.Equal(got, inbound) {
t.Fatalf("recording intercepted or changed the live inbound audio: err=%v", err)
}
wav, durationMS, err := recording.WAV()
if err != nil || durationMS != 40 || !bytes.Equal(wav[44:], append(bytes.Clone(outbound), inbound...)) {
t.Fatalf("recording did not preserve media in callflow order: duration=%d err=%v", durationMS, err)
}
if stats := recording.Stats(); stats.ReceivedPackets != 1 || stats.SentPackets != 1 {
t.Fatalf("recording changed live RTP accounting: %+v", stats)
}
entries, err := os.ReadDir(root)
if err != nil || len(entries) != 0 {
t.Fatalf("normal in-memory recording created business files: entries=%d err=%v", len(entries), err)
}
}
func TestRecordingSessionReportsNoAudioAndCaptureFailuresWithoutInventingWAV(t *testing.T) {
if _, err := NewRecordingSession(nil, 1024); err == nil {
t.Fatal("missing media session admitted")
}
if _, err := NewRecordingSession(NewMemorySession(nil), 44); err == nil {
t.Fatal("no room for even one audio sample admitted")
}
empty, err := NewRecordingSession(NewMemorySession(nil), 1024)
if err != nil {
t.Fatal(err)
}
if _, _, err := empty.WAV(); !errors.Is(err, media.ErrWAVEmpty) {
t.Fatalf("no call audio became a recording: %v", err)
}
tooLarge, err := NewRecordingSession(NewMemorySession([]byte{3, 0, 4, 0}), 48)
if err != nil {
t.Fatal(err)
}
if err := tooLarge.SendPCM16(context.Background(), []byte{1, 0, 2, 0}, 16000); err != nil {
t.Fatal(err)
}
if frame, err := tooLarge.ReadPayload(context.Background()); err != nil || len(frame) != 4 {
t.Fatalf("recording size failure blocked the live call: frame=%v err=%v", frame, err)
}
if _, _, err := tooLarge.WAV(); !errors.Is(err, media.ErrWAVTooLarge) {
t.Fatalf("over-limit call audio became an uploadable WAV: %v", err)
}
odd, err := NewRecordingSession(NewMemorySession([]byte{1, 2, 3}), 1024)
if err != nil {
t.Fatal(err)
}
if frame, err := odd.ReadPayload(context.Background()); err != nil || len(frame) != 3 {
t.Fatalf("invalid recorded PCM blocked the original media stream: frame=%v err=%v", frame, err)
}
if _, _, err := odd.WAV(); !errors.Is(err, media.ErrWAVInvalidPCM) {
t.Fatalf("odd PCM16 was reported as a valid recording: %v", err)
}
}
func TestRecordingSessionDoesNotRecordUnsentAudio(t *testing.T) {
recording, err := NewRecordingSession(NewMemorySession(nil), 1024)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := recording.SendPCM16(ctx, []byte{1, 0}, 16000); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled media send unexpectedly succeeded: %v", err)
}
if _, _, err := recording.WAV(); !errors.Is(err, media.ErrWAVEmpty) {
t.Fatalf("unsent audio appeared in recording: %v", err)
}
}
+44
View File
@@ -0,0 +1,44 @@
package media
import (
"encoding/binary"
"errors"
)
var ErrWAVEmpty = errors.New("no captured PCM16 recording")
var ErrWAVInvalidPCM = errors.New("captured PCM16 has an odd byte length")
var ErrWAVTooLarge = errors.New("captured WAV exceeds its configured byte limit")
// EncodeMonoWAV wraps already decoded mono PCM16/16-kHz audio in memory.
// It is only a WAV container writer: RTP decoding and audio codecs remain in
// the selected media libraries. No business file is created on this path.
func EncodeMonoWAV(pcm []byte, maxBytes int64) ([]byte, int64, error) {
const headerBytes = 44
if len(pcm) == 0 {
return nil, 0, ErrWAVEmpty
}
if len(pcm)%2 != 0 {
return nil, 0, ErrWAVInvalidPCM
}
if maxBytes <= headerBytes || int64(len(pcm)) > maxBytes-headerBytes || uint64(len(pcm)) > uint64(^uint32(0))-36 {
return nil, 0, ErrWAVTooLarge
}
const sampleRate = 16000
dataSize := uint32(len(pcm))
wav := make([]byte, headerBytes+len(pcm))
copy(wav[:4], "RIFF")
binary.LittleEndian.PutUint32(wav[4:8], 36+dataSize)
copy(wav[8:12], "WAVE")
copy(wav[12:16], "fmt ")
binary.LittleEndian.PutUint32(wav[16:20], 16)
binary.LittleEndian.PutUint16(wav[20:22], 1)
binary.LittleEndian.PutUint16(wav[22:24], 1)
binary.LittleEndian.PutUint32(wav[24:28], sampleRate)
binary.LittleEndian.PutUint32(wav[28:32], sampleRate*2)
binary.LittleEndian.PutUint16(wav[32:34], 2)
binary.LittleEndian.PutUint16(wav[34:36], 16)
copy(wav[36:40], "data")
binary.LittleEndian.PutUint32(wav[40:44], dataSize)
copy(wav[44:], pcm)
return wav, int64(len(pcm)) * 1000 / (sampleRate * 2), nil
}
+38
View File
@@ -0,0 +1,38 @@
package media
import (
"bytes"
"encoding/binary"
"testing"
)
func TestEncodeMonoWAVProducesBoundedInMemoryPCM16(t *testing.T) {
pcm := bytes.Repeat([]byte{0x12, 0x34}, 3200) // 200 ms at 16 kHz.
wav, durationMS, err := EncodeMonoWAV(pcm, int64(len(pcm)+44))
if err != nil {
t.Fatal(err)
}
if durationMS != 200 || len(wav) != 44+len(pcm) || string(wav[:4]) != "RIFF" || string(wav[8:12]) != "WAVE" || string(wav[12:16]) != "fmt " || string(wav[36:40]) != "data" {
t.Fatalf("in-memory recording does not contain the original PCM16: duration=%d bytes=%d", durationMS, len(wav))
}
if binary.LittleEndian.Uint32(wav[4:8]) != uint32(len(wav)-8) || binary.LittleEndian.Uint16(wav[20:22]) != 1 || binary.LittleEndian.Uint16(wav[22:24]) != 1 || binary.LittleEndian.Uint32(wav[24:28]) != 16000 || binary.LittleEndian.Uint32(wav[28:32]) != 32000 || binary.LittleEndian.Uint16(wav[34:36]) != 16 || binary.LittleEndian.Uint32(wav[40:44]) != uint32(len(pcm)) || !bytes.Equal(wav[44:], pcm) {
t.Fatal("WAV header or copied PCM differs from the 16-kHz mono original")
}
}
func TestEncodeMonoWAVRejectsEmptyOddAndOverLimitAudio(t *testing.T) {
for _, tc := range []struct {
name string
pcm []byte
limit int64
}{
{"no recording", nil, 1024},
{"odd PCM16", []byte{1, 2, 3}, 1024},
{"no storage allowance", []byte{1, 2}, 44},
{"audio over bound", []byte{1, 2, 3, 4}, 47},
} {
if _, _, err := EncodeMonoWAV(tc.pcm, tc.limit); err == nil {
t.Fatalf("%s was fabricated as an uploadable WAV", tc.name)
}
}
}