Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,17 @@ prometheus_port: port used to collect prometheus metrics. Used for autoscaling
log_level: debug, info, warn, or error (default info)
sip_port: port to listen and send SIP traffic (default 5060)
rtp_port: port to listen and send RTP traffic (default 10000-20000)
enable_opus: offer the Opus codec for SIP media (default false, experimental)
```

The config file can be added to a mounted volume with its location passed in the SIP_CONFIG_FILE env var, or its body can be passed in the SIP_CONFIG_BODY env var.

#### Codecs

PCMU, PCMA, G722, and DTMF are negotiated by default. Opus is **disabled by default** - set `enable_opus: true` to offer it. Validate interoperability with your SIP infrastructure before enabling in production.

When enabled, Opus (`opus/48000/2`, 48 kHz mono) is preferred over G722 and G711. Peers that do not support Opus fall back to G722, then G711 transparently.

### Using the SIP service

#### Creating Bridge and Dispatch Rule
Expand Down
11 changes: 11 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ var (
DefaultRTPPortRange = rtcconfig.PortRange{Start: 10000, End: 20000}
)

// OpusConfig tunes the Opus encoder for SIP media. All fields are optional;
// zero values keep libopus defaults.
type OpusConfig struct {
Bitrate int `yaml:"bitrate"` // target bitrate in bits/sec (e.g. 24000); 0 = auto
Complexity int `yaml:"complexity"` // encoder complexity 1-10; 0 = default
FEC bool `yaml:"fec"` // enable in-band Forward Error Correction
PacketLossPercent int `yaml:"packet_loss_percent"` // expected packet loss 0-100, tunes FEC
}

type TLSCert struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
Expand Down Expand Up @@ -111,6 +120,8 @@ type Config struct {
SymmetricRTP bool `yaml:"symmetric_rtp"`
IgnoreLocalAddrInSDP bool `yaml:"ignore_local_addr_in_sdp"` // enable symmetric RTP if local IP is specified in SDP
Codecs map[string]bool `yaml:"codecs"`
EnableOpus bool `yaml:"enable_opus"`
Opus OpusConfig `yaml:"opus"`

// HideInboundPort controls how SIP endpoint responds to unverified inbound requests.
// Setting it to true makes SIP server silently drop INVITE requests if it gets a negative Auth or Dispatch response.
Expand Down
32 changes: 32 additions & 0 deletions pkg/sip/media_codecs.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package sip
import (
"errors"
"fmt"
"strings"
"time"

_ "github.com/livekit/media-sdk/all"
Expand All @@ -31,6 +32,16 @@ import (
"github.com/livekit/protocol/livekit"
)

const OpusSDPName = "opus/48000/2"

// OpusEncodeOptions tunes the Opus encoder. Zero values keep libopus defaults.
type OpusEncodeOptions struct {
Bitrate int // target bitrate in bits/sec (e.g. 24000); 0 = auto
Complexity int // encoder complexity 1-10; 0 = default
FEC bool // enable in-band Forward Error Correction
PacketLossPercent int // expected packet loss 0-100, tunes FEC
}

var defaultCodecs = msdk.NewCodecSet()

func init() {
Expand All @@ -39,6 +50,7 @@ func init() {
g711.ULawSDPNameAndRate: true,
g722.SDPNameAndRate: true,
amrwb.SDPNameAndRate: false, // optional
OpusSDPName: false, // opt-in via enable_opus config flag
dtmf.SDPNameAndRate: true,
})
}
Expand Down Expand Up @@ -101,6 +113,26 @@ func codecSet(m *livekit.SIPMediaConfig) (*msdk.CodecSet, error) {
}
name = fmt.Sprintf("%s/%d", name, rate)
s.SetEnabled(name, true)
if sdpName := resolveSDPName(name); sdpName != "" {
s.SetEnabled(sdpName, true)
}
}
return s, nil
}

// resolveSDPName finds the full SDP name for a codec specified as "name/rate"
// by matching against registered codecs. This handles codecs like Opus whose
// SDP name includes a channel count suffix (e.g., "opus/48000/2").
func resolveSDPName(name string) string {
name = strings.ToLower(name)
for _, c := range msdk.Codecs() {
sdpName := strings.ToLower(c.Info().SDPName)
if sdpName == name {
return ""
}
if strings.HasPrefix(sdpName, name+"/") {
return c.Info().SDPName
}
}
return ""
}
71 changes: 71 additions & 0 deletions pkg/sip/media_codecs_opus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright 2024 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build cgo

package sip

import (
"sync/atomic"

msdk "github.com/livekit/media-sdk"
"github.com/livekit/media-sdk/opus"
"github.com/livekit/protocol/logger"
)

var opusEncodeOpts atomic.Pointer[OpusEncodeOptions]

// SetOpusOptions configures the Opus encoder. Call before or after enabling;
// takes effect on the next encoder instantiation (i.e. next call).
func SetOpusOptions(opts OpusEncodeOptions) {
opusEncodeOpts.Store(&opts)
}

func init() {
msdk.RegisterCodec(msdk.NewAudioCodec(msdk.CodecInfo{
SDPName: OpusSDPName,
SampleRate: 48000,
RTPClockRate: 48000,
RTPIsStatic: false,
Priority: 10,
Disabled: true,
FileExt: "opus",
}, opusDecode, opusEncode))
}

// SetOpusEnabled toggles Opus in both the per-call default codec set and the
// global media-sdk codec set. Call once during Service.Start.
func SetOpusEnabled(enabled bool) {
defaultCodecs.SetEnabled(OpusSDPName, enabled)
msdk.CodecSetEnabled(OpusSDPName, enabled)
}

func opusDecode(w msdk.PCM16Writer) msdk.WriteCloser[opus.Sample] {
dec, err := opus.Decode(w, 1, logger.GetLogger())
if err != nil {
logger.GetLogger().Errorw("opus decode init failed", err)
return nil
}
return dec
}

func opusEncode(w msdk.WriteCloser[opus.Sample]) msdk.PCM16Writer {
// TODO: apply opusEncodeOpts once livekit/media-sdk#69 (EncodeWith) merges.
enc, err := opus.Encode(w, 1, logger.GetLogger())
if err != nil {
logger.GetLogger().Errorw("opus encode init failed", err)
return nil
}
return enc
}
23 changes: 23 additions & 0 deletions pkg/sip/media_codecs_opus_nocgo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2024 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build !cgo

package sip

// SetOpusEnabled is a no-op in non-CGo builds; Opus requires libopus.
func SetOpusEnabled(_ bool) {}

// SetOpusOptions is a no-op in non-CGo builds; Opus requires libopus.
func SetOpusOptions(_ OpusEncodeOptions) {}
108 changes: 108 additions & 0 deletions pkg/sip/media_codecs_opus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright 2024 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build cgo

package sip

import (
"strings"
"testing"

"github.com/stretchr/testify/require"

msdk "github.com/livekit/media-sdk"
"github.com/livekit/media-sdk/g711"
"github.com/livekit/media-sdk/g722"
"github.com/livekit/media-sdk/sdp"
)

// enableOpusForTest turns Opus on for a test and restores disabled state after.
func enableOpusForTest(t *testing.T) {
t.Helper()
SetOpusEnabled(true)
t.Cleanup(func() { SetOpusEnabled(false) })
}

// TestOpusDisabledByDefault verifies that without calling SetOpusEnabled,
// Opus is absent from defaultCodecs — so existing deployments are unaffected.
func TestOpusDisabledByDefault(t *testing.T) {
c := sdp.CodecByNameWith(defaultCodecs, OpusSDPName)
require.Nil(t, c, "opus must not appear in defaultCodecs by default")
}

// TestOpusRegistered verifies the codec is present in msdk.Codecs(), uses a
// dynamic payload type, and runs at the correct 48 kHz clock rate.
func TestOpusRegistered(t *testing.T) {
enableOpusForTest(t)

c := sdp.CodecByNameWith(defaultCodecs, OpusSDPName)
require.NotNil(t, c, "opus codec must be present in defaultCodecs when enabled")

_, ok := c.(msdk.AudioCodec)
require.True(t, ok, "opus codec must implement AudioCodec")

info := c.Info()
require.Equal(t, OpusSDPName, info.SDPName)
require.Equal(t, 48000, info.SampleRate)
require.Equal(t, 48000, info.RTPClockRate)
require.False(t, info.RTPIsStatic, "opus must use a dynamic payload type")
}

// TestOpusInSDPOffer verifies that after enabling Opus, an SDP offer contains
// an rtpmap line advertising opus/48000/2.
func TestOpusInSDPOffer(t *testing.T) {
enableOpusForTest(t)

_, md, err := sdp.OfferMediaWith(defaultCodecs, 12345, sdp.EncryptionNone)
require.NoError(t, err)

var found bool
for _, a := range md.Attributes {
if a.Key == "rtpmap" && strings.Contains(strings.ToLower(a.Value), "opus/48000/2") {
found = true
break
}
}
require.True(t, found, "SDP offer should contain an rtpmap line for opus/48000/2")
}

// TestOpusPreferredOverG722 verifies codec selection picks Opus (priority 10)
// over G722 (priority -5) and G711 (priority -10/-20) when all are offered.
func TestOpusPreferredOverG722(t *testing.T) {
enableOpusForTest(t)

opusC, ok := sdp.CodecByNameWith(defaultCodecs, OpusSDPName).(msdk.AudioCodec)
require.True(t, ok, "opus must be an AudioCodec")

ulawC, ok := sdp.CodecByNameWith(defaultCodecs, g711.ULawSDPName).(msdk.AudioCodec)
require.True(t, ok, "PCMU must be an AudioCodec")

g722C, ok := sdp.CodecByNameWith(defaultCodecs, g722.SDPName).(msdk.AudioCodec)
require.True(t, ok, "G722 must be an AudioCodec")

desc := sdp.MediaDesc{
Codecs: []sdp.CodecInfo{
{Type: 0, Codec: ulawC},
{Type: 9, Codec: g722C},
{Type: 111, Codec: opusC},
},
}
got, err := sdp.SelectAudio(desc, false)
require.NoError(t, err)
require.Equal(t, OpusSDPName, got.Codec.Info().SDPName,
"Opus should win priority-based codec selection")
require.Equal(t, byte(111), got.Type,
"peer-assigned payload type 111 must be honored")
}
56 changes: 56 additions & 0 deletions pkg/sip/media_codecs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2024 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build cgo

package sip

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/livekit/protocol/livekit"
)

func TestResolveSDPName(t *testing.T) {
t.Run("two-part name resolves to three-part", func(t *testing.T) {
got := resolveSDPName("opus/48000")
require.Equal(t, OpusSDPName, got)
})
t.Run("exact three-part match returns empty", func(t *testing.T) {
got := resolveSDPName("opus/48000/2")
require.Empty(t, got)
})
t.Run("unknown codec returns empty", func(t *testing.T) {
got := resolveSDPName("unknown/8000")
require.Empty(t, got)
})
}

func TestCodecSetWithOpus(t *testing.T) {
SetOpusEnabled(true)
t.Cleanup(func() { SetOpusEnabled(false) })

m := &livekit.SIPMediaConfig{
OnlyListedCodecs: true,
Codecs: []*livekit.SIPCodec{
{Name: "opus", Rate: 48000},
},
}
s, err := codecSet(m)
require.NoError(t, err)
require.True(t, s.IsEnabledByName(OpusSDPName),
"codecSet should enable opus/48000/2 when opus/48000 is listed")
}
Loading