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:
@@ -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]))
|
||||
}
|
||||
Reference in New Issue
Block a user