feat: implement M1 teleport protocol sender

Stream a synthetic test pattern and silent PCM audio over the OBS
Teleport protocol:
- protocol: wire format (Header/ImageHeader/WaveHeader), BT.709 full
  range colour matrix, JPEG encode via turbojpeg cgo, WAVE packet builder
- output: TCP sender with per-connection buffered channels and drop-on-overflow
- discovery: multicast announce via peerdiscovery
- cmd: teleportfling CLI with flags, test-pattern frame generator

Verified end-to-end: OBS discovers and renders the stream with correct
colours and motion.
This commit is contained in:
2026-09-18 18:36:25 +01:00
parent b8b2ba8da1
commit 10fa72b228
11 changed files with 1438 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
package protocol
/*
#cgo pkg-config: libturbojpeg
#include <turbojpeg.h>
#include <stdlib.h>
static tjhandle new_compressor(void) {
return tj3Init(TJINIT_COMPRESS);
}
*/
import "C"
import (
"errors"
"image"
"image/color"
"runtime"
"unsafe"
)
// JPEGEncoder wraps a TurboJPEG tj3 compressor. Not safe for concurrent
// use; callers must serialise or use one per goroutine.
type JPEGEncoder struct {
ctx C.tjhandle
}
// NewJPEGEncoder initialises a TJ3 compressor. Must be freed via Close().
func NewJPEGEncoder() (*JPEGEncoder, error) {
ctx := C.new_compressor()
if ctx == nil {
return nil, errors.New("turbojpeg: tj3Init failed")
}
// Allow turbojpeg to allocate the output buffer itself (no NOREALLOC).
// This avoids the need to pin a Go output buffer and simplifies the
// API: the caller receives a Go-owned copy and the C buffer is freed.
return &JPEGEncoder{ctx: ctx}, nil
}
// Close destroys the underlying compressor.
func (e *JPEGEncoder) Close() {
if e.ctx != nil {
C.tj3Destroy(e.ctx)
e.ctx = nil
}
}
// Encode compresses img to JPEG at the given quality (1100).
//
// Supported source types:
// - *image.YCbCr compressed via the YUV path (420/422/444, matching
// obs-teleport exactly). SubsampleRatio is honoured.
// - *image.RGBA compressed as RGB via TJPF_RGBA, 444 subsampling.
// - any other image.Image converted to *image.RGBA then encoded as above.
//
// The returned byte slice is owned by the caller and must not be reused
// after the encoder is closed.
func (e *JPEGEncoder) Encode(img image.Image, quality int) ([]byte, error) {
if quality < 1 {
quality = 1
}
if quality > 100 {
quality = 100
}
C.tj3Set(e.ctx, C.TJPARAM_QUALITY, C.int(quality))
switch src := img.(type) {
case *image.YCbCr:
return e.encodeYCbCr(src)
case *image.RGBA:
return e.encodeRGBA(src)
default:
return e.encodeGeneric(img)
}
}
// encodeRGBA compresses a Go RGBA image (pixel layout [R,G,B,A] per 4 bytes).
// TJPF_RGBA tells turbojpeg the exact layout; colourspace is RGB.
func (e *JPEGEncoder) encodeRGBA(img *image.RGBA) ([]byte, error) {
w := img.Rect.Dx()
h := img.Rect.Dy()
subsamp := C.int(C.TJSAMP_444)
C.tj3Set(e.ctx, C.TJPARAM_SUBSAMP, subsamp)
C.tj3Set(e.ctx, C.TJPARAM_COLORSPACE, C.TJCS_RGB)
size := C.tj3JPEGBufSize(C.int(w), C.int(h), subsamp)
buf := make([]byte, int(size))
srcPtr := unsafe.Pointer(&img.Pix[0])
dstPtr := (*C.uchar)(&buf[0])
var pin runtime.Pinner
pin.Pin(srcPtr)
pin.Pin(dstPtr)
defer pin.Unpin()
jpegSize := size
rc := C.tj3Compress8(e.ctx, (*C.uchar)(srcPtr), C.int(w), 0, C.int(h), C.TJPF_RGBA, &dstPtr, &jpegSize)
if rc != 0 {
return nil, errors.New("turbojpeg RGBA compress failed")
}
return buf[:int(jpegSize)], nil
}
// encodeYCbCr compresses a YCbCr image via the turbojpeg YUV compressor.
// SubsampleRatio selects the chroma subsampling: 420, 422, or 444.
//
// turbojpeg's tj3CompressFromYUV8 expects the Y, Cb and Cr planes packed
// contiguously in a single buffer (Y, then Cb, then Cr). Go's image.YCbCr
// keeps them in three independent slices, so we copy them into a packed
// scratch buffer first — matching obs-teleport's ToJPEG behaviour.
func (e *JPEGEncoder) encodeYCbCr(img *image.YCbCr) ([]byte, error) {
w := img.Rect.Dx()
h := img.Rect.Dy()
var subsamp C.int
switch img.SubsampleRatio {
case image.YCbCrSubsampleRatio420:
subsamp = C.TJSAMP_420
case image.YCbCrSubsampleRatio422:
subsamp = C.TJSAMP_422
case image.YCbCrSubsampleRatio444:
subsamp = C.TJSAMP_444
default:
// Fall back to 444; this matches obs-teleport's default for non-standard ratios.
subsamp = C.TJSAMP_444
}
C.tj3Set(e.ctx, C.TJPARAM_SUBSAMP, subsamp)
C.tj3Set(e.ctx, C.TJPARAM_COLORSPACE, C.TJCS_YCbCr)
size := C.tj3JPEGBufSize(C.int(w), C.int(h), subsamp)
buf := make([]byte, int(size))
// Pack the planes contiguously for the compressor.
yuv := make([]byte, 0, len(img.Y)+len(img.Cb)+len(img.Cr))
yuv = append(yuv, img.Y...)
yuv = append(yuv, img.Cb...)
yuv = append(yuv, img.Cr...)
srcPtr := unsafe.Pointer(&yuv[0])
dstPtr := (*C.uchar)(&buf[0])
var pin runtime.Pinner
pin.Pin(srcPtr)
pin.Pin(dstPtr)
defer pin.Unpin()
jpegSize := size
rc := C.tj3CompressFromYUV8(e.ctx, (*C.uchar)(srcPtr), C.int(w), 1, C.int(h), &dstPtr, &jpegSize)
if rc != 0 {
return nil, errors.New("turbojpeg YUV compress failed")
}
return buf[:int(jpegSize)], nil
}
// encodeGeneric is the fallback for unsupported image types: draw into
// RGBA and encode via the RGBA path.
func (e *JPEGEncoder) encodeGeneric(img image.Image) ([]byte, error) {
b := img.Bounds()
rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, b2, a := img.At(x, y).RGBA()
rgba.SetRGBA(x-b.Min.X, y-b.Min.Y, color.RGBA{
R: uint8(r >> 8),
G: uint8(g >> 8),
B: uint8(b2 >> 8),
A: uint8(a >> 8),
})
}
}
return e.encodeRGBA(rgba)
}
+122
View File
@@ -0,0 +1,122 @@
package protocol
import (
"bytes"
"image"
"image/color"
"testing"
)
// minimalRGB builds a tiny RGBA test image with a known gradient.
func minimalRGB(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetRGBA(x, y, color.RGBA{R: uint8(x * 4), G: uint8(y * 4), B: 128, A: 255})
}
}
return img
}
// minimalYCbCr builds a 420 subsampled image with a vertical colour split.
func minimalYCbCr(w, h int) *image.YCbCr {
img := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, b := uint8(0), uint8(0), uint8(0)
switch {
case x < w/3:
r = 200
case x < w*2/3:
g = 200
default:
b = 200
}
img.Y[y*img.YStride+x], _, _ = color.RGBToYCbCr(r, g, b)
}
}
// Subsample chroma from the known dominant colours per region.
for y := 0; y < h/2; y++ {
for x := 0; x < w/2; x++ {
idx := (y*img.CStride + x)
switch {
case x < w/6:
_, cb, cr := color.RGBToYCbCr(200, 0, 0)
img.Cb[idx], img.Cr[idx] = cb, cr
case x < w*2/6:
_, cb, cr := color.RGBToYCbCr(0, 200, 0)
img.Cb[idx], img.Cr[idx] = cb, cr
default:
_, cb, cr := color.RGBToYCbCr(0, 0, 200)
img.Cb[idx], img.Cr[idx] = cb, cr
}
}
}
return img
}
// TestJPEGEncodeRGBA checks the RGBA path produces a decodable JPEG.
func TestJPEGEncodeRGBA(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
img := minimalRGB(64, 48)
buf, err := enc.Encode(img, 80)
if err != nil {
t.Fatalf("Encode: %v", err)
}
if len(buf) == 0 {
t.Fatal("empty JPEG output")
}
if !bytes.Equal(buf[:2], []byte{0xFF, 0xD8}) {
t.Errorf("bad JPEG SOI marker: %x", buf[:2])
}
if !bytes.Equal(buf[len(buf)-2:], []byte{0xFF, 0xD9}) {
t.Errorf("bad JPEG EOI marker")
}
}
// TestJPEGEncodeYCbCr checks the YUV path for all three subsampling modes.
func TestJPEGEncodeYCbCr(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
img := minimalYCbCr(64, 48)
if _, err := enc.Encode(img, 80); err != nil {
t.Fatalf("Encode (420): %v", err)
}
img422 := minimalYCbCr(64, 48)
img422.SubsampleRatio = image.YCbCrSubsampleRatio422
if _, err := enc.Encode(img422, 80); err != nil {
t.Fatalf("Encode (422): %v", err)
}
img444 := minimalYCbCr(64, 48)
img444.SubsampleRatio = image.YCbCrSubsampleRatio444
if _, err := enc.Encode(img444, 80); err != nil {
t.Fatalf("Encode (444): %v", err)
}
}
// TestJPEGEncodeQualityClamp ensures out-of-range qualities are clamped.
func TestJPEGEncodeQualityClamp(t *testing.T) {
enc, err := NewJPEGEncoder()
if err != nil {
t.Fatalf("NewJPEGEncoder: %v", err)
}
defer enc.Close()
img := minimalRGB(16, 16)
for _, q := range []int{-5, 0, 101, 200} {
if buf, err := enc.Encode(img, q); err != nil || len(buf) == 0 {
t.Errorf("quality %d: err=%v len=%d", q, err, len(buf))
}
}
}
+233
View File
@@ -0,0 +1,233 @@
// Package protocol implements the obs-teleport wire format.
//
// Every packet on the wire starts with a Header followed by the payload.
// Video packets are "JPEG" (Header + ImageHeader + JPEG bytes), audio
// packets are "WAVE" (Header + WaveHeader + raw PCM).
//
// All integers/float32s are little-endian, matching the reference
// implementation https://github.com/fzwoch/obs-teleport (GPL-2.0).
package protocol
import (
"encoding/binary"
"errors"
"io"
"math"
)
// Header is the fixed-size prefix of every packet.
//
// Type [4]byte | Timestamp uint64 | Size int32 (all little-endian)
type Header struct {
Type [4]byte
Timestamp uint64
Size int32
}
// ImageHeader is written after Header for video packets. It carries the
// color parameters OBS derives from its rendering pipeline (BT.709/FULL in
// teleportfling, but the receiver happily forwards whatever we send).
//
// ColorMatrix [16]float32 | ColorRangeMin [3]float32 | ColorRangeMax [3]float32
type ImageHeader struct {
ColorMatrix [16]float32
ColorRangeMin [3]float32
ColorRangeMax [3]float32
}
// WaveHeader is written after Header for audio packets. Format uses the
// OBS AUDIO_FORMAT_* enum values (see audioFormat_* consts below); the
// receiver feeds these straight into obs_source_output_audio.
//
// Format int32 | SampleRate int32 | Speakers int32 | Frames int32
type WaveHeader struct {
Format int32
SampleRate int32
Speakers int32
Frames int32
}
// AnnouncePayload is the JSON document broadcast on the multicast discovery
// group. It tells OBS receivers where to connect and what the pipe carries.
type AnnouncePayload struct {
Name string
Port int
AudioAndVideo bool
Version string
Address string `json:",omitempty"`
}
// Packet type identifiers used in Header.Type.
var (
VideoType = [4]byte{'J', 'P', 'E', 'G'}
AudioType = [4]byte{'W', 'A', 'V', 'E'}
)
// OBS audio output format enum values written into WaveHeader.Format. Only
// the interleaved (non-planar) forms appear on the wire; the reference
// collapses the planar forms to these when packetizing. See obs-audio.h.
const (
AudioFormatU8 int32 = 1 // unsigned 8-bit
AudioFormatS16 int32 = 2 // signed 16-bit little-endian
AudioFormatS32 int32 = 3 // signed 32-bit little-endian
AudioFormatF32 int32 = 4 // IEEE-754 float little-endian
)
// headerSize / imageHeaderSize / waveHeaderSize are the fixed wire sizes.
const (
headerSize = 16 // Type[4] + Timestamp[8] + Size[4]
imageHeaderSize = 16*4 + 3*4 + 3*4 // 16 float32s + 6 float32s
waveHeaderSize = 4 * 4 // 4 int32s
)
// DefaultBT709Full returns the ImageHeader describing a BT.709, full-range
// YCbCr stream, matching obs-teleport's fallback (video_format_get_parameters
// for VIDEO_CS_709 + VIDEO_RANGE_FULL, 8-bit). The ColorMatrix is the YUV→RGB
// conversion matrix OBS applies when rendering the frame.
func DefaultBT709Full() ImageHeader {
var m [16]float32
copy(m[:], bt709FullMatrix[:])
return ImageHeader{
ColorMatrix: m,
ColorRangeMin: [3]float32{0, 0, 0},
ColorRangeMax: [3]float32{1, 1, 1},
}
}
// bt709FullMatrix is the YUV→RGB matrix produced by OBS's
// video_format_get_parameters(VIDEO_CS_709, VIDEO_RANGE_FULL) for 8-bit
// video. The trailing column is the chroma-offset term that centres
// Cb/Cr at 0.5.
var bt709FullMatrix = [16]float32{
1, 0, 1.5748, -0.790488,
1, -0.187324, -0.468124, 0.329009,
1, 1.8556, 0, -0.931439,
0, 0, 0, 1,
}
// WritePacket serializes the full packet: Header, optional ImageHeader or
// WaveHeader, then the payload bytes. It returns the wire slice.
//
// image/wave selects which sub-header is emitted; passing both is an error,
// passing neither (with payload) produces a header-only packet. A nil header
// and the empty type is used by tests to size check framing.
func WritePacket(h Header, img *ImageHeader, wave *WaveHeader, payload []byte) ([]byte, error) {
if img != nil && wave != nil {
return nil, errors.New("protocol: both image and wave headers set")
}
out := make([]byte, 0, headerSize+len(payload)+imageHeaderSize)
var scratch [headerSize]byte
binary.LittleEndian.PutUint32(scratch[0:4], encodeType(h.Type))
binary.LittleEndian.PutUint64(scratch[4:12], h.Timestamp)
binary.LittleEndian.PutUint32(scratch[12:16], uint32(h.Size))
out = append(out, scratch[:]...)
if img != nil {
out = appendImageHeader(out, img)
}
if wave != nil {
out = appendWaveHeader(out, wave)
}
out = append(out, payload...)
return out, nil
}
// ReadPacket reads one complete packet from r (Header + sub-header + payload)
// and returns the payload bytes plus the parsed headers.
func ReadPacket(r io.Reader) (Header, *ImageHeader, *WaveHeader, []byte, error) {
var hdr [headerSize]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return Header{}, nil, nil, nil, err
}
h := Header{
Type: [4]byte{hdr[0], hdr[1], hdr[2], hdr[3]},
Timestamp: binary.LittleEndian.Uint64(hdr[4:12]),
Size: int32(binary.LittleEndian.Uint32(hdr[12:16])),
}
if h.Size < 0 || int64(h.Size)+imageHeaderSize > 1<<30 {
return Header{}, nil, nil, nil, errors.New("protocol: invalid packet size")
}
var img *ImageHeader
var wave *WaveHeader
switch h.Type {
case VideoType:
var ih imageHeaderBytes
if _, err := io.ReadFull(r, ih[:]); err != nil {
return Header{}, nil, nil, nil, err
}
img = &ImageHeader{}
decodeImageHeader(ih, img)
case AudioType:
var wh waveHeaderBytes
if _, err := io.ReadFull(r, wh[:]); err != nil {
return Header{}, nil, nil, nil, err
}
wave = &WaveHeader{}
decodeWaveHeader(wh, wave)
default:
return Header{}, nil, nil, nil, errors.New("protocol: unknown packet type")
}
payload := make([]byte, h.Size)
if _, err := io.ReadFull(r, payload); err != nil {
return Header{}, nil, nil, nil, err
}
return h, img, wave, payload, nil
}
func encodeType(t [4]byte) uint32 {
return uint32(t[0]) | uint32(t[1])<<8 | uint32(t[2])<<16 | uint32(t[3])<<24
}
type imageHeaderBytes [imageHeaderSize]byte
type waveHeaderBytes [waveHeaderSize]byte
func appendImageHeader(dst []byte, img *ImageHeader) []byte {
var b imageHeaderBytes
for i, f := range img.ColorMatrix {
binary.LittleEndian.PutUint32(b[i*4:], math.Float32bits(f))
}
off := 16 * 4
for i, f := range img.ColorRangeMin {
binary.LittleEndian.PutUint32(b[off+i*4:], math.Float32bits(f))
}
off += 3 * 4
for i, f := range img.ColorRangeMax {
binary.LittleEndian.PutUint32(b[off+i*4:], math.Float32bits(f))
}
return append(dst, b[:]...)
}
func decodeImageHeader(b imageHeaderBytes, img *ImageHeader) {
for i := range img.ColorMatrix {
img.ColorMatrix[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[i*4:]))
}
off := 16 * 4
for i := range img.ColorRangeMin {
img.ColorRangeMin[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[off+i*4:]))
}
off += 3 * 4
for i := range img.ColorRangeMax {
img.ColorRangeMax[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[off+i*4:]))
}
}
func appendWaveHeader(dst []byte, w *WaveHeader) []byte {
var b waveHeaderBytes
binary.LittleEndian.PutUint32(b[0:4], uint32(w.Format))
binary.LittleEndian.PutUint32(b[4:8], uint32(w.SampleRate))
binary.LittleEndian.PutUint32(b[8:12], uint32(w.Speakers))
binary.LittleEndian.PutUint32(b[12:16], uint32(w.Frames))
return append(dst, b[:]...)
}
func decodeWaveHeader(b waveHeaderBytes, w *WaveHeader) {
w.Format = int32(binary.LittleEndian.Uint32(b[0:4]))
w.SampleRate = int32(binary.LittleEndian.Uint32(b[4:8]))
w.Speakers = int32(binary.LittleEndian.Uint32(b[8:12]))
w.Frames = int32(binary.LittleEndian.Uint32(b[12:16]))
}
+115
View File
@@ -0,0 +1,115 @@
package protocol
import (
"bytes"
"testing"
)
// TestWriteReadVideoPacket verifies the full video packet round-trip through
// WritePacket/ReadPacket, including the little-endian float32s in the image
// header and the exact byte layout (Header + ImageHeader + payload).
func TestWriteReadVideoPacket(t *testing.T) {
img := DefaultBT709Full()
img.ColorMatrix[0] = 0.12345
img.ColorRangeMax[2] = 0.9999
h := Header{Type: VideoType, Timestamp: 1_700_000_000, Size: 5}
wire, err := WritePacket(h, &img, nil, []byte("hello"))
if err != nil {
t.Fatalf("WritePacket: %v", err)
}
gotH, gotImg, gotWave, payload, err := ReadPacket(bytes.NewReader(wire))
if err != nil {
t.Fatalf("ReadPacket: %v", err)
}
if gotH != h {
t.Errorf("header mismatch: got %+v want %+v", gotH, h)
}
if gotImg == nil || gotWave != nil {
t.Fatalf("expected image header and no wave header")
}
if *gotImg != img {
t.Errorf("image header mismatch:\n got %+v\nwant %+v", *gotImg, img)
}
if string(payload) != "hello" {
t.Errorf("payload mismatch: got %q", payload)
}
}
// TestVideoPacketWireSize locks the on-wire size of a video packet to the
// reference layout: 16-byte Header + 80-byte ImageHeader + payload.
func TestVideoPacketWireSize(t *testing.T) {
const payload = 10
ih := DefaultBT709Full()
h := Header{Type: VideoType, Size: payload}
wire, err := WritePacket(h, &ih, nil, make([]byte, payload))
if err != nil {
t.Fatalf("WritePacket: %v", err)
}
if len(wire) != headerSize+imageHeaderSize+payload {
t.Errorf("wire size = %d, want %d", len(wire), headerSize+imageHeaderSize+payload)
}
}
// TestWriteReadAudioPacket round-trips a WAVE packet and checks that the
// WaveHeader fields arrive intact.
func TestWriteReadAudioPacket(t *testing.T) {
pcm := make([]byte, 480*2*4) // 480 frames, 2 ch, 4 bytes float
h := Header{Type: AudioType, Timestamp: 42, Size: int32(len(pcm))}
w := WaveHeader{Format: AudioFormatF32, SampleRate: 48000, Speakers: 2, Frames: 480}
wire, err := WritePacket(h, nil, &w, pcm)
if err != nil {
t.Fatalf("WritePacket: %v", err)
}
if len(wire) != headerSize+waveHeaderSize+len(pcm) {
t.Errorf("wire size = %d, want %d", len(wire), headerSize+waveHeaderSize+len(pcm))
}
_, gotImg, gotWave, payload, err := ReadPacket(bytes.NewReader(wire))
if err != nil {
t.Fatalf("ReadPacket: %v", err)
}
if gotWave == nil || gotImg != nil {
t.Fatalf("expected wave header and no image header")
}
if *gotWave != w {
t.Errorf("wave header mismatch: got %+v want %+v", *gotWave, w)
}
if !bytes.Equal(payload, pcm) {
t.Errorf("pcm payload mismatch")
}
}
// TestBuildWavePacket checks the convenience builder enforces PCM length
// vs. the declared format/speakers/frames.
func TestBuildWavePacket(t *testing.T) {
good := make([]byte, 480*2*4)
if _, err := BuildWavePacket(1, AudioFormatF32, 48000, 2, 480, good); err != nil {
t.Errorf("valid packet rejected: %v", err)
}
if _, err := BuildWavePacket(1, AudioFormatF32, 48000, 2, 480, good[:len(good)-1]); err == nil {
t.Error("truncated pcm accepted")
}
u8 := make([]byte, 480) // 1 ch, 480 frames, 1 byte
if _, err := BuildWavePacket(1, AudioFormatU8, 48000, 1, 480, u8); err != nil {
t.Errorf("u8 packet rejected: %v", err)
}
}
// TestUnsupportedPacketType ensures ReadPacket rejects unknown markers by
// feeding it a raw 16-byte header with a non-"JPEG"/"WAVE" type.
func TestUnsupportedPacketType(t *testing.T) {
var wire [headerSize]byte
wire[0] = 'Z'
wire[1] = 'Z'
wire[2] = 'Z'
wire[3] = 'Z'
if _, _, _, _, err := ReadPacket(bytes.NewReader(wire[:])); err == nil {
t.Error("unknown packet type accepted")
}
}
+80
View File
@@ -0,0 +1,80 @@
// Package protocol implements the obs-teleport wire format.
//
// This file builds the audio ("WAVE") side of the protocol: Header +
// WaveHeader + raw interleaved PCM, byte-for-byte compatible with the
// reference implementation (GPL-2.0).
package protocol
// BuildWavePacket assembles a complete ""WAVE"" wire packet:
// Header + WaveHeader + interleaved PCM payload.
//
// pcm is already interleaved (L,R,L,R,… for stereo). format must be one of
// the AudioFormat* constants. frames is the PCM frame count (samples per
// channel). The returned slice is a freshly allocated buffer safe to hand to
// the network.
func BuildWavePacket(timestamp uint64, format int32, sampleRate, speakers, frames int32, pcm []byte) ([]byte, error) {
expected := int64(speakers) * int64(frames) * int64(bytesPerSample(format))
if int64(len(pcm)) != expected {
return nil, errBadPCMLength{got: len(pcm), want: int(expected)}
}
h := Header{
Type: AudioType,
Timestamp: timestamp,
Size: int32(len(pcm)),
}
wave := WaveHeader{
Format: format,
SampleRate: sampleRate,
Speakers: speakers,
Frames: frames,
}
return WritePacket(h, nil, &wave, pcm)
}
// bytesPerSample maps an AudioFormat* value to its size in bytes.
func bytesPerSample(format int32) int {
switch format {
case AudioFormatU8:
return 1
case AudioFormatS16:
return 2
case AudioFormatS32, AudioFormatF32:
return 4
default:
return 0
}
}
// errBadPCMLength is returned when pcm length does not match the declared
// format/speakers/frames.
type errBadPCMLength struct{ got, want int }
func (e errBadPCMLength) Error() string {
return "protocol: pcm length mismatch" +
": got " + itoa(e.got) + " bytes, want " + itoa(e.want)
}
// itoa is a tiny local int→string helper to avoid importing strconv in the
// hot path callers.
func itoa(v int) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}