install go2rtc on bob

This commit is contained in:
2026-04-04 19:36:14 +02:00
parent f0b56e63d1
commit ccf88187b8
537 changed files with 69213 additions and 0 deletions
@@ -0,0 +1,11 @@
## StateChange
1. offer = pc.CreateOffer()
2. pc.SetLocalDescription(offer)
3. OnICEGatheringStateChange: gathering
4. OnSignalingStateChange: have-local-offer
*. OnICEGatheringStateChange: complete
5. pc.SetRemoteDescription(answer)
6. OnSignalingStateChange: stable
7. OnICEConnectionStateChange: checking
8. OnICEConnectionStateChange: connected
+316
View File
@@ -0,0 +1,316 @@
package webrtc
import (
"fmt"
"net"
"slices"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/xnet"
"github.com/pion/ice/v4"
"github.com/pion/interceptor"
"github.com/pion/webrtc/v4"
)
// ReceiveMTU = Ethernet MTU (1500) - IP Header (20) - UDP Header (8)
// https://ffmpeg.org/ffmpeg-all.html#Muxer
const ReceiveMTU = 1472
func NewAPI() (*webrtc.API, error) {
return NewServerAPI("", "", nil)
}
type Filters struct {
Candidates []string `yaml:"candidates"`
Loopback bool `yaml:"loopback"`
Interfaces []string `yaml:"interfaces"`
IPs []string `yaml:"ips"`
Networks []string `yaml:"networks"`
UDPPorts []uint16 `yaml:"udp_ports"`
}
func (f *Filters) Network(protocol string) string {
if f == nil || f.Networks == nil {
return protocol
}
v4 := slices.Contains(f.Networks, protocol+"4")
v6 := slices.Contains(f.Networks, protocol+"6")
if v4 && v6 {
return protocol
} else if v4 {
return protocol + "4"
} else if v6 {
return protocol + "6"
}
return ""
}
func (f *Filters) NetIPs() (ips []net.IP) {
itfs, _ := net.Interfaces()
for _, itf := range itfs {
if itf.Flags&net.FlagUp == 0 {
continue
}
if !f.IncludeLoopback() && itf.Flags&net.FlagLoopback != 0 {
continue
}
if !f.InterfaceFilter(itf.Name) {
continue
}
addrs, _ := itf.Addrs()
for _, addr := range addrs {
ip := parseNetAddr(addr)
if ip == nil || !f.IPFilter(ip) {
continue
}
ips = append(ips, ip)
}
}
return
}
func parseNetAddr(addr net.Addr) net.IP {
switch addr := addr.(type) {
case *net.IPNet:
return addr.IP
case *net.IPAddr:
return addr.IP
}
return nil
}
func (f *Filters) IncludeLoopback() bool {
return f != nil && f.Loopback
}
func (f *Filters) InterfaceFilter(name string) bool {
return f == nil || f.Interfaces == nil || slices.Contains(f.Interfaces, name)
}
func (f *Filters) IPFilter(ip net.IP) bool {
return f == nil || f.IPs == nil || core.Contains(f.IPs, ip.String())
}
func NewServerAPI(network, address string, filters *Filters) (*webrtc.API, error) {
// for debug logs add to env: `PION_LOG_DEBUG=all`
m := &webrtc.MediaEngine{}
//if err := m.RegisterDefaultCodecs(); err != nil {
// return nil, err
//}
if err := RegisterDefaultCodecs(m); err != nil {
return nil, err
}
i := &interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptors(m, i); err != nil {
return nil, err
}
s := webrtc.SettingEngine{}
// fix https://github.com/pion/webrtc/pull/2407
s.SetDTLSInsecureSkipHelloVerify(true)
if filters != nil && filters.Loopback {
s.SetIncludeLoopbackCandidate(true)
}
var interfaceFilter func(name string) bool
if filters != nil && filters.Interfaces != nil {
interfaceFilter = func(name string) bool {
return core.Contains(filters.Interfaces, name)
}
} else {
// default interfaces - all, except loopback
}
s.SetInterfaceFilter(interfaceFilter)
var ipFilter func(ip net.IP) bool
if filters != nil && filters.IPs != nil {
ipFilter = func(ip net.IP) bool {
return core.Contains(filters.IPs, ip.String())
}
} else {
// try filter all Docker-like interfaces
ipFilter = func(ip net.IP) bool {
return !xnet.Docker.Contains(ip)
}
// if there are no such interfaces - disable the filter
// the user will need to enable port forwarding
if nets, _ := xnet.IPNets(ipFilter); len(nets) == 0 {
ipFilter = nil
}
}
s.SetIPFilter(ipFilter)
var networkTypes []webrtc.NetworkType
if filters != nil && filters.Networks != nil {
for _, s := range filters.Networks {
if networkType, err := webrtc.NewNetworkType(s); err == nil {
networkTypes = append(networkTypes, networkType)
}
}
} else {
// default network types - all
networkTypes = []webrtc.NetworkType{
webrtc.NetworkTypeUDP4, webrtc.NetworkTypeUDP6,
webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6,
}
}
s.SetNetworkTypes(networkTypes)
if filters != nil && len(filters.UDPPorts) == 2 {
_ = s.SetEphemeralUDPPortRange(filters.UDPPorts[0], filters.UDPPorts[1])
}
// If you don't specify an address, this won't cause an error.
// Connections can still be established using random UDP addresses.
if address != "" {
// Both newMux functions respect filters and do not raise an error
// if the port cannot be listened on.
if network == "" || network == "tcp" {
tcpMux := newTCPMux(address, filters)
s.SetICETCPMux(tcpMux)
}
if network == "" || network == "udp" {
udpMux := newUDPMux(address, filters)
s.SetICEUDPMux(udpMux)
}
}
return webrtc.NewAPI(
webrtc.WithMediaEngine(m),
webrtc.WithInterceptorRegistry(i),
webrtc.WithSettingEngine(s),
), nil
}
// OnNewListener temporary ugly solution for log
var OnNewListener = func(ln any) {}
func newTCPMux(address string, filters *Filters) ice.TCPMux {
networkTCP := filters.Network("tcp") // tcp or tcp4 or tcp6
if ln, _ := net.Listen(networkTCP, address); ln != nil {
OnNewListener(ln)
return webrtc.NewICETCPMux(nil, ln, 8)
}
return nil
}
func newUDPMux(address string, filters *Filters) ice.UDPMux {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil
}
// UDPMux should not listening on unspecified address.
// So we will create a listener on all available interfaces.
// We can't use ice.NewMultiUDPMuxFromPort, because it sometimes crashes with an error:
// listen udp [***]:8555: bind: cannot assign requested address
var addrs []string
if host == "" {
for _, ip := range filters.NetIPs() {
addrs = append(addrs, fmt.Sprintf("%s:%s", ip, port))
}
} else {
addrs = []string{address}
}
networkUDP := filters.Network("udp") // udp or udp4 or udp6
var muxes []ice.UDPMux
for _, addr := range addrs {
if ln, _ := net.ListenPacket(networkUDP, addr); ln != nil {
OnNewListener(ln)
mux := ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: ln})
muxes = append(muxes, mux)
}
}
switch len(muxes) {
case 0:
return nil
case 1:
return muxes[0]
}
return ice.NewMultiUDPMuxDefault(muxes...)
}
func RegisterDefaultCodecs(m *webrtc.MediaEngine) error {
for _, codec := range []webrtc.RTPCodecParameters{
{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2, SDPFmtpLine: "minptime=10;useinbandfec=1",
},
PayloadType: 101, //111,
},
{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypePCMU, ClockRate: 8000,
},
PayloadType: 0,
},
{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypePCMA, ClockRate: 8000,
},
PayloadType: 8,
},
} {
if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeAudio); err != nil {
return err
}
}
videoRTCPFeedback := []webrtc.RTCPFeedback{
{"goog-remb", ""},
{"ccm", "fir"},
{"nack", ""},
{"nack", "pli"},
}
for _, codec := range []webrtc.RTPCodecParameters{
{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f",
RTCPFeedback: videoRTCPFeedback,
},
PayloadType: 96, // Chrome v110 - PayloadType: 102
},
{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
RTCPFeedback: videoRTCPFeedback,
},
PayloadType: 97, // Chrome v110 - PayloadType: 106
},
{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: 90000,
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640032",
RTCPFeedback: videoRTCPFeedback,
},
PayloadType: 98, // Chrome v110 - PayloadType: 112
},
// macOS Safari 15.1
{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH265,
ClockRate: 90000,
RTCPFeedback: videoRTCPFeedback,
},
PayloadType: 100,
},
} {
if err := m.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,145 @@
package webrtc
import (
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/sdp/v3"
"github.com/pion/webrtc/v4"
)
func (c *Conn) CreateOffer(medias []*core.Media) (string, error) {
// 1. Create transeivers with proper kind and direction
for _, media := range medias {
var err error
switch media.Direction {
case core.DirectionRecvonly:
_, err = c.pc.AddTransceiverFromKind(
webrtc.NewRTPCodecType(media.Kind),
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly},
)
case core.DirectionSendonly:
_, err = c.pc.AddTransceiverFromTrack(
NewTrack(media.Kind),
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly},
)
case core.DirectionSendRecv:
// default transceiver is sendrecv
_, err = c.pc.AddTransceiverFromTrack(NewTrack(media.Kind))
default:
// Nest cameras require data channel
_, err = c.pc.CreateDataChannel(media.Kind, nil)
}
if err != nil {
return "", err
}
}
// 2. Create local offer
desc, err := c.pc.CreateOffer(nil)
if err != nil {
return "", err
}
// 3. Start gathering phase
if err = c.pc.SetLocalDescription(desc); err != nil {
return "", err
}
return c.pc.LocalDescription().SDP, nil
}
func (c *Conn) CreateCompleteOffer(medias []*core.Media) (string, error) {
if _, err := c.CreateOffer(medias); err != nil {
return "", err
}
<-webrtc.GatheringCompletePromise(c.pc)
return c.pc.LocalDescription().SDP, nil
}
func (c *Conn) SetAnswer(answer string) (err error) {
desc := webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: fakeFormatsInAnswer(c.pc.LocalDescription().SDP, answer),
}
if err = c.pc.SetRemoteDescription(desc); err != nil {
return err
}
sd := &sdp.SessionDescription{}
if err = sd.Unmarshal([]byte(answer)); err != nil {
return err
}
c.Medias = UnmarshalMedias(sd.MediaDescriptions)
return nil
}
// fakeFormatsInAnswer - fix pion bug with remote SDP parsing:
// pion will process formats only from first media of each kind
// so we add all formats from first offer media to the first answer media
func fakeFormatsInAnswer(offer, answer string) string {
sd2 := &sdp.SessionDescription{}
if err := sd2.Unmarshal([]byte(answer)); err != nil {
return answer
}
// check if answer has recvonly audio
var ok bool
for _, md2 := range sd2.MediaDescriptions {
if md2.MediaName.Media == "audio" {
if _, ok = md2.Attribute("recvonly"); ok {
break
}
}
}
if !ok {
return answer
}
sd1 := &sdp.SessionDescription{}
if err := sd1.Unmarshal([]byte(offer)); err != nil {
return answer
}
var formats []string
var attrs []sdp.Attribute
for _, md1 := range sd1.MediaDescriptions {
if md1.MediaName.Media == "audio" {
for _, attr := range md1.Attributes {
switch attr.Key {
case "rtpmap", "fmtp", "rtcp-fb", "extmap":
attrs = append(attrs, attr)
}
}
formats = md1.MediaName.Formats
break
}
}
for _, md2 := range sd2.MediaDescriptions {
if md2.MediaName.Media == "audio" {
for _, attr := range md2.Attributes {
switch attr.Key {
case "rtpmap", "fmtp", "rtcp-fb", "extmap":
default:
attrs = append(attrs, attr)
}
}
md2.MediaName.Formats = formats
md2.Attributes = attrs
break
}
}
b, err := sd2.Marshal()
if err != nil {
return answer
}
return string(b)
}
@@ -0,0 +1,118 @@
package webrtc
import (
"testing"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient(t *testing.T) {
api, err := NewAPI()
require.Nil(t, err)
pc, err := api.NewPeerConnection(webrtc.Configuration{})
require.Nil(t, err)
prod := NewConn(pc)
medias := []*core.Media{
{Kind: core.KindVideo, Direction: core.DirectionRecvonly},
{Kind: core.KindAudio, Direction: core.DirectionRecvonly},
{Kind: core.KindAudio, Direction: core.DirectionSendonly},
}
offer, err := prod.CreateOffer(medias)
require.Nil(t, err)
assert.NotEmpty(t, offer)
require.Len(t, prod.pc.GetReceivers(), 2)
require.Len(t, prod.pc.GetSenders(), 1)
answer := `v=0
o=- 1934370540648269799 1678277622 IN IP4 0.0.0.0
s=-
t=0 0
a=fingerprint:sha-256 77:8C:9A:62:51:81:69:EA:4E:BE:93:6B:4E:DF:51:D2:2F:E3:DF:E7:F4:8A:18:1A:C0:74:FA:AE:B8:98:29:9B
a=extmap-allow-mixed
a=group:BUNDLE 0 1 2
m=video 9 UDP/TLS/RTP/SAVPF 97
c=IN IP4 0.0.0.0
a=setup:active
a=mid:0
a=ice-ufrag:xxx
a=ice-pwd:xxx
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:97 H264/90000
a=fmtp:97 packetization-mode=1;profile-level-id=42e01f
a=extmap:1 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=ssrc:2815449682 cname:go2rtc
a=ssrc:2815449682 msid:go2rtc video
a=ssrc:2815449682 mslabel:go2rtc
a=ssrc:2815449682 label:video
a=msid:go2rtc video
a=sendonly
m=audio 9 UDP/TLS/RTP/SAVPF 8
c=IN IP4 0.0.0.0
a=setup:active
a=mid:1
a=ice-ufrag:xxx
a=ice-pwd:xxx
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:8 PCMA/8000
a=extmap:1 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=ssrc:1392166302 cname:go2rtc
a=ssrc:1392166302 msid:go2rtc audio
a=ssrc:1392166302 mslabel:go2rtc
a=ssrc:1392166302 label:audio
a=msid:go2rtc audio
a=sendonly
m=audio 9 UDP/TLS/RTP/SAVPF 0
c=IN IP4 0.0.0.0
a=setup:active
a=mid:2
a=ice-ufrag:xxx
a=ice-pwd:xxx
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:0 PCMU/8000
a=extmap:1 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=recvonly
`
err = prod.SetAnswer(answer)
require.Nil(t, err)
sender := prod.pc.GetSenders()[0]
caps := webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypePCMU,
ClockRate: 8000,
Channels: 0,
}
track := sender.Track()
track, err = webrtc.NewTrackLocalStaticRTP(caps, track.ID(), track.StreamID())
require.Nil(t, err)
err = sender.ReplaceTrack(track)
require.Nil(t, err)
}
func TestUnmarshalICEServers(t *testing.T) {
s := `[{"credential":"xxx","urls":"xxx","username":"xxx"},{"credential":null,"urls":"xxx","username":null}]`
servers, err := UnmarshalICEServers([]byte(s))
require.Nil(t, err)
require.Len(t, servers, 2)
require.Equal(t, []string{"xxx"}, servers[0].URLs)
s = `[{"urls":"xxx"},{"urls":["yyy","zzz"]}]`
servers, err = UnmarshalICEServers([]byte(s))
require.Nil(t, err)
require.Len(t, servers, 2)
require.Equal(t, []string{"xxx"}, servers[0].URLs)
require.Equal(t, []string{"yyy", "zzz"}, servers[1].URLs)
}
+220
View File
@@ -0,0 +1,220 @@
package webrtc
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/rtcp"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
type Conn struct {
core.Connection
core.Listener
Mode core.Mode `json:"mode"`
pc *webrtc.PeerConnection
offer string
closed core.Waiter
}
func NewConn(pc *webrtc.PeerConnection) *Conn {
c := &Conn{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "webrtc",
Transport: pc,
},
pc: pc,
}
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
// last candidate will be empty
if candidate != nil {
c.Fire(candidate)
}
})
pc.OnDataChannel(func(channel *webrtc.DataChannel) {
c.Fire(channel)
})
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
if state != webrtc.ICEConnectionStateChecking {
return
}
pc.SCTP().Transport().ICETransport().OnSelectedCandidatePairChange(
func(pair *webrtc.ICECandidatePair) {
// fix situation when candidate pair changes multiple times
if i := strings.IndexByte(c.Protocol, '+'); i > 0 {
c.Protocol = c.Protocol[:i]
}
c.Protocol += "+" + pair.Remote.Protocol.String()
c.RemoteAddr = fmt.Sprintf(
"%s:%d %s", sanitizeIP6(pair.Remote.Address), pair.Remote.Port, pair.Remote.Typ,
)
if pair.Remote.RelatedAddress != "" {
c.RemoteAddr += fmt.Sprintf(
" %s:%d", sanitizeIP6(pair.Remote.RelatedAddress), pair.Remote.RelatedPort,
)
}
},
)
})
pc.OnTrack(func(remote *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
media, codec := c.getMediaCodec(remote)
if media == nil {
return
}
track, err := c.GetTrack(media, codec)
if err != nil {
return
}
switch c.Mode {
case core.ModePassiveProducer, core.ModeActiveProducer:
// replace the theoretical list of codecs with the actual list of codecs
if len(media.Codecs) > 1 {
media.Codecs = []*core.Codec{codec}
}
}
if c.Mode == core.ModePassiveProducer && remote.Kind() == webrtc.RTPCodecTypeVideo {
go func() {
pkts := []rtcp.Packet{&rtcp.PictureLossIndication{MediaSSRC: uint32(remote.SSRC())}}
for range time.NewTicker(time.Second * 2).C {
if err := pc.WriteRTCP(pkts); err != nil {
return
}
}
}()
}
for {
b := make([]byte, ReceiveMTU)
n, _, err := remote.Read(b)
if err != nil {
return
}
c.Recv += n
packet := &rtp.Packet{}
if err := packet.Unmarshal(b[:n]); err != nil {
return
}
if len(packet.Payload) == 0 {
continue
}
track.WriteRTP(packet)
}
})
// OK connection:
// 15:01:46 ICE connection state changed: checking
// 15:01:46 peer connection state changed: connected
// 15:01:54 peer connection state changed: disconnected
// 15:02:20 peer connection state changed: failed
//
// Fail connection:
// 14:53:08 ICE connection state changed: checking
// 14:53:39 peer connection state changed: failed
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
c.Fire(state)
switch state {
case webrtc.PeerConnectionStateConnected:
for _, sender := range c.Senders {
sender.Start()
}
case webrtc.PeerConnectionStateDisconnected, webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed:
// disconnect event comes earlier, than failed
// but it comes only for success connections
_ = c.Close()
}
})
return c
}
func (c *Conn) MarshalJSON() ([]byte, error) {
return json.Marshal(c.Connection)
}
func (c *Conn) Close() error {
c.closed.Done(nil)
return c.pc.Close()
}
func (c *Conn) AddCandidate(candidate string) error {
// pion uses only candidate value from json/object candidate struct
return c.pc.AddICECandidate(webrtc.ICECandidateInit{Candidate: candidate})
}
func (c *Conn) GetSenderTrack(mid string) *Track {
if tr := c.getTranseiver(mid); tr != nil {
if s := tr.Sender(); s != nil {
if t := s.Track().(*Track); t != nil {
return t
}
}
}
return nil
}
func (c *Conn) getTranseiver(mid string) *webrtc.RTPTransceiver {
for _, tr := range c.pc.GetTransceivers() {
if tr.Mid() == mid {
return tr
}
}
return nil
}
func (c *Conn) getMediaCodec(remote *webrtc.TrackRemote) (*core.Media, *core.Codec) {
for _, tr := range c.pc.GetTransceivers() {
// search Transeiver for this TrackRemote
if tr.Receiver() == nil || tr.Receiver().Track() != remote {
continue
}
// search Media for this MID
for _, media := range c.Medias {
if media.ID != tr.Mid() || media.Direction != core.DirectionRecvonly {
continue
}
// search codec for this PayloadType
for _, codec := range media.Codecs {
if codec.PayloadType != uint8(remote.PayloadType()) {
continue
}
return media, codec
}
}
}
// fix moment when core.ModePassiveProducer or core.ModeActiveProducer
// sends new codec with new payload type to same media
// check GetTrack
panic(core.Caller())
return nil, nil
}
func sanitizeIP6(host string) string {
if strings.IndexByte(host, ':') > 0 {
return "[" + host + "]"
}
return host
}
@@ -0,0 +1,90 @@
package webrtc
import (
"errors"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/h264"
"github.com/AlexxIT/go2rtc/pkg/h265"
"github.com/AlexxIT/go2rtc/pkg/pcm"
"github.com/pion/rtp"
)
func (c *Conn) GetMedias() []*core.Media {
return WithResampling(c.Medias)
}
func (c *Conn) AddTrack(media *core.Media, codec *core.Codec, track *core.Receiver) error {
core.Assert(media.Direction == core.DirectionSendonly)
for _, sender := range c.Senders {
if sender.Codec == codec {
sender.Bind(track)
return nil
}
}
switch c.Mode {
case core.ModePassiveConsumer: // video/audio for browser
case core.ModeActiveProducer: // go2rtc as WebRTC client (backchannel)
case core.ModePassiveProducer: // WebRTC/WHIP
default:
panic(core.Caller())
}
localTrack := c.GetSenderTrack(media.ID)
if localTrack == nil {
return errors.New("webrtc: can't get track")
}
payloadType := codec.PayloadType
sender := core.NewSender(media, codec)
sender.Handler = func(packet *rtp.Packet) {
c.Send += packet.MarshalSize()
//important to send with remote PayloadType
_ = localTrack.WriteRTP(payloadType, packet)
}
switch track.Codec.Name {
case core.CodecH264:
sender.Handler = h264.RTPPay(1200, sender.Handler)
if track.Codec.IsRTP() {
sender.Handler = h264.RTPDepay(track.Codec, sender.Handler)
} else {
sender.Handler = h264.RepairAVCC(track.Codec, sender.Handler)
}
case core.CodecH265:
sender.Handler = h265.RTPPay(1200, sender.Handler)
if track.Codec.IsRTP() {
sender.Handler = h265.RTPDepay(track.Codec, sender.Handler)
} else {
sender.Handler = h265.RepairAVCC(track.Codec, sender.Handler)
}
case core.CodecPCMA, core.CodecPCMU, core.CodecPCM, core.CodecPCML:
// Fix audio quality https://github.com/AlexxIT/WebRTC/issues/500
// should be before ResampleToG711, because it will be called last
sender.Handler = pcm.RepackG711(false, sender.Handler)
if codec.ClockRate == 0 {
if codec.Name == core.CodecPCM || codec.Name == core.CodecPCML {
codec.Name = core.CodecPCMA
}
codec.ClockRate = 8000
sender.Handler = pcm.TranscodeHandler(codec, track.Codec, sender.Handler)
}
}
// TODO: rewrite this dirty logic
// maybe not best solution, but ActiveProducer connected before AddTrack
if c.Mode != core.ModeActiveProducer {
sender.Bind(track)
} else {
sender.HandleRTP(track)
}
c.Senders = append(c.Senders, sender)
return nil
}
@@ -0,0 +1,348 @@
package webrtc
import (
"encoding/json"
"errors"
"fmt"
"hash/crc32"
"net"
"strconv"
"strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/ice/v4"
"github.com/pion/sdp/v3"
"github.com/pion/stun/v3"
"github.com/pion/webrtc/v4"
)
func UnmarshalMedias(descriptions []*sdp.MediaDescription) (medias []*core.Media) {
// 1. Sort medias, so video will always be before audio
// 2. Ignore application media from Hass default lovelace card
// 3. Ignore media without direction (inactive media)
// 4. Inverse media direction (because it is remote peer medias list)
for _, kind := range []string{core.KindVideo, core.KindAudio} {
for _, md := range descriptions {
if md.MediaName.Media != kind {
continue
}
media := core.UnmarshalMedia(md)
switch media.Direction {
case core.DirectionSendRecv:
media.Direction = core.DirectionRecvonly
medias = append(medias, media)
media = media.Clone()
media.Direction = core.DirectionSendonly
case core.DirectionRecvonly:
media.Direction = core.DirectionSendonly
case core.DirectionSendonly:
media.Direction = core.DirectionRecvonly
case "":
continue
}
// skip non-media codecs to avoid confusing users in info and logs
media.Codecs = SkipNonMediaCodecs(media.Codecs)
medias = append(medias, media)
}
}
return
}
func SkipNonMediaCodecs(input []*core.Codec) (output []*core.Codec) {
for _, codec := range input {
switch codec.Name {
case "RTX", "RED", "ULPFEC", "FLEXFEC-03":
continue
case "CN", "TELEPHONE-EVENT":
continue // https://datatracker.ietf.org/doc/html/rfc7874
}
// VP8, VP9, H264, H265, AV1
// OPUS, G722, PCMU, PCMA
output = append(output, codec)
}
return
}
// WithResampling - will add for consumer: PCMA/0, PCMU/0, PCM/0, PCML/0
// so it can add resampling for PCMA/PCMU and repack for PCM/PCML
func WithResampling(medias []*core.Media) []*core.Media {
for _, media := range medias {
if media.Kind != core.KindAudio || media.Direction != core.DirectionSendonly {
continue
}
var pcma, pcmu, pcm, pcml *core.Codec
for _, codec := range media.Codecs {
switch codec.Name {
case core.CodecPCMA:
if codec.ClockRate != 0 {
pcma = codec
} else {
pcma = nil
}
case core.CodecPCMU:
if codec.ClockRate != 0 {
pcmu = codec
} else {
pcmu = nil
}
case core.CodecPCM:
pcm = codec
case core.CodecPCML:
pcml = codec
}
}
if pcma != nil {
pcma = pcma.Clone()
pcma.ClockRate = 0 // reset clock rate so will match any
media.Codecs = append(media.Codecs, pcma)
}
if pcmu != nil {
pcmu = pcmu.Clone()
pcmu.ClockRate = 0
media.Codecs = append(media.Codecs, pcmu)
}
if pcma != nil && pcm == nil {
pcm = pcma.Clone()
pcm.Name = core.CodecPCM
media.Codecs = append(media.Codecs, pcm)
}
if pcma != nil && pcml == nil {
pcml = pcma.Clone()
pcml.Name = core.CodecPCML
media.Codecs = append(media.Codecs, pcml)
}
}
return medias
}
func NewCandidate(network, address string) (string, error) {
i := strings.LastIndexByte(address, ':')
if i < 0 {
return "", errors.New("wrong candidate: " + address)
}
host, port := address[:i], address[i+1:]
i, err := strconv.Atoi(port)
if err != nil {
return "", err
}
config := &ice.CandidateHostConfig{
Network: network,
Address: host,
Port: i,
Component: ice.ComponentRTP,
}
if network == "tcp" {
config.TCPType = ice.TCPTypePassive
}
cand, err := ice.NewCandidateHost(config)
if err != nil {
return "", err
}
return "candidate:" + cand.Marshal(), nil
}
func LookupIP(address string) (string, error) {
if strings.HasPrefix(address, "stun:") {
ip, err := GetCachedPublicIP()
if err != nil {
return "", err
}
return ip.String() + address[4:], nil
}
if IsIP(address) {
return address, nil
}
i := strings.IndexByte(address, ':')
ips, err := net.LookupIP(address[:i])
if err != nil {
return "", err
}
if len(ips) == 0 {
return "", fmt.Errorf("can't resolve: %s", address)
}
return ips[0].String() + address[i:], nil
}
// GetPublicIP example from https://github.com/pion/stun
func GetPublicIP(address string) (net.IP, error) {
conn, err := net.Dial("udp", address)
if err != nil {
return nil, err
}
c, err := stun.NewClient(conn)
if err != nil {
return nil, err
}
if err = conn.SetDeadline(time.Now().Add(time.Second * 3)); err != nil {
return nil, err
}
var res stun.Event
message := stun.MustBuild(stun.TransactionID, stun.BindingRequest)
if err = c.Do(message, func(e stun.Event) { res = e }); err != nil {
return nil, err
}
if err = c.Close(); err != nil {
return nil, err
}
if res.Error != nil {
return nil, res.Error
}
var xorAddr stun.XORMappedAddress
if err = xorAddr.GetFrom(res.Message); err != nil {
return nil, err
}
return xorAddr.IP, nil
}
var cachedIP net.IP
var cachedTS time.Time
func GetCachedPublicIP(stuns ...string) (net.IP, error) {
if now := time.Now(); now.After(cachedTS) {
for _, addr := range stuns {
if ip, _ := GetPublicIP(addr); ip != nil {
cachedIP = ip
cachedTS = now.Add(time.Minute * 5)
return ip, nil
}
}
}
if cachedIP == nil {
return nil, errors.New("webrtc: can't get public IP")
}
return cachedIP, nil
}
func IsIP(host string) bool {
for _, i := range host {
if i >= 'A' {
return false
}
}
return true
}
func MimeType(codec *core.Codec) string {
switch codec.Name {
case core.CodecH264:
return webrtc.MimeTypeH264
case core.CodecH265:
return webrtc.MimeTypeH265
case core.CodecVP8:
return webrtc.MimeTypeVP8
case core.CodecVP9:
return webrtc.MimeTypeVP9
case core.CodecAV1:
return webrtc.MimeTypeAV1
case core.CodecPCMU:
return webrtc.MimeTypePCMU
case core.CodecPCMA:
return webrtc.MimeTypePCMA
case core.CodecOpus:
return webrtc.MimeTypeOpus
case core.CodecG722:
return webrtc.MimeTypeG722
}
panic("not implemented")
}
func CandidateICE(network, host, port string, priority uint32) string {
// 1. Foundation
// 2. Component, always 1 because RTP
// 3. "udp" or "tcp"
// 4. Priority
// 5. Host - IP4 or IP6 or domain name
// 6. Port
// 7. "typ host"
foundation := crc32.ChecksumIEEE([]byte("host" + host + network + "4"))
s := fmt.Sprintf("candidate:%d 1 %s %d %s %s typ host", foundation, network, priority, host, port)
if network == "tcp" {
return s + " tcptype passive"
}
return s
}
// Priority = type << 24 + local << 8 + component
// https://www.rfc-editor.org/rfc/rfc8445#section-5.1.2.1
const PriorityHostUDP uint32 = 0x001F_FFFF |
126<<24 | // udp host
7<<21 // udp
const PriorityHostTCPPassive uint32 = 0x001F_FFFF |
99<<24 | // tcp host
4<<21 // tcp passive
// CandidateHostPriority (lower indexes has a higher priority)
func CandidateHostPriority(network string, index int) uint32 {
switch network {
case "udp":
return PriorityHostUDP - uint32(index)
case "tcp":
return PriorityHostTCPPassive - uint32(index)
}
return 0
}
func UnmarshalICEServers(b []byte) ([]webrtc.ICEServer, error) {
type ICEServer struct {
URLs any `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
var src []ICEServer
if err := json.Unmarshal(b, &src); err != nil {
return nil, err
}
var dst []webrtc.ICEServer
for i := range src {
srv := webrtc.ICEServer{
Username: src[i].Username,
Credential: src[i].Credential,
}
switch v := src[i].URLs.(type) {
case []any:
for _, u := range v {
if s, ok := u.(string); ok {
srv.URLs = append(srv.URLs, s)
}
}
case string:
srv.URLs = []string{v}
}
dst = append(dst, srv)
}
return dst, nil
}
@@ -0,0 +1,49 @@
package webrtc
import (
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/webrtc/v4"
)
func (c *Conn) GetTrack(media *core.Media, codec *core.Codec) (*core.Receiver, error) {
core.Assert(media.Direction == core.DirectionRecvonly)
for _, track := range c.Receivers {
if track.Codec == codec {
return track, nil
}
}
switch c.Mode {
case core.ModePassiveConsumer: // backchannel from browser
// set codec for consumer recv track so remote peer should send media with this codec
params := webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: MimeType(codec),
ClockRate: codec.ClockRate,
Channels: uint16(codec.Channels),
},
PayloadType: 0, // don't know if this necessary
}
tr := c.getTranseiver(media.ID)
_ = tr.SetCodecPreferences([]webrtc.RTPCodecParameters{params})
case core.ModePassiveProducer, core.ModeActiveProducer:
// Passive producers: OBS Studio via WHIP or Browser
// Active producers: go2rtc as WebRTC client or WebTorrent
default:
panic(core.Caller())
}
track := core.NewReceiver(media, codec)
c.Receivers = append(c.Receivers, track)
return track, nil
}
func (c *Conn) Start() error {
c.closed.Wait()
return nil
}
@@ -0,0 +1,123 @@
package webrtc
import (
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/sdp/v3"
"github.com/pion/webrtc/v4"
)
func (c *Conn) SetOffer(offer string) (err error) {
c.offer = offer
sd := &sdp.SessionDescription{}
if err = sd.Unmarshal([]byte(offer)); err != nil {
return
}
// create transceivers with opposite direction
for _, md := range sd.MediaDescriptions {
var mid string
var tr *webrtc.RTPTransceiver
for _, attr := range md.Attributes {
switch attr.Key {
case core.DirectionSendRecv:
tr, _ = c.pc.AddTransceiverFromTrack(NewTrack(md.MediaName.Media))
case core.DirectionSendonly:
tr, _ = c.pc.AddTransceiverFromKind(
webrtc.NewRTPCodecType(md.MediaName.Media),
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly},
)
case core.DirectionRecvonly:
tr, _ = c.pc.AddTransceiverFromTrack(
NewTrack(md.MediaName.Media),
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly},
)
case "mid":
mid = attr.Value
}
}
if mid != "" && tr != nil {
_ = tr.SetMid(mid)
}
}
c.Medias = UnmarshalMedias(sd.MediaDescriptions)
return
}
func (c *Conn) GetAnswer() (answer string, err error) {
// we need to process remote offer after we create transeivers
desc := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: c.offer}
if err = c.pc.SetRemoteDescription(desc); err != nil {
return "", err
}
// disable transceivers if we don't have track, make direction=inactive
transeivers:
for _, tr := range c.pc.GetTransceivers() {
for _, sender := range c.Senders {
if sender.Media.ID == tr.Mid() {
continue transeivers
}
}
switch tr.Direction() {
case webrtc.RTPTransceiverDirectionSendrecv:
_ = tr.Sender().Stop() // don't know if necessary
_ = tr.SetSender(tr.Sender(), nil) // set direction to recvonly
case webrtc.RTPTransceiverDirectionSendonly:
_ = tr.Stop()
}
}
if desc, err = c.pc.CreateAnswer(nil); err != nil {
return
}
if err = c.pc.SetLocalDescription(desc); err != nil {
return
}
return c.pc.LocalDescription().SDP, nil
}
// GetCompleteAnswer - get SDP answer with candidates inside
func (c *Conn) GetCompleteAnswer(candidates []string, filter func(*webrtc.ICECandidate) bool) (string, error) {
var done = make(chan struct{})
c.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
if filter == nil || filter(candidate) {
candidates = append(candidates, candidate.ToJSON().Candidate)
}
} else {
done <- struct{}{}
}
})
answer, err := c.GetAnswer()
if err != nil {
return "", err
}
<-done
sd := &sdp.SessionDescription{}
if err = sd.Unmarshal([]byte(answer)); err != nil {
return "", err
}
md := sd.MediaDescriptions[0]
for _, candidate := range candidates {
md.WithPropertyAttribute(candidate)
}
b, err := sd.Marshal()
if err != nil {
return "", err
}
return string(b), nil
}
@@ -0,0 +1,83 @@
package webrtc
import (
"sync"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
type Track struct {
kind string
id string
streamID string
sequence uint16
ssrc uint32
writer webrtc.TrackLocalWriter
mu sync.Mutex
}
func NewTrack(kind string) *Track {
return &Track{
kind: kind,
id: "go2rtc-" + kind,
streamID: "go2rtc",
}
}
func (t *Track) Bind(context webrtc.TrackLocalContext) (webrtc.RTPCodecParameters, error) {
t.mu.Lock()
t.ssrc = uint32(context.SSRC())
t.writer = context.WriteStream()
t.mu.Unlock()
for _, parameters := range context.CodecParameters() {
// return first parameters
return parameters, nil
}
return webrtc.RTPCodecParameters{}, nil
}
func (t *Track) Unbind(context webrtc.TrackLocalContext) error {
t.mu.Lock()
t.writer = nil
t.mu.Unlock()
return nil
}
func (t *Track) ID() string {
return t.id
}
func (t *Track) RID() string {
return "" // don't know what it is
}
func (t *Track) StreamID() string {
return t.streamID
}
func (t *Track) Kind() webrtc.RTPCodecType {
return webrtc.NewRTPCodecType(t.kind)
}
func (t *Track) WriteRTP(payloadType uint8, packet *rtp.Packet) (err error) {
// using mutex because Unbind https://github.com/AlexxIT/go2rtc/issues/994
t.mu.Lock()
// in case when we start WriteRTP before Track.Bind
if t.writer != nil {
// important to have internal counter if input packets from different sources
t.sequence++
header := packet.Header
header.SSRC = t.ssrc
header.PayloadType = payloadType
header.SequenceNumber = t.sequence
_, err = t.writer.WriteRTP(&header, packet.Payload)
}
t.mu.Unlock()
return
}
@@ -0,0 +1,71 @@
package webrtc
import (
"testing"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
)
func TestAlexa(t *testing.T) {
// from https://github.com/AlexxIT/go2rtc/issues/825
offer := `v=0
o=- 3911343731 3911343731 IN IP4 0.0.0.0
s=a 2 z
c=IN IP4 0.0.0.0
t=0 0
a=group:BUNDLE audio0 video0
m=audio 1 UDP/TLS/RTP/SAVPF 96 0 8
a=candidate:1 1 UDP 2013266431 52.90.193.210 60128 typ host
a=candidate:2 1 TCP 1015021823 52.90.193.210 9 typ host tcptype active
a=candidate:3 1 TCP 1010827519 52.90.193.210 45962 typ host tcptype passive
a=candidate:1 2 UDP 2013266430 52.90.193.210 46109 typ host
a=candidate:2 2 TCP 1015021822 52.90.193.210 9 typ host tcptype active
a=candidate:3 2 TCP 1010827518 52.90.193.210 53795 typ host tcptype passive
a=setup:actpass
a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=rtpmap:96 opus/48000/2
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtcp:9 IN IP4 0.0.0.0
a=rtcp-mux
a=sendrecv
a=mid:audio0
a=ssrc:3573704076 cname:user3856789923@host-9dd1dd33
a=ice-ufrag:gxfV
a=ice-pwd:KepKrlQ1+LD+RGTAFaqVck
a=fingerprint:sha-256 A2:93:53:50:E4:2F:C5:4E:DF:7C:70:99:5A:A7:39:50:1A:63:E5:B2:CA:73:70:7A:C5:F4:01:BF:BD:99:57:FC
m=video 1 UDP/TLS/RTP/SAVPF 99
a=candidate:1 1 UDP 2013266431 52.90.193.210 60128 typ host
a=candidate:1 2 UDP 2013266430 52.90.193.210 46109 typ host
a=candidate:2 1 TCP 1015021823 52.90.193.210 9 typ host tcptype active
a=candidate:3 1 TCP 1010827519 52.90.193.210 45962 typ host tcptype passive
a=candidate:3 2 TCP 1010827518 52.90.193.210 53795 typ host tcptype passive
a=candidate:2 2 TCP 1015021822 52.90.193.210 9 typ host tcptype active
b=AS:2500
a=setup:actpass
a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=rtpmap:99 H264/90000
a=rtcp:9 IN IP4 0.0.0.0
a=rtcp-mux
a=sendrecv
a=mid:video0
a=rtcp-fb:99 nack
a=rtcp-fb:99 nack pli
a=rtcp-fb:99 ccm fir
a=ssrc:3778078295 cname:user3856789923@host-9dd1dd33
a=ice-ufrag:gxfV
a=ice-pwd:KepKrlQ1+LD+RGTAFaqVck
a=fingerprint:sha-256 A2:93:53:50:E4:2F:C5:4E:DF:7C:70:99:5A:A7:39:50:1A:63:E5:B2:CA:73:70:7A:C5:F4:01:BF:BD:99:57:FC
`
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
require.Nil(t, err)
conn := NewConn(pc)
err = conn.SetOffer(offer)
require.Nil(t, err)
_, err = conn.GetAnswer()
require.Nil(t, err)
}