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
+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)
}
}
}