diff --git a/README.md b/README.md index faed8dfa..a5609c36 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pkg/config/config.go b/pkg/config/config.go index 585b6dde..a9832516 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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"` @@ -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. diff --git a/pkg/sip/media_codecs.go b/pkg/sip/media_codecs.go index 538a1382..d7a07975 100644 --- a/pkg/sip/media_codecs.go +++ b/pkg/sip/media_codecs.go @@ -18,6 +18,7 @@ package sip import ( "errors" "fmt" + "strings" "time" _ "github.com/livekit/media-sdk/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() { @@ -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, }) } @@ -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 "" +} diff --git a/pkg/sip/media_codecs_opus.go b/pkg/sip/media_codecs_opus.go new file mode 100644 index 00000000..11e5b7e7 --- /dev/null +++ b/pkg/sip/media_codecs_opus.go @@ -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 +} diff --git a/pkg/sip/media_codecs_opus_nocgo.go b/pkg/sip/media_codecs_opus_nocgo.go new file mode 100644 index 00000000..3beda439 --- /dev/null +++ b/pkg/sip/media_codecs_opus_nocgo.go @@ -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) {} diff --git a/pkg/sip/media_codecs_opus_test.go b/pkg/sip/media_codecs_opus_test.go new file mode 100644 index 00000000..64949f0f --- /dev/null +++ b/pkg/sip/media_codecs_opus_test.go @@ -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") +} diff --git a/pkg/sip/media_codecs_test.go b/pkg/sip/media_codecs_test.go new file mode 100644 index 00000000..484d182e --- /dev/null +++ b/pkg/sip/media_codecs_test.go @@ -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") +} diff --git a/pkg/sip/media_port_test.go b/pkg/sip/media_port_test.go index e2ee1222..e2f5fa44 100644 --- a/pkg/sip/media_port_test.go +++ b/pkg/sip/media_port_test.go @@ -213,14 +213,19 @@ func TestMediaPort(t *testing.T) { codecs := msdk.NewCodecSet() codecs.SetEnabled(info.SDPName, true) - sub := strings.SplitN(info.SDPName, "/", 2) + sub := strings.SplitN(info.SDPName, "/", 3) codecName := sub[0] + encName := strings.ToUpper(codecName) nativeRateSDP, err := strconv.Atoi(sub[1]) nativeRate := nativeRateSDP require.NoError(t, err) switch codecName { case "telephone-event": t.SkipNow() + case "opus": + // Lossy perceptual codec; waveform-fidelity assertions don't hold. + // Covered by media_codecs_opus_test.go instead. + t.SkipNow() case "G722": nativeRate *= 2 // error in RFC } @@ -342,30 +347,34 @@ func TestMediaPort(t *testing.T) { aliceToBobWrites := 1 bobToAliceWrites := 1 if tconf.Rate == nativeRate { - expChainBase := fmt.Sprintf("Switch(%d) -> LatencyEntry -> %s(encode) -> ByteEncoder(%d) -> StatsWriter(%s/%d) -> LatencyExit", - nativeRate, codecName, nativeRate, codecName, nativeRateSDP) - require.Equal(t, fmt.Sprintf("%s -> RTPWriteStream(%s:%d)", expChainBase, ip2, port2), aliceToBobWriteChain) - require.Equal(t, fmt.Sprintf("%s -> RTPWriteStream(%s:%d)", expChainBase, ip1, port1), bobToAliceWriteChain) + expChainBase := fmt.Sprintf("Switch(%d) -> LatencyEntry -> %s(encode) -> ByteEncoder(%d) -> StatsWriter(%s) -> LatencyExit", + nativeRate, encName, nativeRate, info.SDPName) + if tconf.Encrypted != sdp.EncryptionNone { + require.Equal(t, fmt.Sprintf("%s -> SRTPWriteStream", expChainBase), aliceToBobWriteChain) + require.Equal(t, fmt.Sprintf("%s -> SRTPWriteStream", expChainBase), bobToAliceWriteChain) + } else { + require.Equal(t, fmt.Sprintf("%s -> RTPWriteStream(%s:%d)", expChainBase, ip2, port2), aliceToBobWriteChain) + require.Equal(t, fmt.Sprintf("%s -> RTPWriteStream(%s:%d)", expChainBase, ip1, port1), bobToAliceWriteChain) + } - expChainBase = fmt.Sprintf("SilenceFiller(25) -> RTP(%%d) -> ByteDecoder -> %s(decode) -> LatencyExit -> Switch(%d) -> Buffer(%d)", codecName, nativeRate, nativeRate) + expChainBase = fmt.Sprintf("SilenceFiller(25) -> RTP(%%d) -> ByteDecoder -> %s(decode) -> LatencyExit -> Switch(%d) -> Buffer(%d)", encName, nativeRate, nativeRate) require.Equal(t, fmt.Sprintf(expChainBase, aliceAudio.Type), bobToAliceHandleChain) require.Equal(t, fmt.Sprintf(expChainBase, bobAudio.Type), aliceToBobHandleChain) } else { - expChain := fmt.Sprintf("Switch(48000) -> Resample(48000->%d) -> LatencyEntry -> %s(encode) -> ByteEncoder(%d) -> StatsWriter(%s/%d) -> LatencyExit -> SRTPWriteStream", - nativeRate, codecName, nativeRate, codecName, nativeRateSDP) + expChain := fmt.Sprintf("Switch(48000) -> Resample(48000->%d) -> LatencyEntry -> %s(encode) -> ByteEncoder(%d) -> StatsWriter(%s) -> LatencyExit -> SRTPWriteStream", + nativeRate, encName, nativeRate, info.SDPName) require.Equal(t, expChain, aliceToBobWriteChain) require.Equal(t, expChain, bobToAliceWriteChain) // This side does not resample the received audio, it uses sample rate of the RTP source. var expChainAlice string if bobToAliceNoResample { - expChainAlice = fmt.Sprintf("SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> LatencyExit -> Switch(%d) -> Buffer(%d)", aliceAudio.Type, codecName, nativeRate, nativeRate) + expChainAlice = fmt.Sprintf("SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> LatencyExit -> Switch(%d) -> Buffer(%d)", aliceAudio.Type, encName, nativeRate, nativeRate) } else { - expChainAlice = fmt.Sprintf("SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> Resample(%d->48000) -> LatencyExit -> Switch(48000) -> Buffer(48000)", aliceAudio.Type, codecName, nativeRate) + expChainAlice = fmt.Sprintf("SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> Resample(%d->48000) -> LatencyExit -> Switch(48000) -> Buffer(48000)", aliceAudio.Type, encName, nativeRate) } - // This side resamples the received audio to the expected sample rate. - expChainBob := fmt.Sprintf("SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> Resample(%d->48000) -> LatencyExit -> Switch(48000) -> Buffer(48000)", bobAudio.Type, codecName, nativeRate) + expChainBob := fmt.Sprintf("SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> Resample(%d->48000) -> LatencyExit -> Switch(48000) -> Buffer(48000)", bobAudio.Type, encName, nativeRate) require.Equal(t, expChainAlice, bobToAliceHandleChain) require.Equal(t, expChainBob, aliceToBobHandleChain) diff --git a/pkg/sip/service.go b/pkg/sip/service.go index fb86c3a4..84861f7a 100644 --- a/pkg/sip/service.go +++ b/pkg/sip/service.go @@ -216,6 +216,15 @@ func (s *Service) Start() error { } } msdk.CodecsSetEnabled(s.conf.Codecs) + SetOpusEnabled(s.conf.EnableOpus) + if s.conf.EnableOpus { + SetOpusOptions(OpusEncodeOptions{ + Bitrate: s.conf.Opus.Bitrate, + Complexity: s.conf.Opus.Complexity, + FEC: s.conf.Opus.FEC, + PacketLossPercent: s.conf.Opus.PacketLossPercent, + }) + } if err := s.mon.Start(s.conf); err != nil { return err