Add bounded in-memory WAV capture for callflow
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user