telegabber/calls/audio/capture.go
2026-05-25 06:27:20 -07:00

160 lines
3.8 KiB
Go

// optional offline-replay dump of the two media streams; gated by
// CALLS_CAPTURE_DIR (no-op when unset). big-endian throughout, ts is ms
// since writer construction.
// header: [magic 8B][ver u8=1][reserved 7B]
// PCM rec (tg->xmpp): [ts_ms i64][ssrc u32][len u32][pcm...]
// RTP rec (xmpp->tg): [ts_ms i64][ssrc u32][seq u16][rtp_ts u32][pt u8][len u32][opus...]
package audio
import (
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
)
const (
captureEnvVar = "CALLS_CAPTURE_DIR"
captureVer = byte(1)
)
var (
magicPCM = [8]byte{'T', 'G', 'C', 'A', 'P', 'P', 'C', 'M'}
magicRTP = [8]byte{'T', 'G', 'C', 'A', 'P', 'R', 'T', 'P'}
)
// per-call file sink; nil-safe so call sites can stay branchless
type captureWriter struct {
mu sync.Mutex
f *os.File
start time.Time
closed bool
}
// returns (nil, nil) when CALLS_CAPTURE_DIR is unset
func newCaptureWriter(name string, magic [8]byte) (*captureWriter, error) {
dir := os.Getenv(captureEnvVar)
if dir == "" {
return nil, nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("capture: mkdir %s: %w", dir, err)
}
path := filepath.Join(dir, name)
f, err := os.Create(path)
if err != nil {
return nil, fmt.Errorf("capture: create %s: %w", path, err)
}
var hdr [16]byte
copy(hdr[0:8], magic[:])
hdr[8] = captureVer
if _, err := f.Write(hdr[:]); err != nil {
_ = f.Close()
return nil, fmt.Errorf("capture: write header: %w", err)
}
return &captureWriter{f: f, start: time.Now()}, nil
}
func (w *captureWriter) writePCM(ssrc uint32, data []byte) {
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return
}
var hdr [16]byte
binary.BigEndian.PutUint64(hdr[0:8], uint64(time.Since(w.start).Milliseconds()))
binary.BigEndian.PutUint32(hdr[8:12], ssrc)
binary.BigEndian.PutUint32(hdr[12:16], uint32(len(data)))
_, _ = w.f.Write(hdr[:])
_, _ = w.f.Write(data)
}
func (w *captureWriter) writeRTP(ssrc uint32, seq uint16, rtpTS uint32, pt uint8, data []byte) {
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return
}
var hdr [23]byte
binary.BigEndian.PutUint64(hdr[0:8], uint64(time.Since(w.start).Milliseconds()))
binary.BigEndian.PutUint32(hdr[8:12], ssrc)
binary.BigEndian.PutUint16(hdr[12:14], seq)
binary.BigEndian.PutUint32(hdr[14:18], rtpTS)
hdr[18] = pt
binary.BigEndian.PutUint32(hdr[19:23], uint32(len(data)))
_, _ = w.f.Write(hdr[:])
_, _ = w.f.Write(data)
}
func (w *captureWriter) close() {
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return
}
w.closed = true
_ = w.f.Close()
}
// one tg->xmpp record from a PCM capture file
type CapturedPCM struct {
TimestampMs int64
SSRC uint32
Data []byte
}
func ReadPCMCapture(r io.Reader) ([]CapturedPCM, error) {
if err := verifyMagic(r, magicPCM); err != nil {
return nil, err
}
var out []CapturedPCM
for {
var hdr [16]byte
_, err := io.ReadFull(r, hdr[:])
if errors.Is(err, io.EOF) {
return out, nil
}
if err != nil {
return nil, err
}
rec := CapturedPCM{
TimestampMs: int64(binary.BigEndian.Uint64(hdr[0:8])),
SSRC: binary.BigEndian.Uint32(hdr[8:12]),
}
length := binary.BigEndian.Uint32(hdr[12:16])
rec.Data = make([]byte, length)
if _, err := io.ReadFull(r, rec.Data); err != nil {
return nil, err
}
out = append(out, rec)
}
}
func verifyMagic(r io.Reader, want [8]byte) error {
var hdr [16]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return fmt.Errorf("capture: read header: %w", err)
}
var got [8]byte
copy(got[:], hdr[0:8])
if got != want {
return fmt.Errorf("capture: bad magic %q, want %q", string(got[:]), string(want[:]))
}
if hdr[8] != captureVer {
return fmt.Errorf("capture: unsupported version %d", hdr[8])
}
return nil
}