├── .github
└── FUNDING.yml
├── .gitignore
├── .gitlab-ci.yml
├── LICENSE.txt
├── README.md
├── audio
├── README.md
├── g711.go
├── monitor_pcm.go
├── monitor_pcm_test.go
├── opus.go
├── opus_c.go
├── opus_c_test.go
├── pcm_encoder.go
├── pcm_encoder_test.go
├── wav.go
├── wav_reader.go
├── wav_writer.go
└── wav_writer_test.go
├── bridge.go
├── bridge_test.go
├── diago.go
├── diago_test.go
├── diago_test_utils.go
├── dialog_cache.go
├── dialog_client_session.go
├── dialog_client_session_test.go
├── dialog_media.go
├── dialog_server_session.go
├── dialog_server_session_test.go
├── dialog_session.go
├── dialog_session_test.go
├── digest_auth.go
├── dtmf_reader_writer.go
├── examples
├── 183ringing
│ ├── README.md
│ └── main.go
├── bridge
│ ├── README.md
│ └── main.go
├── dtmf
│ ├── README.md
│ └── main.go
├── logger.go
├── playback
│ ├── README.md
│ └── main.go
├── playback_control
│ ├── README.md
│ └── main.go
├── readmedia
│ └── main.go
├── register
│ ├── README.md
│ └── main.go
└── wav_record
│ ├── README.md
│ └── main.go
├── go.mod
├── go.sum
├── icons
├── diago-text.png
└── diago.png
├── main_benchmark_test.go
├── media
├── .gitignore
├── README.md
├── codec.go
├── images
│ └── design.png
├── media_session.go
├── media_session_test.go
├── media_stream.go
├── rtp_dtmf.go
├── rtp_dtmf_reader.go
├── rtp_dtmf_reader_test.go
├── rtp_dtmf_writer.go
├── rtp_examples_test.go
├── rtp_packet_reader.go
├── rtp_packet_reader_test.go
├── rtp_packet_writer.go
├── rtp_packet_writer_test.go
├── rtp_parse.go
├── rtp_parse_test.go
├── rtp_sequencer.go
├── rtp_sequencer_test.go
├── rtp_session.go
├── rtp_session_test.go
├── rtp_stats_reader_writer.go
├── rtp_utils.go
└── sdp
│ ├── formats.go
│ ├── sdp.go
│ ├── sdp_test.go
│ └── utils.go
├── playback.go
├── playback_control.go
├── playback_control_test.go
├── playback_test.go
├── playback_url.go
├── playback_url_test.go
├── recording.go
├── recording_test.go
├── register_transaction.go
├── rsync_public.sh
├── testdata
├── embed.go
└── files
│ ├── demo-echodone.wav
│ └── demo-echotest.wav
└── utils.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [emiago]
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /go.work*
2 | /diagox
3 | /cmd
4 | /TODO.md
5 | /PRIVATE_DEV*.md
6 | /license_header_inject.sh
7 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: golang:latest
2 |
3 | stages:
4 | - test
5 |
6 | test:
7 | stage: test
8 | when: manual
9 | script:
10 | - cat /proc/net/dev
11 | - for x in $(go list ./... | grep -v -E '/examples|/testdata|/cmd'); do go test $x -v -timeout=30s; done
12 | #- go test $(go list ./... | grep -v /examples/) -v -cover
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [](https://goreportcard.com/report/github.com/emiago/diago)
4 | 
5 | 
6 |
7 | Short of **dialog + GO**.
8 | **Library for building VOIP solutions in GO!**
9 |
10 | Built on top of optimized [SIPgo library]((https://emiago.github.io/diago))!
11 | In short it allows developing fast and easy testable VOIP apps to handle calls, registrations and more...
12 |
13 | *Diago is mainly project driven lib, so lot of API design will/should be challenged with real working apps needs*
14 |
15 | **For more information and documentation visit [the website](https://emiago.github.io/diago/docs)**
16 |
17 | Quick links:
18 | - [Getting started](https://emiago.github.io/diago/docs/getting_started/)
19 | - [Demo Examples](https://emiago.github.io/diago/docs/examples/)
20 | - [API Docs](https://emiago.github.io/diago/docs/api_docs/)
21 | - [GO Docs](https://pkg.go.dev/github.com/emiago/diago)
22 |
23 | *If you find this project useful and you want to support/sponzor or need help with your projects, you can contact me more on*
24 | [mail](mailto:emirfreelance91@gmail.com).
25 |
26 | Follow me on [X/Twitter](https://twitter.com/emiago123) for regular updates
27 |
28 | ## RFCS
29 |
30 | SIP: [RFC 3261](https://datatracker.ietf.org/doc/html/rfc3261)|[RFC3581](https://datatracker.ietf.org/doc/html/rfc3581)|[RFC6026](https://datatracker.ietf.org/doc/html/rfc6026)
31 | > More refer to lib [github.com/emiago/sipgo](https://github.com/emiago/sipgo)
32 | Full dialog control (client/server), Registering, Authentication ...
33 |
34 | SDP: [RFC8866](https://datatracker.ietf.org/doc/html/rfc8866).
35 | > Parsing + Auto Generating for media session/audio
36 |
37 | RTP/AVP:
38 | [RFC3550](https://datatracker.ietf.org/doc/html/rfc3550)
39 | > RTP Packetizers, Media Forking, RTP Session control, RTCP Sender/Receiver reports, RTCP statistics tracking, DTMF reader/writer ...
40 |
41 | NOTE: For specifics and questions what is covered by RFC, please open Issue. Note lot of functionality can be extended even if not built in library.
42 |
43 | ## Contributions
44 | Please open first issues instead PRs. Library is under development and could not have latest code pushed.
45 |
46 |
47 | ## Usage
48 |
49 | Checkout more on [Getting started](https://emiago.github.io/diago/docs/getting_started/), but for quick view here is echotest (hello world) example.
50 | ```go
51 | ua, _ := sipgo.NewUA()
52 | dg := diago.NewDiago(ua)
53 |
54 | dg.Serve(ctx, func(inDialog *diago.DialogServerSession) {
55 | inDialog.Trying() // Progress -> 100 Trying
56 | inDialog.Answer(); // Answer
57 |
58 | // Make sure file below exists in work dir
59 | playfile, err := os.Open("demo-echotest.wav")
60 | if err != nil {
61 | fmt.Println("Failed to open file", err)
62 | return
63 | }
64 | defer playfile.Close()
65 |
66 | // Create playback and play file.
67 | pb, _ := inDialog.PlaybackCreate()
68 | if err := pb.Play(playfile, "audio/wav"); err != nil {
69 | fmt.Println("Playing failed", err)
70 | }
71 | }
72 | ```
73 |
74 | See more [examples in this repo](/examples)
75 | ### Tracing SIP, RTP
76 |
77 | While openning issue, consider having some traces enabled.
78 |
79 | ```go
80 | sip.SIPDebug = true // Enables SIP tracing
81 | media.RTCPDebug = true // Enables RTCP tracing
82 | media.RTPDebug = true // Enables RTP tracing. NOTE: It will dump every RTP Packet
83 | ```
84 |
--------------------------------------------------------------------------------
/audio/README.md:
--------------------------------------------------------------------------------
1 | # Audio package
2 |
3 | Allows many audio encoding and decoding.
4 | - PCM encoder/decoder
5 | - WAV writer/reader
6 |
7 |
8 | ## Installing opus C library
9 |
10 | ```
11 | #Ubuntu
12 | sudo apt install libopus0
13 |
14 | # Fedora
15 | sudo dnf install opus-devel
16 | sudo dnf install opusfile-devel
17 | ```
18 |
--------------------------------------------------------------------------------
/audio/g711.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package audio
5 |
6 | import (
7 | "io"
8 |
9 | "github.com/zaf/g711"
10 | )
11 |
12 | func EncodeUlawTo(ulaw []byte, lpcm []byte) (n int, err error) {
13 | if len(lpcm) > len(ulaw)*2 {
14 | return 0, io.ErrShortBuffer
15 | }
16 |
17 | for i, j := 0, 0; j <= len(lpcm)-2; i, j = i+1, j+2 {
18 | ulaw[i] = g711.EncodeUlawFrame(int16(lpcm[j]) | int16(lpcm[j+1])<<8)
19 | n++
20 | }
21 | return n, nil
22 | }
23 |
24 | func DecodeUlawTo(lpcm []byte, ulaw []byte) (n int, err error) {
25 | if ulaw == nil {
26 | return 0, nil
27 | }
28 |
29 | if len(lpcm) < 2*len(ulaw) {
30 | return 0, io.ErrShortBuffer
31 | }
32 | for i, j := 0, 0; i < len(ulaw); i, j = i+1, j+2 {
33 | frame := g711.DecodeUlawFrame(ulaw[i])
34 | lpcm[j] = byte(frame)
35 | lpcm[j+1] = byte(frame >> 8)
36 | n += 2
37 | }
38 | return n, nil
39 | }
40 |
41 | func EncodeAlawTo(alaw []byte, lpcm []byte) (n int, err error) {
42 | if len(lpcm) > len(alaw)*2 {
43 | return 0, io.ErrShortBuffer
44 | }
45 |
46 | for i, j := 0, 0; j <= len(lpcm)-2; i, j = i+1, j+2 {
47 | alaw[i] = g711.EncodeAlawFrame(int16(lpcm[j]) | int16(lpcm[j+1])<<8)
48 | n++
49 | }
50 | return n, nil
51 | }
52 |
53 | func DecodeAlawTo(lpcm []byte, alaw []byte) (n int, err error) {
54 | if alaw == nil {
55 | return 0, nil
56 | }
57 |
58 | if len(lpcm) < len(alaw)*2 {
59 | return 0, io.ErrShortBuffer
60 | }
61 | for i, j := 0, 0; i < len(alaw); i, j = i+1, j+2 {
62 | frame := g711.DecodeAlawFrame(alaw[i])
63 | lpcm[j] = byte(frame)
64 | lpcm[j+1] = byte(frame >> 8)
65 | n += 2
66 | }
67 | return n, nil
68 | }
69 |
--------------------------------------------------------------------------------
/audio/monitor_pcm.go:
--------------------------------------------------------------------------------
1 | package audio
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "errors"
7 | "io"
8 | "os"
9 | "time"
10 |
11 | "github.com/emiago/diago/media"
12 | "github.com/google/uuid"
13 | )
14 |
15 | var (
16 | RecordingFlushSize = 4096
17 | )
18 |
19 | type MonitorPCMReader struct {
20 | audioReader io.Reader
21 | writer *bufio.Writer // Lets use Buffered flushing
22 |
23 | codec media.Codec
24 | decoder PCMDecoderBuffer
25 | silence []byte
26 | lastTime time.Time
27 | }
28 |
29 | func (m *MonitorPCMReader) Init(w io.Writer, codec media.Codec, audioReader io.Reader) error {
30 | bw := bufio.NewWriterSize(w, RecordingFlushSize)
31 | m.writer = bw
32 | m.codec = codec
33 | m.audioReader = audioReader
34 |
35 | decoder := PCMDecoderBuffer{}
36 | if err := decoder.Init(codec); err != nil {
37 | return err
38 | }
39 | m.decoder = decoder
40 |
41 | samples16 := codec.Samples16()
42 | silence := bytes.Repeat([]byte{0}, samples16) // This alloc could be avoided
43 | m.silence = silence
44 | return nil
45 | }
46 |
47 | func (m *MonitorPCMReader) Flush() error {
48 | return m.writer.Flush()
49 | }
50 |
51 | func (m *MonitorPCMReader) Read(b []byte) (int, error) {
52 | n, err := m.audioReader.Read(b)
53 | if err != nil {
54 | return n, err
55 | }
56 | // Check do we need to inject silence
57 | now := time.Now()
58 | if !m.lastTime.IsZero() {
59 | diff := uint32(now.Sub(m.lastTime).Seconds() * float64(m.codec.SampleRate))
60 | srt := m.codec.SampleTimestamp()
61 | for i := 2 * srt; i < diff; i += srt {
62 | if _, err := m.writer.Write(m.silence); err != nil {
63 | return n, err
64 | }
65 | }
66 | }
67 | m.lastTime = now
68 |
69 | // Decode stream to PCM unless stream is already decoded?
70 | if _, err := m.decoder.Write(b[:n]); err != nil {
71 | return 0, err
72 | }
73 | lpcm := m.decoder.ReadFull()
74 |
75 | // Write to outer stream. Expecting some buffer with flushing will happen
76 | _, err = m.writer.Write(lpcm)
77 | return n, err
78 | }
79 |
80 | type MonitorPCMWriter struct {
81 | audioWriter io.Writer
82 | writer *bufio.Writer // Lets use Buffered flushing
83 |
84 | codec media.Codec
85 | decoder PCMDecoderBuffer
86 | silence []byte
87 | lastTime time.Time
88 | }
89 |
90 | func (m *MonitorPCMWriter) Init(w io.Writer, codec media.Codec, audioWriter io.Writer) error {
91 | bw := bufio.NewWriterSize(w, RecordingFlushSize)
92 | m.writer = bw
93 | m.codec = codec
94 | m.audioWriter = audioWriter
95 |
96 | decoder := PCMDecoderBuffer{}
97 | if err := decoder.Init(codec); err != nil {
98 | return err
99 | }
100 | m.decoder = decoder
101 |
102 | samples16 := codec.Samples16()
103 | silence := bytes.Repeat([]byte{0}, samples16) // This alloc could be avoided
104 | m.silence = silence
105 | return nil
106 | }
107 |
108 | func (m *MonitorPCMWriter) Flush() error {
109 | return m.writer.Flush()
110 | }
111 |
112 | func (m *MonitorPCMWriter) Write(b []byte) (int, error) {
113 | // Check do we need to inject silence
114 | now := time.Now()
115 | if !m.lastTime.IsZero() {
116 | diff := uint32(now.Sub(m.lastTime).Seconds() * float64(m.codec.SampleRate))
117 | srt := m.codec.SampleTimestamp()
118 | for i := 2 * srt; i < diff; i += srt {
119 | if _, err := m.writer.Write(m.silence); err != nil {
120 | return 0, err
121 | }
122 | }
123 | }
124 | m.lastTime = now
125 |
126 | n, err := m.audioWriter.Write(b)
127 | if err != nil {
128 | return n, err
129 | }
130 |
131 | // Decode stream to PCM unless stream is already decoded?
132 | if _, err := m.decoder.Write(b[:n]); err != nil {
133 | return 0, err
134 | }
135 | lpcm := m.decoder.ReadFull()
136 |
137 | // Write to outer stream. Expecting some buffer with flushing will happen
138 | _, err = m.writer.Write(lpcm)
139 | return n, err
140 | }
141 |
142 | type MonitorPCMStereo struct {
143 | MonitorPCMReader
144 | MonitorPCMWriter
145 |
146 | PCMFileRead *os.File
147 | PCMFileWrite *os.File
148 |
149 | recording io.Writer
150 | }
151 |
152 | // It supports only single codec, which must be same for reader and writer
153 | func (m *MonitorPCMStereo) Init(record io.Writer, codec media.Codec, audioReader io.Reader, audioWriter io.Writer) error {
154 | m.recording = record
155 |
156 | uuid := uuid.New().String()
157 | var err error
158 | err = func() error {
159 | if m.PCMFileRead == nil {
160 | m.PCMFileRead, err = os.OpenFile("/tmp/"+uuid+"_monitor_reader.raw", os.O_CREATE|os.O_RDWR, 0755)
161 | if err != nil {
162 | return err
163 | }
164 | }
165 |
166 | if m.PCMFileWrite == nil {
167 | m.PCMFileWrite, err = os.OpenFile("/tmp/"+uuid+"_monitor_writer.raw", os.O_CREATE|os.O_RDWR, 0755)
168 | if err != nil {
169 | return err
170 | }
171 | }
172 |
173 | if err := m.MonitorPCMReader.Init(m.PCMFileRead, codec, audioReader); err != nil {
174 | return err
175 | }
176 |
177 | if err := m.MonitorPCMWriter.Init(m.PCMFileWrite, codec, audioWriter); err != nil {
178 | return err
179 | }
180 | return nil
181 | }()
182 | if err != nil {
183 | return errors.Join(err, m.removeTmpFiles())
184 | }
185 |
186 | return nil
187 | }
188 |
189 | func (m *MonitorPCMStereo) removeTmpFiles() (err error) {
190 | if m.PCMFileRead != nil {
191 | e1 := m.PCMFileRead.Close()
192 | e2 := os.Remove(m.PCMFileRead.Name())
193 | err = errors.Join(err, e1, e2)
194 | }
195 |
196 | if m.PCMFileWrite != nil {
197 | e1 := m.PCMFileWrite.Close()
198 | e2 := os.Remove(m.PCMFileWrite.Name())
199 | err = errors.Join(err, e1, e2)
200 | }
201 | return err
202 | }
203 |
204 | func (m *MonitorPCMStereo) Close() error {
205 | if err := m.Flush(); err != nil {
206 | return err
207 | }
208 | if err := m.interleave(); err != nil {
209 | return err
210 | }
211 |
212 | return m.removeTmpFiles()
213 | }
214 |
215 | func (m *MonitorPCMStereo) Flush() error {
216 | if err := m.MonitorPCMReader.Flush(); err != nil {
217 | return err
218 | }
219 | if err := m.MonitorPCMWriter.Flush(); err != nil {
220 | return err
221 | }
222 | return nil
223 | }
224 |
225 | func (m *MonitorPCMStereo) interleave() error {
226 | fr := m.PCMFileRead
227 | fw := m.PCMFileWrite
228 | recording := m.recording
229 | if _, err := fr.Seek(0, 0); err != nil {
230 | return err
231 | }
232 | if _, err := fw.Seek(0, 0); err != nil {
233 | return err
234 | }
235 |
236 | // Read frames from both files and interleave
237 | readBuf1 := make([]byte, RecordingFlushSize/2)
238 | readBuf2 := make([]byte, RecordingFlushSize/2)
239 | stereoBuf := make([]byte, (RecordingFlushSize/2)*2)
240 | size := 2 // 16 bit
241 | for {
242 | n1, err1 := io.ReadFull(fr, readBuf1)
243 | n2, err2 := io.ReadFull(fw, readBuf2)
244 |
245 | n := max(n1, n2)
246 |
247 | if (err1 != nil || err2 != nil) && n == 0 {
248 | if !errors.Is(err1, io.EOF) {
249 | return err1
250 | }
251 |
252 | if !errors.Is(err2, io.EOF) {
253 | return err2
254 | }
255 | break
256 | }
257 |
258 | // interleave
259 | copyN := 0
260 | for i, j := 0, 0; i < n; i += size {
261 | copyN += copy(stereoBuf[j:j+size], readBuf1[i:i+size])
262 | copyN += copy(stereoBuf[j+size:j+2*size], readBuf2[i:i+size])
263 | j += 2 * size // 2 channels * size
264 | }
265 |
266 | if _, err := recording.Write(stereoBuf[:copyN]); err != nil {
267 | return err
268 | }
269 |
270 | }
271 |
272 | return nil
273 | }
274 |
--------------------------------------------------------------------------------
/audio/monitor_pcm_test.go:
--------------------------------------------------------------------------------
1 | package audio
2 |
3 | import (
4 | "bytes"
5 | "os"
6 | "testing"
7 | "time"
8 |
9 | "github.com/emiago/diago/media"
10 | "github.com/pion/rtp"
11 | "github.com/stretchr/testify/assert"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | type rtpBuffer struct {
16 | buf []rtp.Packet
17 | }
18 |
19 | func (b *rtpBuffer) WriteRTP(p *rtp.Packet) error {
20 | b.buf = append(b.buf, *p)
21 | return nil
22 | }
23 |
24 | func TestMonitorPCMReaderWriter(t *testing.T) {
25 | codecR := media.CodecAudioAlaw
26 |
27 | audioAlawBuf := make([]byte, 4*160)
28 | _, err := EncodeAlawTo(audioAlawBuf, bytes.Repeat([]byte("0123456789"), media.CodecAudioAlaw.Samples16()*4/10))
29 | require.NoError(t, err)
30 |
31 | t.Run("Reader", func(t *testing.T) {
32 | rtpBufferReader := bytes.NewBuffer(audioAlawBuf)
33 |
34 | recording := bytes.NewBuffer([]byte{})
35 | mon := &MonitorPCMReader{}
36 | mon.Init(recording, codecR, rtpBufferReader)
37 |
38 | mon.Read(make([]byte, 160))
39 | mon.Read(make([]byte, 160))
40 | time.Sleep(3 * codecR.SampleDur) // 1 now comming and 2 delayed
41 | _, err = media.ReadAll(mon, 160)
42 | require.NoError(t, err)
43 |
44 | mon.Flush()
45 |
46 | // 2 Frames, 2 Silence, 2 Frames
47 | frameSize := codecR.Samples16()
48 | assert.Equal(t, 2*frameSize+2*frameSize+2*frameSize, recording.Len())
49 | })
50 |
51 | t.Run("Writer", func(t *testing.T) {
52 | // Lets
53 | recording := bytes.NewBuffer([]byte{})
54 | mon := &MonitorPCMWriter{}
55 | mon.Init(recording, codecR, bytes.NewBuffer([]byte{}))
56 |
57 | mon.Write(audioAlawBuf[:160])
58 | mon.Write(audioAlawBuf[160 : 2*160])
59 | time.Sleep(3 * codecR.SampleDur) // 1 now comming and 2 delayed
60 | _, err = media.WriteAll(mon, audioAlawBuf[2*160:], 160)
61 | require.NoError(t, err)
62 |
63 | mon.Flush()
64 |
65 | // 2 Frames, 2 Silence, 2 Frames
66 | frameSize := codecR.Samples16()
67 | assert.Equal(t, 2*frameSize+2*frameSize+2*frameSize, recording.Len())
68 | })
69 |
70 | }
71 |
72 | func TestMonitorPCMStereo(t *testing.T) {
73 | audioAlawBuf := make([]byte, 4*160)
74 | _, err := EncodeAlawTo(audioAlawBuf, bytes.Repeat([]byte("0123456789"), media.CodecAudioAlaw.Samples16()*4/10))
75 | require.NoError(t, err)
76 | audioPCMBuf := make([]byte, 4*320)
77 | DecodeAlawTo(audioPCMBuf, audioAlawBuf)
78 |
79 | t.Run("SmallData", func(t *testing.T) {
80 | mon := &MonitorPCMStereo{}
81 | recording := bytes.NewBuffer([]byte{})
82 | err = mon.Init(recording, media.CodecAudioAlaw, bytes.NewBuffer(audioAlawBuf), bytes.NewBuffer([]byte{}))
83 | require.NoError(t, err)
84 |
85 | errWrite := make(chan error)
86 | go func() {
87 | _, err = media.WriteAll(mon, audioAlawBuf, 160)
88 | errWrite <- err
89 | }()
90 |
91 | _, err = media.ReadAll(mon, 160)
92 | require.NoError(t, err)
93 |
94 | err = <-errWrite
95 | require.NoError(t, err)
96 |
97 | err = mon.Close()
98 | require.NoError(t, err)
99 |
100 | // Do files get removed
101 | _, err = os.Stat(mon.PCMFileRead.Name())
102 | assert.True(t, os.IsNotExist(err))
103 | _, err = os.Stat(mon.PCMFileWrite.Name())
104 | assert.True(t, os.IsNotExist(err))
105 |
106 | frameSize := media.CodecAudioAlaw.Samples16()
107 | assert.Equal(t, 8*frameSize, recording.Len())
108 | // Check does data alternate
109 | stereo := recording.Bytes()
110 | assert.Equal(t, audioPCMBuf[:2], stereo[:2])
111 | assert.Equal(t, audioPCMBuf[:2], stereo[2:4])
112 | })
113 |
114 | t.Run("BigData", func(t *testing.T) {
115 | audioAlawBufBig := bytes.Repeat(audioAlawBuf, 20)
116 |
117 | mon := &MonitorPCMStereo{}
118 | recording := bytes.NewBuffer([]byte{})
119 | err = mon.Init(recording, media.CodecAudioAlaw, bytes.NewBuffer(audioAlawBufBig), bytes.NewBuffer([]byte{}))
120 | require.NoError(t, err)
121 |
122 | errWrite := make(chan error)
123 | go func() {
124 | _, err = media.WriteAll(mon, audioAlawBufBig, 160)
125 | errWrite <- err
126 | }()
127 |
128 | _, err = media.ReadAll(mon, 160)
129 | require.NoError(t, err)
130 |
131 | err = <-errWrite
132 | require.NoError(t, err)
133 |
134 | err = mon.Close()
135 | require.NoError(t, err)
136 |
137 | frameSize := media.CodecAudioAlaw.Samples16()
138 | // 80 frames * 2 channels
139 | assert.Equal(t, 80*2*frameSize, recording.Len())
140 | })
141 |
142 | }
143 |
--------------------------------------------------------------------------------
/audio/opus.go:
--------------------------------------------------------------------------------
1 | //go:build !with_opus_c
2 |
3 | // SPDX-License-Identifier: MPL-2.0
4 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
5 |
6 | package audio
7 |
8 | import (
9 | "fmt"
10 | )
11 |
12 | // This is placeholder for opus encoder. Opus is used as C binding.
13 |
14 | type OpusEncoder struct {
15 | }
16 |
17 | func (enc *OpusEncoder) Init(sampleRate int, numChannels int, samplesSize int) error {
18 | return fmt.Errorf("Use with_opus_c tag to compile opus encoder")
19 | }
20 |
21 | func (enc *OpusEncoder) EncodeTo(data []byte, lpcm []byte) (int, error) {
22 | return 0, fmt.Errorf("not supported")
23 | }
24 |
25 | type OpusDecoder struct {
26 | }
27 |
28 | func (enc *OpusDecoder) Init(sampleRate int, numChannels int, samplesSize int) error {
29 | return fmt.Errorf("Use with_opus_c tag to compile opus decoder")
30 | }
31 |
32 | func (dec *OpusDecoder) DecodeTo(lpcm []byte, data []byte) (int, error) {
33 | return 0, fmt.Errorf("not supported")
34 | }
35 |
--------------------------------------------------------------------------------
/audio/opus_c.go:
--------------------------------------------------------------------------------
1 | //go:build with_opus_c
2 |
3 | // SPDX-License-Identifier: MPL-2.0
4 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
5 |
6 | package audio
7 |
8 | import (
9 | "fmt"
10 |
11 | "gopkg.in/hraban/opus.v2"
12 | )
13 |
14 | type OpusEncoder struct {
15 | opus.Encoder
16 | pcmInt16 []int16
17 | numChannels int
18 | }
19 |
20 | func (enc *OpusEncoder) Init(sampleRate int, numChannels int, samplesSize int) error {
21 | enc.numChannels = numChannels
22 | // TODO use sync pool for creating this. Needs to be closer
23 | enc.pcmInt16 = make([]int16, samplesSize)
24 |
25 | if err := enc.Encoder.Init(sampleRate, numChannels, opus.AppVoIP); err != nil {
26 | return fmt.Errorf("failed to create opus decoder: %w", err)
27 | }
28 |
29 | return nil
30 | }
31 |
32 | func (enc *OpusEncoder) EncodeTo(data []byte, lpcm []byte) (int, error) {
33 | n, err := samplesByteToInt16(lpcm, enc.pcmInt16)
34 | if err != nil {
35 | return 0, err
36 | }
37 |
38 | // NOTE: opus has fixed frame sizes 2.5, 5, 10, 20, 40 or 60 ms
39 | pcmInt16 := enc.pcmInt16[:n]
40 |
41 | n, err = enc.Encode(pcmInt16, data)
42 | return n, err
43 |
44 | }
45 |
46 | type OpusDecoder struct {
47 | opus.Decoder
48 | pcmInt16 []int16
49 | numChannels int
50 | }
51 |
52 | func (enc *OpusDecoder) Init(sampleRate int, numChannels int, samplesSize int) error {
53 | enc.numChannels = numChannels
54 | enc.pcmInt16 = make([]int16, samplesSize)
55 | if err := enc.Decoder.Init(sampleRate, numChannels); err != nil {
56 | return fmt.Errorf("failed to create opus decoder: %w", err)
57 | }
58 |
59 | return nil
60 | }
61 |
62 | func (dec *OpusDecoder) DecodeTo(lpcm []byte, data []byte) (int, error) {
63 | pcmN, err := dec.Decoder.Decode(data, dec.pcmInt16)
64 | if err != nil {
65 | return 0, err
66 | }
67 | // If there are more channels lib will not return, so it needs to be multiplied
68 | pcmN = pcmN * dec.numChannels
69 | if len(dec.pcmInt16) < pcmN {
70 | // Should never happen
71 | return 0, fmt.Errorf("opus: pcm int buffer expected=%d", pcmN)
72 | }
73 |
74 | pcm := dec.pcmInt16[:pcmN]
75 | n, err := samplesInt16ToBytes(pcm, lpcm)
76 | return n, err
77 | }
78 |
--------------------------------------------------------------------------------
/audio/opus_c_test.go:
--------------------------------------------------------------------------------
1 | //go:build with_opus_c
2 |
3 | // SPDX-License-Identifier: MPL-2.0
4 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
5 | package audio
6 |
7 | import (
8 | "bytes"
9 | "fmt"
10 | "io"
11 | "testing"
12 |
13 | "github.com/emiago/diago/media"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/require"
16 | "gopkg.in/hraban/opus.v2"
17 | )
18 |
19 | func TestPCMEncoderOpus(t *testing.T) {
20 | // Expected decoded output
21 | pcm := testGeneratePCM16(48000)
22 | pcmInt16 := make([]int16, len(pcm)/2)
23 | n, _ := samplesByteToInt16(pcm, pcmInt16)
24 | require.Equal(t, len(pcmInt16), n)
25 |
26 | enc, err := opus.NewEncoder(48000, 2, opus.AppVoIP)
27 | require.NoError(t, err)
28 |
29 | encodedOpus := make([]byte, 1500)
30 | n, err = enc.Encode(pcmInt16, encodedOpus)
31 | require.NoError(t, err)
32 | encodedOpus = encodedOpus[:n]
33 |
34 | t.Log("Len of PCM ", len(pcm))
35 | t.Log("Len of encoded ", len(encodedOpus))
36 |
37 | // Test cases for both μ-law and A-law
38 | // tt := struct {
39 | // name string
40 | // codec uint8
41 | // input []byte
42 | // expected []byte
43 | // }{
44 | // {"PCMDecoding", FORMAT_TYPE_OPUS, nil, encodedOpus},
45 | // }
46 |
47 | t.Run("Encode", func(t *testing.T) {
48 | var outputBuffer bytes.Buffer
49 |
50 | // Create the PCM encoder
51 | encoder, err := NewPCMEncoderWriter(FORMAT_TYPE_OPUS, &outputBuffer)
52 | require.NoError(t, err)
53 |
54 | // Write the PCM data
55 | n, err := encoder.Write(pcm)
56 | if err != nil {
57 | t.Fatalf("Write failed: %v", err)
58 | }
59 | if n != len(pcm) {
60 | t.Fatalf("expected to write %d bytes, but wrote %d", len(pcm), n)
61 | }
62 |
63 | assert.Equal(t, encodedOpus, outputBuffer.Bytes())
64 | })
65 |
66 | dec, err := opus.NewDecoder(48000, 2)
67 | require.NoError(t, err)
68 |
69 | // data := make([]byte, 1500)
70 | n, err = dec.Decode(encodedOpus, pcmInt16)
71 | require.NoError(t, err)
72 |
73 | expectedPCM := pcmInt16[:n*2]
74 | expectedLpcm := make([]byte, len(expectedPCM)*2)
75 | n, _ = samplesInt16ToBytes(expectedPCM, expectedLpcm)
76 | require.Equal(t, n, len(expectedLpcm))
77 |
78 | t.Run("Decode", func(t *testing.T) {
79 | // Create a buffer to simulate the encoded input
80 | inputBuffer := bytes.NewReader(encodedOpus)
81 |
82 | // Create the PCM decoder
83 | decoder, err := NewPCMDecoderReader(FORMAT_TYPE_OPUS, inputBuffer)
84 | require.NoError(t, err)
85 |
86 | // Prepare a buffer to read the decoded PCM data into
87 | decodedPCM := make([]byte, media.CodecAudioOpus.Samples16())
88 | fullPCM := make([]byte, 0, len(expectedLpcm))
89 | // Read the data
90 | for {
91 | n, err := decoder.Read(decodedPCM)
92 | if err != nil {
93 | break
94 | }
95 | fullPCM = append(fullPCM, decodedPCM[:n]...)
96 | }
97 |
98 | // Verify the decoded output matches the expected PCM data
99 | assert.Equal(t, expectedLpcm, fullPCM)
100 | })
101 |
102 | t.Run("DecodeWithSmallBuffer", func(t *testing.T) {
103 | // Create a buffer to simulate the encoded input
104 | inputBuffer := bytes.NewReader(encodedOpus)
105 |
106 | // Create the PCM decoder
107 | decoder, err := NewPCMDecoderReader(FORMAT_TYPE_OPUS, inputBuffer)
108 | require.NoError(t, err)
109 |
110 | decodedPCM := make([]byte, 512)
111 | _, err = decoder.Read(decodedPCM)
112 | require.Error(t, err)
113 | require.ErrorIs(t, err, io.ErrShortBuffer)
114 | })
115 | }
116 |
117 | // Decoding or encoding is supported regardless of sampling rate
118 | // https://datatracker.ietf.org/doc/html/rfc6716#section-2.1.3
119 | func TestOpusDecodingDifferentBandwith(t *testing.T) {
120 | pcm := testGeneratePCM16(48000)
121 | pcmInt16 := make([]int16, len(pcm)/2)
122 | n, _ := samplesByteToInt16(pcm, pcmInt16)
123 | require.Equal(t, len(pcmInt16), n)
124 |
125 | enc, err := opus.NewEncoder(48000, 2, opus.AppVoIP)
126 | require.NoError(t, err)
127 |
128 | encodedOpus := make([]byte, 1500)
129 | n, err = enc.Encode(pcmInt16, encodedOpus)
130 | require.NoError(t, err)
131 | encodedOpus = encodedOpus[:n]
132 |
133 | runDecodeTest := func(t *testing.T, sampleRate int) {
134 | dec, err := opus.NewDecoder(8000, 2)
135 | require.NoError(t, err)
136 |
137 | // data := make([]byte, 1500)
138 | n, err = dec.Decode(encodedOpus, pcmInt16)
139 | require.NoError(t, err)
140 |
141 | expectedPCM := pcmInt16[:n*2]
142 | expectedLpcm := make([]byte, len(expectedPCM)*2)
143 | n, _ = samplesInt16ToBytes(expectedPCM, expectedLpcm)
144 | require.Equal(t, n, len(expectedLpcm))
145 | }
146 |
147 | t.Run(fmt.Sprintf("FBDecode8000"), func(t *testing.T) {
148 | runDecodeTest(t, 8000)
149 | })
150 |
151 | t.Run(fmt.Sprintf("FBDecode16000"), func(t *testing.T) {
152 | runDecodeTest(t, 16000)
153 | })
154 | }
155 |
--------------------------------------------------------------------------------
/audio/pcm_encoder_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package audio
5 |
6 | import (
7 | "bytes"
8 | "encoding/binary"
9 | "io/ioutil"
10 | "math"
11 | "testing"
12 |
13 | "github.com/emiago/diago/media"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/require"
16 | "github.com/zaf/g711"
17 | )
18 |
19 | // testGeneratePCM16 generates a 20ms PCM16 sine wave as a byte slice.
20 | // Frequency: 1kHz, Amplitude: max for PCM16.
21 | func testGeneratePCM16(sampleRate int) []byte {
22 | const (
23 | durationMs = 20 // 20 ms
24 | frequency = 1000 // 1 kHz sine wave
25 | amplitude = 32767 // Max amplitude for PCM16
26 | )
27 |
28 | numSamples := sampleRate * durationMs / 1000
29 | buf := new(bytes.Buffer)
30 |
31 | for i := 0; i < numSamples; i++ {
32 | sample := int16(amplitude * math.Sin(2*math.Pi*float64(frequency)*float64(i)/float64(sampleRate)))
33 | binary.Write(buf, binary.LittleEndian, sample)
34 | binary.Write(buf, binary.LittleEndian, sample)
35 | }
36 | return buf.Bytes()
37 | }
38 |
39 | func TestPCMEncoderWrite(t *testing.T) {
40 | lpcm := []byte{
41 | 0x00, 0x01, // Sample 1
42 | 0x02, 0x03, // Sample 2
43 | 0x04, 0x05, // Sample 3
44 | 0x06, 0x07, // Sample 4
45 | }
46 |
47 | // Expected μ-law and A-law outputs (replace with actual expected encoded bytes)
48 | expectedULaw := g711.EncodeUlaw(lpcm)
49 | expectedALaw := g711.EncodeAlaw(lpcm)
50 |
51 | // Test cases for both μ-law and A-law
52 | tests := []struct {
53 | name string
54 | codec uint8
55 | expected []byte
56 | }{
57 | {"UlawEncoding", FORMAT_TYPE_ULAW, expectedULaw},
58 | {"AlawEncoding", FORMAT_TYPE_ALAW, expectedALaw},
59 | }
60 |
61 | for _, tt := range tests {
62 | t.Run(tt.name, func(t *testing.T) {
63 | var outputBuffer bytes.Buffer
64 |
65 | // Create the PCM encoder
66 | encoder, err := NewPCMEncoderWriter(tt.codec, &outputBuffer)
67 | require.NoError(t, err)
68 |
69 | // Write the PCM data
70 | n, err := encoder.Write(lpcm)
71 | if err != nil {
72 | t.Fatalf("Write failed: %v", err)
73 | }
74 | if n != len(lpcm) {
75 | t.Fatalf("expected to write %d bytes, but wrote %d", len(lpcm), n)
76 | }
77 |
78 | assert.Equal(t, tt.expected, outputBuffer.Bytes())
79 | })
80 | }
81 | }
82 |
83 | func TestPCMDecoderRead(t *testing.T) {
84 | // Expected decoded output
85 | // pcm := []byte{
86 | // 0x00, 0x01, 0x02, 0x03, // This should match original PCM data after decoding
87 | // 0x04, 0x05, 0x06, 0x07,
88 | // }
89 | pcm := testGeneratePCM16(8000)
90 |
91 | encodedUlaw := g711.EncodeUlaw(pcm)
92 | encodedAlaw := g711.EncodeAlaw(pcm)
93 |
94 | // Test cases for both μ-law and A-law
95 | tests := []struct {
96 | name string
97 | codec uint8
98 | input []byte
99 | expected []byte
100 | }{
101 | {"UlawDecoding", FORMAT_TYPE_ULAW, encodedUlaw, g711.DecodeUlaw(encodedUlaw)},
102 | {"AlawDecoding", FORMAT_TYPE_ALAW, encodedAlaw, g711.DecodeAlaw(encodedAlaw)},
103 | {"AlawDecodingCut", FORMAT_TYPE_ALAW, encodedAlaw[:len(encodedAlaw)-48], g711.DecodeAlaw(encodedAlaw[:len(encodedAlaw)-48])},
104 | }
105 |
106 | for _, tt := range tests {
107 | t.Run(tt.name, func(t *testing.T) {
108 | // Create a buffer to simulate the encoded input
109 | inputBuffer := bytes.NewReader(tt.input)
110 |
111 | // Create the PCM decoder
112 | decoder, err := NewPCMDecoderReader(tt.codec, inputBuffer)
113 | decoder.BufSize = 160
114 | require.NoError(t, err)
115 |
116 | // Prepare a buffer to read the decoded PCM data into
117 | // decodedPCM := make([]byte, 320)
118 |
119 | // Read the data
120 | decodedPCM, err := media.ReadAll(decoder, 320)
121 | n := len(decodedPCM)
122 | // n, err := decoder.Read(decodedPCM)
123 | require.NoError(t, err)
124 | if n != len(tt.expected) {
125 | t.Fatalf("expected to read %d bytes, but read %d", len(tt.expected), n)
126 | }
127 |
128 | // Verify the decoded output matches the expected PCM data
129 | decodedPCM = decodedPCM[:n]
130 | assert.Equal(t, tt.expected, decodedPCM)
131 | })
132 | }
133 | }
134 |
135 | // Extract raw pcm data from .wav file
136 | func extractWavPcm(t *testing.T, fname string) []int16 {
137 | bytes, err := ioutil.ReadFile(fname)
138 | if err != nil {
139 | t.Fatalf("Error reading file data from %s: %v", fname, err)
140 | }
141 | const wavHeaderSize = 44
142 | if (len(bytes)-wavHeaderSize)%2 == 1 {
143 | t.Fatalf("Illegal wav data: payload must be encoded in byte pairs")
144 | }
145 | numSamples := (len(bytes) - wavHeaderSize) / 2
146 | samples := make([]int16, numSamples)
147 | for i := 0; i < numSamples; i++ {
148 | samples[i] += int16(bytes[wavHeaderSize+i*2])
149 | samples[i] += int16(bytes[wavHeaderSize+i*2+1]) << 8
150 | }
151 | return samples
152 | }
153 |
154 | func TestPCM16ToByte(t *testing.T) {
155 | pcm := []int16{-32768, -12345, -1, 0, 1, 12345, 32767, 32767}
156 | bytearr := []byte{0, 128, 199, 207, 255, 255, 0, 0, 1, 0, 57, 48, 255, 127, 255, 127}
157 |
158 | output := make([]byte, len(pcm)*2)
159 | samplesInt16ToBytes(pcm, output)
160 | assert.Equal(t, bytearr, output)
161 |
162 | outputPcm := make([]int16, len(bytearr)/2)
163 | samplesByteToInt16(bytearr, outputPcm)
164 | assert.Equal(t, pcm, outputPcm)
165 | }
166 |
--------------------------------------------------------------------------------
/audio/wav.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package audio
5 |
6 | import (
7 | "encoding/binary"
8 | "io"
9 | )
10 |
11 | // WavWriteVoipPCM is normally 16 bit mono 8000 PCM
12 | func WavWriteVoipPCM(w io.Writer, audio []byte) (int, error) {
13 | return WavWrite(w, audio, WavWriteOpts{
14 | AudioFormat: 1,
15 | BitDepth: 16, // 16 bit
16 | NumChans: 1,
17 | SampleRate: 8000,
18 | })
19 | }
20 |
21 | type WavWriteOpts struct {
22 | SampleRate int
23 | BitDepth int
24 | NumChans int
25 | AudioFormat int
26 | }
27 |
28 | // WavWrite wrates WAV encoded to writter with the given audio payload, sample rate, and bit rate
29 | func WavWrite(w io.Writer, audio []byte, opts WavWriteOpts) (int, error) {
30 | // WAV header constants
31 | const (
32 | headerSize = 44
33 | fmtChunkSize = 16
34 | // audioFormat = 1 // PCM
35 | // numChannels = 1 // mono
36 | // bitsPerSample = 16
37 | )
38 |
39 | audioFormat := opts.AudioFormat
40 | numChannels := opts.NumChans
41 | bitsPerSample := opts.BitDepth
42 | sampleRate := opts.SampleRate
43 | // Calculate file size
44 | fileSize := len(audio) + headerSize - 8
45 |
46 | // Create the header
47 | header := make([]byte, headerSize)
48 | copy(header[0:4], []byte("RIFF"))
49 | binary.LittleEndian.PutUint32(header[4:8], uint32(fileSize))
50 | copy(header[8:12], []byte("WAVE"))
51 |
52 | // fmt subchunk
53 | copy(header[12:16], []byte("fmt "))
54 | binary.LittleEndian.PutUint32(header[16:20], fmtChunkSize)
55 | binary.LittleEndian.PutUint16(header[20:22], uint16(audioFormat))
56 | binary.LittleEndian.PutUint16(header[22:24], uint16(numChannels))
57 | binary.LittleEndian.PutUint32(header[24:28], uint32(sampleRate))
58 | binary.LittleEndian.PutUint32(header[28:32], uint32(sampleRate*bitsPerSample*numChannels/8)) // Byte rate calculation
59 | binary.LittleEndian.PutUint16(header[32:34], uint16(bitsPerSample*numChannels/8)) // Block align
60 | binary.LittleEndian.PutUint16(header[34:36], uint16(bitsPerSample))
61 |
62 | // data chunk
63 | copy(header[36:40], []byte("data"))
64 | binary.LittleEndian.PutUint32(header[40:44], uint32(len(audio)))
65 |
66 | // Combine header and audio payload
67 | wavFile := append(header, audio...)
68 | for N := 0; N < len(wavFile); {
69 | n, err := w.Write(wavFile)
70 | if err != nil {
71 | return 0, err
72 | }
73 | N += n
74 | }
75 | return len(wavFile), nil
76 | }
77 |
--------------------------------------------------------------------------------
/audio/wav_reader.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package audio
5 |
6 | import (
7 | "io"
8 |
9 | "github.com/go-audio/riff"
10 | )
11 |
12 | type WavReader struct {
13 | riff.Parser
14 | chunkData *riff.Chunk
15 | DataSize int
16 | }
17 |
18 | func NewWavReader(r io.Reader) *WavReader {
19 | parser := riff.New(r)
20 | reader := WavReader{Parser: *parser}
21 | return &reader
22 | }
23 |
24 | // ReadHeaders reads until data chunk
25 | func (r *WavReader) ReadHeaders() error {
26 | if err := r.readHeaders(); err != nil {
27 | return err
28 | }
29 |
30 | return r.readDataChunk()
31 | }
32 |
33 | func (r *WavReader) readHeaders() error {
34 | if err := r.Parser.ParseHeaders(); err != nil {
35 | return err
36 | }
37 | for {
38 | chunk, err := r.NextChunk()
39 | if err != nil {
40 | return err
41 | }
42 |
43 | if chunk.ID != riff.FmtID {
44 | chunk.Drain()
45 | continue
46 | }
47 | return chunk.DecodeWavHeader(&r.Parser)
48 | }
49 | }
50 |
51 | func (r *WavReader) readDataChunk() error {
52 | // if r.Size == 0 {
53 | // r.Parser.ParseHeaders()
54 | // }
55 |
56 | for {
57 | chunk, err := r.NextChunk()
58 | if err != nil {
59 | return err
60 | }
61 |
62 | if chunk.ID != riff.DataFormatID {
63 | chunk.Drain()
64 | continue
65 | }
66 | r.chunkData = chunk
67 | r.DataSize = chunk.Size
68 | return nil
69 | }
70 | }
71 |
72 | // Read returns PCM underneath
73 | func (r *WavReader) Read(buf []byte) (n int, err error) {
74 | if r.chunkData != nil {
75 | return r.chunkData.Read(buf)
76 | }
77 |
78 | if err := r.readDataChunk(); err != nil {
79 | return 0, err
80 | }
81 | return r.chunkData.Read(buf)
82 | }
83 |
--------------------------------------------------------------------------------
/audio/wav_writer.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package audio
5 |
6 | import (
7 | "encoding/binary"
8 | "io"
9 | )
10 |
11 | type WavWriter struct {
12 | SampleRate int
13 | BitDepth int
14 | NumChans int
15 | AudioFormat int
16 |
17 | W io.WriteSeeker
18 | headersWritten bool
19 | dataSize int64
20 | }
21 |
22 | func NewWavWriter(w io.WriteSeeker) *WavWriter {
23 | return &WavWriter{
24 | SampleRate: 8000,
25 | BitDepth: 16,
26 | NumChans: 2,
27 | AudioFormat: 1, // 1 PCM
28 | dataSize: 0,
29 | W: w,
30 | }
31 | }
32 |
33 | func (ww *WavWriter) Write(audio []byte) (int, error) {
34 | n, err := ww.writeData(audio)
35 | ww.dataSize += int64(n)
36 | return n, err
37 | }
38 |
39 | func (ww *WavWriter) writeData(audio []byte) (int, error) {
40 | w := ww.W
41 | if ww.headersWritten {
42 | return w.Write(audio)
43 | }
44 |
45 | _, err := ww.writeHeader()
46 | if err != nil {
47 | return 0, err
48 | }
49 | ww.headersWritten = true
50 |
51 | n, err := w.Write(audio)
52 | return n, err
53 | }
54 |
55 | func (ww *WavWriter) writeHeader() (int, error) {
56 | w := ww.W
57 | // WAV header constants
58 | const (
59 | headerSize = 44
60 | fmtChunkSize = 16
61 | )
62 |
63 | audioFormat := ww.AudioFormat
64 | numChannels := ww.NumChans
65 | bitsPerSample := ww.BitDepth
66 | sampleRate := ww.SampleRate
67 | // Calculate file size
68 | fileSize := ww.dataSize + headerSize - 8
69 |
70 | // Create the header
71 | header := make([]byte, headerSize)
72 | copy(header[0:4], []byte("RIFF"))
73 | binary.LittleEndian.PutUint32(header[4:8], uint32(fileSize))
74 | copy(header[8:12], []byte("WAVE"))
75 |
76 | // fmt subchunk
77 | copy(header[12:16], []byte("fmt "))
78 | binary.LittleEndian.PutUint32(header[16:20], fmtChunkSize)
79 | binary.LittleEndian.PutUint16(header[20:22], uint16(audioFormat))
80 | binary.LittleEndian.PutUint16(header[22:24], uint16(numChannels))
81 | binary.LittleEndian.PutUint32(header[24:28], uint32(sampleRate))
82 | binary.LittleEndian.PutUint32(header[28:32], uint32(sampleRate*bitsPerSample*numChannels/8)) // Byte rate calculation
83 | binary.LittleEndian.PutUint16(header[32:34], uint16(bitsPerSample*numChannels/8)) // Block align
84 | binary.LittleEndian.PutUint16(header[34:36], uint16(bitsPerSample))
85 |
86 | // data chunk
87 | copy(header[36:40], []byte("data"))
88 | binary.LittleEndian.PutUint32(header[40:44], uint32(ww.dataSize))
89 |
90 | // Combine header and audio payload
91 | return w.Write(header)
92 | }
93 |
94 | func (ww *WavWriter) Close() error {
95 | // It is needed to finalize and update wav
96 | _, err := ww.W.Seek(0, 0)
97 | if err != nil {
98 | return err
99 | }
100 | // Update header.
101 | _, err = ww.writeHeader()
102 | if err != nil {
103 | return err
104 | }
105 | return err
106 | }
107 |
--------------------------------------------------------------------------------
/audio/wav_writer_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package audio
5 |
6 | import (
7 | "bytes"
8 | "os"
9 | "testing"
10 |
11 | "github.com/go-audio/riff"
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestWavWriter(t *testing.T) {
17 | f, err := os.OpenFile("/tmp/test-waw-writer.wav", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0755)
18 | require.NoError(t, err)
19 | defer f.Close()
20 |
21 | w := NewWavWriter(f)
22 | n, err := w.Write(bytes.Repeat([]byte{1}, 100))
23 | require.NoError(t, err)
24 | require.Equal(t, 100, n)
25 |
26 | f.Seek(0, 0)
27 |
28 | p := riff.New(f)
29 | err = p.ParseHeaders()
30 | require.NoError(t, err)
31 |
32 | for {
33 | chunk, err := p.NextChunk()
34 | require.NoError(t, err)
35 |
36 | if chunk.ID != riff.FmtID {
37 | chunk.Drain()
38 | continue
39 | }
40 | err = chunk.DecodeWavHeader(p)
41 | require.NoError(t, err)
42 | break
43 | }
44 |
45 | assert.EqualValues(t, 8000, p.SampleRate)
46 | assert.EqualValues(t, 100, w.dataSize)
47 | }
48 |
--------------------------------------------------------------------------------
/bridge.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "errors"
8 | "fmt"
9 | "io"
10 | "log/slog"
11 | "time"
12 |
13 | "github.com/emiago/diago/media"
14 | )
15 |
16 | type Bridger interface {
17 | AddDialogSession(d DialogSession) error
18 | }
19 |
20 | type Bridge struct {
21 | // Originator is dialog session that created bridge
22 | Originator DialogSession
23 | // DTMFpass is also dtmf pipeline and proxy. By default only audio media is proxied
24 | DTMFpass bool
25 |
26 | log *slog.Logger
27 | // TODO: RTPpass. RTP pass means that RTP will be proxied.
28 | // This gives high performance but you can not attach any pipeline in media processing
29 | // RTPpass bool
30 |
31 | dialogs []DialogSession
32 |
33 | // minDialogs is just helper flag when to start proxy
34 | waitDialogsNum int
35 | }
36 |
37 | func NewBridge() Bridge {
38 | b := Bridge{}
39 | b.Init(slog.Default())
40 | return b
41 | }
42 |
43 | func (b *Bridge) Init(log *slog.Logger) {
44 | b.log = log
45 | b.waitDialogsNum = 2
46 | }
47 |
48 | func (b *Bridge) GetDialogs() []DialogSession {
49 | return b.dialogs
50 | }
51 |
52 | func (b *Bridge) AddDialogSession(d DialogSession) error {
53 | // Check can this dialog be added to bridge. NO TRANSCODING
54 | if b.Originator != nil {
55 | // This may look ugly but it is safe way of reading
56 | origM := b.Originator.Media()
57 | origProps := MediaProps{}
58 | _ = origM.audioWriterProps(&origProps)
59 |
60 | m := d.Media()
61 | mprops := MediaProps{}
62 | _ = m.audioWriterProps(&mprops)
63 |
64 | err := func() error {
65 | if origProps.Codec != mprops.Codec {
66 | return fmt.Errorf("no transcoding supported in bridge codec1=%+v codec2=%+v", origProps.Codec, mprops.Codec)
67 | }
68 | return nil
69 | }()
70 | if err != nil {
71 | return err
72 | }
73 | }
74 |
75 | b.dialogs = append(b.dialogs, d)
76 | if len(b.dialogs) == 1 {
77 | b.Originator = d
78 | }
79 |
80 | if len(b.dialogs) < b.waitDialogsNum {
81 | return nil
82 | }
83 |
84 | if len(b.dialogs) > 2 {
85 | return fmt.Errorf("currently bridge only support 2 party")
86 | }
87 | // Check are both answered
88 | for _, d := range b.dialogs {
89 | // TODO remove this double locking. Read once
90 | if d.Media().RTPPacketReader == nil || d.Media().RTPPacketWriter == nil {
91 | return fmt.Errorf("dialog session not answered %q", d.Id())
92 | }
93 | }
94 |
95 | go func() {
96 | defer func(start time.Time) {
97 | b.log.Info("Proxy media setup", "dur", time.Since(start).String())
98 | }(time.Now())
99 | if err := b.proxyMedia(); err != nil {
100 | if errors.Is(err, io.EOF) {
101 | return
102 | }
103 | b.log.Error("Proxy media stopped", "error", err)
104 | }
105 | }()
106 | return nil
107 | }
108 |
109 | // proxyMedia starts routine to proxy media between
110 | // Should be called after having 2 or more participants
111 | func (b *Bridge) proxyMedia() error {
112 | var err error
113 | log := b.log
114 |
115 | m1 := b.dialogs[0].Media()
116 | m2 := b.dialogs[1].Media()
117 |
118 | // Lets for now simplify proxy and later optimize
119 |
120 | if b.DTMFpass {
121 | errCh := make(chan error, 4)
122 | go func() {
123 | errCh <- b.proxyMediaWithDTMF(m1, m2)
124 | }()
125 |
126 | go func() {
127 | errCh <- b.proxyMediaWithDTMF(m2, m1)
128 | }()
129 |
130 | // Wait for all to finish
131 | for i := 0; i < 2; i++ {
132 | err = errors.Join(err, <-errCh)
133 | }
134 | return err
135 | }
136 | errCh := make(chan error, 2)
137 | func() {
138 | p1, p2 := MediaProps{}, MediaProps{}
139 | r := m1.audioReaderProps(&p1)
140 | w := m2.audioWriterProps(&p2)
141 |
142 | log.Debug("Starting proxy media routine", "from", p1.Raddr+" > "+p1.Laddr, "to", p2.Laddr+" > "+p2.Raddr)
143 | go proxyMediaBackground(log, r, w, errCh)
144 | }()
145 |
146 | // Second
147 | func() {
148 | p1, p2 := MediaProps{}, MediaProps{}
149 | r := m2.audioReaderProps(&p1)
150 | w := m1.audioWriterProps(&p2)
151 | log.Debug("Starting proxy media routine", "from", p1.Raddr+" > "+p1.Laddr, "to", p2.Laddr+" > "+p2.Raddr)
152 | go proxyMediaBackground(log, r, w, errCh)
153 | }()
154 |
155 | // Wait for all to finish
156 | for i := 0; i < 2; i++ {
157 | err = errors.Join(err, <-errCh)
158 | }
159 | return err
160 | }
161 |
162 | func proxyMediaBackground(log *slog.Logger, reader io.Reader, writer io.Writer, ch chan error) {
163 | buf := rtpBufPool.Get()
164 | defer rtpBufPool.Put(buf)
165 |
166 | written, err := copyWithBuf(reader, writer, buf.([]byte))
167 | log.Debug("Bridge proxy stream finished", "bytes", written)
168 | ch <- err
169 | }
170 |
171 | func (b *Bridge) proxyMediaWithDTMF(m1 *DialogMedia, m2 *DialogMedia) error {
172 | dtmfReader := DTMFReader{}
173 | p1, p2 := MediaProps{}, MediaProps{}
174 | r, err := m1.AudioReader(WithAudioReaderDTMF(&dtmfReader), WithAudioReaderMediaProps(&p1))
175 | if err != nil {
176 | return err
177 | }
178 | dtmfWriter := DTMFWriter{}
179 | w, err := m2.AudioWriter(WithAudioWriterDTMF(&dtmfWriter), WithAudioWriterMediaProps(&p2))
180 | if err != nil {
181 | return err
182 | }
183 | dtmfReader.OnDTMF(func(dtmf rune) error {
184 | return dtmfWriter.WriteDTMF(dtmf)
185 | })
186 |
187 | buf := rtpBufPool.Get()
188 | defer rtpBufPool.Put(buf)
189 |
190 | log := b.log
191 | log.Debug("Starting proxy media routine", "from", p1.Raddr+" > "+p1.Laddr, "to", p2.Laddr+" > "+p2.Raddr)
192 | written, err := copyWithBuf(r, w, buf.([]byte))
193 | log.Debug("Bridge proxy stream finished", "bytes", written)
194 | return err
195 | }
196 |
197 | func (b *Bridge) proxyMediaRTPRaw(m1 media.RTPReaderRaw, m2 media.RTPWriterRaw) (written int64, e error) {
198 | buf := make([]byte, 1500) // MTU
199 |
200 | var total int64
201 | for {
202 | // In case of recording we need to unmarshal RTP packet
203 | n, err := m1.ReadRTPRaw(buf)
204 | if err != nil {
205 | return total, err
206 | }
207 | written, err := m2.WriteRTPRaw(buf[:n])
208 | if err != nil {
209 | return total, err
210 | }
211 | if written != n {
212 | return total, io.ErrShortWrite
213 | }
214 | total += int64(written)
215 | }
216 | }
217 |
218 | func (b *Bridge) proxyMediaRTCP(m1 *media.MediaSession, m2 *media.MediaSession) (written int64, e error) {
219 | buf := make([]byte, 1500) // MTU
220 |
221 | var total int64
222 | for {
223 | // In case of recording we need to unmarshal RTP packet
224 | n, err := m1.ReadRTCPRaw(buf)
225 | if err != nil {
226 | return total, err
227 | }
228 | written, err := m2.WriteRTCPRaw(buf[:n])
229 | if err != nil {
230 | return total, err
231 | }
232 | if written != n {
233 | return total, io.ErrShortWrite
234 | }
235 | total += int64(written)
236 | }
237 | }
238 |
239 | func (b *Bridge) proxyMediaRTCPRaw(m1 media.RTPCReaderRaw, m2 media.RTCPWriterRaw) (written int64, e error) {
240 | buf := make([]byte, 1500) // MTU
241 |
242 | var total int64
243 | for {
244 | // In case of recording we need to unmarshal RTP packet
245 | n, err := m1.ReadRTCPRaw(buf)
246 | if err != nil {
247 | return total, err
248 | }
249 | written, err := m2.WriteRTCPRaw(buf[:n])
250 | if err != nil {
251 | return total, err
252 | }
253 | if written != n {
254 | return total, io.ErrShortWrite
255 | }
256 | total += int64(written)
257 | }
258 | }
259 |
--------------------------------------------------------------------------------
/bridge_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "bytes"
8 | "io"
9 | "testing"
10 |
11 | "github.com/emiago/diago/media"
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestBridgeProxy(t *testing.T) {
17 | b := NewBridge()
18 | b.waitDialogsNum = 99 // Do not start proxy
19 |
20 | incoming := &DialogServerSession{
21 | DialogMedia: DialogMedia{
22 | mediaSession: &media.MediaSession{
23 | Codecs: []media.Codec{media.CodecAudioAlaw},
24 | },
25 | audioReader: bytes.NewBuffer(make([]byte, 9999)),
26 | audioWriter: bytes.NewBuffer(make([]byte, 0)),
27 | RTPPacketReader: media.NewRTPPacketReader(nil, media.CodecAudioAlaw),
28 | RTPPacketWriter: media.NewRTPPacketWriter(nil, media.CodecAudioAlaw),
29 | },
30 | }
31 | outgoing := &DialogClientSession{
32 | DialogMedia: DialogMedia{
33 | mediaSession: &media.MediaSession{
34 | Codecs: []media.Codec{media.CodecAudioAlaw},
35 | },
36 | audioReader: bytes.NewBuffer(make([]byte, 9999)),
37 | audioWriter: bytes.NewBuffer(make([]byte, 0)),
38 | RTPPacketReader: media.NewRTPPacketReader(nil, media.CodecAudioAlaw),
39 | RTPPacketWriter: media.NewRTPPacketWriter(nil, media.CodecAudioAlaw),
40 | },
41 | }
42 |
43 | err := b.AddDialogSession(incoming)
44 | require.NoError(t, err)
45 | err = b.AddDialogSession(outgoing)
46 | require.NoError(t, err)
47 |
48 | err = b.proxyMedia()
49 | require.ErrorIs(t, err, io.EOF)
50 |
51 | // Confirm all data is proxied
52 | assert.Equal(t, 9999, incoming.audioWriter.(*bytes.Buffer).Len())
53 | assert.Equal(t, 9999, outgoing.audioWriter.(*bytes.Buffer).Len())
54 | }
55 |
56 | func TestBridgeNoTranscodingAllowed(t *testing.T) {
57 | b := NewBridge()
58 | // b.waitDialogsNum = 99 // Do not start proxy
59 |
60 | incoming := &DialogServerSession{
61 | DialogMedia: DialogMedia{
62 | mediaSession: &media.MediaSession{
63 | Codecs: []media.Codec{media.CodecAudioAlaw},
64 | },
65 | // RTPPacketReader: media.NewRTPPacketReader(nil, media.CodecAudioAlaw),
66 | // RTPPacketWriter: media.NewRTPPacketWriter(nil, media.CodecAudioAlaw),
67 | },
68 | }
69 | outgoing := &DialogClientSession{
70 | DialogMedia: DialogMedia{
71 | mediaSession: &media.MediaSession{
72 | Codecs: []media.Codec{media.CodecAudioUlaw},
73 | },
74 | // RTPPacketReader: media.NewRTPPacketReader(nil, media.CodecAudioUlaw),
75 | // RTPPacketWriter: media.NewRTPPacketWriter(nil, media.CodecAudioUlaw),
76 | },
77 | }
78 |
79 | err := b.AddDialogSession(incoming)
80 | require.NoError(t, err)
81 | err = b.AddDialogSession(outgoing)
82 | require.Error(t, err)
83 | }
84 |
--------------------------------------------------------------------------------
/diago_test.go:
--------------------------------------------------------------------------------
1 | package diago
2 |
3 | import (
4 | "context"
5 | "net"
6 | "sync"
7 | "testing"
8 |
9 | "github.com/emiago/diago/examples"
10 | "github.com/emiago/diago/media/sdp"
11 | "github.com/emiago/sipgo"
12 | "github.com/emiago/sipgo/sip"
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func testDiagoClient(t *testing.T, onRequest func(req *sip.Request) *sip.Response, opts ...DiagoOption) *Diago {
18 | // Create client transaction request
19 | cTxReq := &clientTxRequester{
20 | onRequest: onRequest,
21 | }
22 |
23 | ua, _ := sipgo.NewUA()
24 | client, _ := sipgo.NewClient(ua)
25 | client.TxRequester = cTxReq
26 | t.Cleanup(func() {
27 | ua.Close()
28 | })
29 |
30 | opts = append(opts, WithClient(client))
31 | return NewDiago(ua, opts...)
32 | }
33 |
34 | func TestMain(m *testing.M) {
35 | examples.SetupLogger()
36 | m.Run()
37 | }
38 |
39 | func TestDiagoRegister(t *testing.T) {
40 | dg := testDiagoClient(t, func(req *sip.Request) *sip.Response {
41 | sync.OnceFunc(func() {
42 | sip.NewResponseFromRequest(req, 100, "Trying", nil)
43 | })()
44 |
45 | return sip.NewResponseFromRequest(req, 200, "OK", nil)
46 | })
47 |
48 | ctx := context.TODO()
49 | rtx, err := dg.RegisterTransaction(ctx, sip.Uri{User: "alice", Host: "localhost"}, RegisterOptions{})
50 | require.NoError(t, err)
51 |
52 | err = rtx.Register(ctx)
53 | require.NoError(t, err)
54 | }
55 |
56 | func TestDiagoRegisterAuthorization(t *testing.T) {
57 | t.Skip("Do test with sending Register and authorization returned")
58 | }
59 |
60 | func TestDiagoInviteCallerID(t *testing.T) {
61 |
62 | t.Run("NoSDPInResponse", func(t *testing.T) {
63 | dg := testDiagoClient(t, func(req *sip.Request) *sip.Response {
64 | return sip.NewResponseFromRequest(req, 200, "OK", nil)
65 | })
66 |
67 | _, err := dg.Invite(context.Background(), sip.Uri{User: "alice", Host: "localhost"}, InviteOptions{})
68 | if assert.Error(t, err) {
69 | assert.Equal(t, "no SDP in response", err.Error())
70 | }
71 | })
72 |
73 | reqCh := make(chan *sip.Request)
74 | dg := testDiagoClient(t, func(req *sip.Request) *sip.Response {
75 | reqCh <- req
76 | return sip.NewResponseFromRequest(req, 500, "", nil)
77 | })
78 |
79 | t.Run("DefaultCallerID", func(t *testing.T) {
80 | go dg.Invite(context.Background(), sip.Uri{User: "alice", Host: "localhost"}, InviteOptions{})
81 | req := <-reqCh
82 |
83 | assert.Equal(t, dg.ua.Name(), req.From().Address.User)
84 | assert.Equal(t, dg.ua.Hostname(), req.From().Address.Host)
85 | assert.NotEmpty(t, req.From().Params["tag"])
86 | })
87 |
88 | }
89 |
90 | func TestDiagoTransportConfs(t *testing.T) {
91 | type testCase = struct {
92 | tran Transport
93 | expectedContactHostPort string
94 | expectedMediaHost string
95 | }
96 |
97 | doTest := func(tc testCase) {
98 | tran := tc.tran
99 | reqCh := make(chan *sip.Request)
100 | dg := testDiagoClient(t, func(req *sip.Request) *sip.Response {
101 | reqCh <- req
102 | return sip.NewResponseFromRequest(req, 200, "OK", nil)
103 | }, WithTransport(tran))
104 |
105 | go dg.Invite(context.TODO(), sip.Uri{User: "alice", Host: "localhost"}, InviteOptions{})
106 |
107 | // Now check our req passed on client
108 | req := <-reqCh
109 |
110 | // parse SDP
111 | sd := sdp.SessionDescription{}
112 | require.NoError(t, sdp.Unmarshal(req.Body(), &sd))
113 | connInfo, err := sd.ConnectionInformation()
114 | require.NoError(t, err)
115 |
116 | assert.Equal(t, tc.expectedContactHostPort, req.Contact().Address.HostPort())
117 | assert.Equal(t, tc.expectedMediaHost, connInfo.IP.String())
118 | }
119 |
120 | t.Run("ExternalHost", func(t *testing.T) {
121 | tc := testCase{
122 | tran: Transport{
123 | Transport: "udp",
124 | BindHost: "127.0.0.111",
125 | BindPort: 15060,
126 | ExternalHost: "1.2.3.4",
127 | },
128 | expectedContactHostPort: "1.2.3.4:15060",
129 | expectedMediaHost: "1.2.3.4",
130 | }
131 |
132 | doTest(tc)
133 | })
134 |
135 | t.Run("ExternalHostFQDN", func(t *testing.T) {
136 | tc := testCase{
137 | tran: Transport{
138 | Transport: "udp",
139 | BindHost: "127.0.0.111",
140 | BindPort: 15060,
141 | ExternalHost: "myhost.pbx.com",
142 | },
143 | expectedContactHostPort: "myhost.pbx.com:15060",
144 | expectedMediaHost: "127.0.0.111", // Hosts are not resolved so it goes with bind
145 | }
146 |
147 | doTest(tc)
148 | })
149 |
150 | t.Run("ExternalHostFQDNExternalMedia", func(t *testing.T) {
151 | tc := testCase{
152 | tran: Transport{
153 | Transport: "udp",
154 | BindHost: "127.0.0.111",
155 | BindPort: 15060,
156 | ExternalHost: "myhost.pbx.com",
157 | MediaExternalIP: net.IPv4(1, 2, 3, 4),
158 | },
159 | expectedContactHostPort: "myhost.pbx.com:15060",
160 | expectedMediaHost: "1.2.3.4", // Hosts are not resolved so it goes with bind
161 | }
162 |
163 | doTest(tc)
164 | })
165 | }
166 |
167 | func TestDiagoNewDialog(t *testing.T) {
168 | dg := testDiagoClient(t, func(req *sip.Request) *sip.Response {
169 | body := sdp.GenerateForAudio(net.IPv4(127, 0, 0, 1), net.IPv4(127, 0, 0, 1), 34455, sdp.ModeSendrecv, []string{sdp.FORMAT_TYPE_ALAW})
170 | return sip.NewResponseFromRequest(req, 200, "OK", body)
171 | })
172 | ctx := context.TODO()
173 |
174 | t.Run("CloseNoError", func(t *testing.T) {
175 | dialog, err := dg.NewDialog(sip.Uri{User: "alice", Host: "localhost"}, NewDialogOptions{})
176 | require.NoError(t, err)
177 | dialog.Close()
178 | })
179 |
180 | // t.Run("NoAcked", func(t *testing.T) {
181 | // dialog, err := dg.NewDialog( sip.Uri{User: "alice", Host: "localhost"}, NewDialogOpts{})
182 | // require.NoError(t, err)
183 | // defer dialog.Close()
184 |
185 | // err = dialog.Invite(ctx, InviteOptions{})
186 | // require.NoError(t, err)
187 |
188 | // dialog.Audio
189 | // })
190 |
191 | t.Run("FullDialog", func(t *testing.T) {
192 | dialog, err := dg.NewDialog(sip.Uri{User: "alice", Host: "localhost"}, NewDialogOptions{})
193 | require.NoError(t, err)
194 | defer dialog.Close()
195 |
196 | err = dialog.Invite(ctx, InviteClientOptions{})
197 | require.NoError(t, err)
198 | assert.NotEmpty(t, dialog.ID)
199 |
200 | err = dialog.Ack(ctx)
201 | require.NoError(t, err)
202 |
203 | // assert.NotEmpty(t, dialog.ID)
204 | })
205 |
206 | // _, err := dg.Invite(context.Background(), sip.Uri{User: "alice", Host: "localhost"}, InviteOptions{})
207 | // if assert.Error(t, err) {
208 | // assert.Equal(t, "no SDP in response", err.Error())
209 | // }
210 | }
211 |
212 | func TestIntegrationDiagoTransportEmpheralPort(t *testing.T) {
213 | tran := Transport{
214 | Transport: "udp",
215 | BindHost: "127.0.0.1",
216 | BindPort: 0,
217 | }
218 |
219 | ua, _ := sipgo.NewUA()
220 | defer ua.Close()
221 |
222 | dg := NewDiago(ua, WithTransport(tran))
223 |
224 | err := dg.ServeBackground(context.TODO(), func(d *DialogServerSession) {})
225 | require.NoError(t, err)
226 |
227 | newTran, _ := dg.getTransport("udp")
228 | t.Log("port assigned", newTran.BindPort)
229 | assert.NotEmpty(t, newTran.BindPort)
230 | }
231 |
--------------------------------------------------------------------------------
/diago_test_utils.go:
--------------------------------------------------------------------------------
1 | package diago
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "net"
7 | "sync/atomic"
8 |
9 | "github.com/emiago/sipgo/sip"
10 | )
11 |
12 | type connRecorder struct {
13 | msgs []sip.Message
14 |
15 | ref atomic.Int32
16 | }
17 |
18 | func NewConnRecorder() *connRecorder {
19 | return &connRecorder{}
20 | }
21 |
22 | func (c *connRecorder) LocalAddr() net.Addr {
23 | return nil
24 | }
25 |
26 | func (c *connRecorder) WriteMsg(msg sip.Message) error {
27 | c.msgs = append(c.msgs, msg)
28 | return nil
29 | }
30 | func (c *connRecorder) Ref(i int) int {
31 | return int(c.ref.Add(int32(i)))
32 | }
33 | func (c *connRecorder) TryClose() (int, error) {
34 | new := c.ref.Add(int32(-1))
35 | return int(new), nil
36 | }
37 | func (c *connRecorder) Close() error { return nil }
38 |
39 | type clientTxRequester struct {
40 | // rec *siptest.ClientTxRecorder
41 | onRequest func(req *sip.Request) *sip.Response
42 | }
43 |
44 | func (r *clientTxRequester) Request(ctx context.Context, req *sip.Request) (sip.ClientTransaction, error) {
45 | key, _ := sip.MakeClientTxKey(req)
46 | rec := NewConnRecorder()
47 | tx := sip.NewClientTx(key, req, rec, slog.Default())
48 | if err := tx.Init(); err != nil {
49 | return nil, err
50 | }
51 |
52 | resp := r.onRequest(req)
53 | go tx.Receive(resp)
54 |
55 | return tx, nil
56 | }
57 |
--------------------------------------------------------------------------------
/dialog_cache.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "context"
8 | "errors"
9 | "sync"
10 |
11 | "github.com/emiago/sipgo"
12 | "github.com/emiago/sipgo/sip"
13 | )
14 |
15 | type DialogCache[T DialogSession] interface {
16 | DialogStore(ctx context.Context, id string, v T) error
17 | DialogLoad(ctx context.Context, id string) (T, error)
18 | DialogDelete(ctx context.Context, id string) error
19 | DialogRange(ctx context.Context, f func(id string, d T) bool) error
20 | }
21 |
22 | // Non optimized for now
23 | type dialogCacheMap[T DialogSession] struct{ sync.Map }
24 |
25 | func (m *dialogCacheMap[T]) DialogStore(ctx context.Context, id string, v T) error {
26 | m.Store(id, v)
27 | return nil
28 | }
29 |
30 | func (m *dialogCacheMap[T]) DialogDelete(ctx context.Context, id string) error {
31 | m.Delete(id)
32 | return nil
33 | }
34 |
35 | func (m *dialogCacheMap[T]) DialogLoad(ctx context.Context, id string) (dialog T, err error) {
36 | d, ok := m.Load(id)
37 | if !ok {
38 | return dialog, sipgo.ErrDialogDoesNotExists
39 | }
40 | // interface to interface conversion is slow
41 | return d.(T), nil
42 | }
43 |
44 | func (m *dialogCacheMap[T]) DialogRange(ctx context.Context, f func(id string, d T) bool) error {
45 | m.Range(func(key, value any) bool {
46 | return f(key.(string), value.(T))
47 | })
48 | return nil
49 | }
50 |
51 | type DialogData struct {
52 | InviteRequest sip.Request
53 | State sip.DialogState
54 | }
55 |
56 | type DialogCachePool struct {
57 | client DialogCache[*DialogClientSession]
58 | server DialogCache[*DialogServerSession]
59 | }
60 |
61 | func (p *DialogCachePool) MatchDialogClient(req *sip.Request) (*DialogClientSession, error) {
62 | id, err := sip.UACReadRequestDialogID(req)
63 | if err != nil {
64 | return nil, errors.Join(err, sipgo.ErrDialogOutsideDialog)
65 | }
66 |
67 | val, err := p.client.DialogLoad(context.Background(), id)
68 | if err != nil {
69 | return nil, err
70 | }
71 |
72 | return val, nil
73 | }
74 |
75 | func (p *DialogCachePool) MatchDialogServer(req *sip.Request) (*DialogServerSession, error) {
76 | id, err := sip.UASReadRequestDialogID(req)
77 | if err != nil {
78 | return nil, errors.Join(err, sipgo.ErrDialogOutsideDialog)
79 | }
80 |
81 | val, err := p.server.DialogLoad(context.Background(), id)
82 | if err != nil {
83 | return nil, err
84 | }
85 |
86 | return val, nil
87 | }
88 |
89 | func (p *DialogCachePool) MatchDialog(req *sip.Request) (*DialogServerSession, *DialogClientSession, error) {
90 | d, err := p.MatchDialogServer(req)
91 | if err != nil {
92 | if !errors.Is(err, sipgo.ErrDialogDoesNotExists) {
93 | return nil, nil, err
94 | }
95 |
96 | cd, err := p.MatchDialogClient(req)
97 | return nil, cd, err
98 | }
99 | return d, nil, nil
100 | }
101 |
--------------------------------------------------------------------------------
/dialog_client_session_test.go:
--------------------------------------------------------------------------------
1 | package diago
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/emiago/sipgo"
8 | "github.com/emiago/sipgo/sip"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestIntegrationDialogClientReinvite(t *testing.T) {
14 | ctx, cancel := context.WithCancel(context.Background())
15 | defer cancel()
16 |
17 | {
18 | ua, _ := sipgo.NewUA(sipgo.WithUserAgent("server"))
19 | defer ua.Close()
20 |
21 | dg := NewDiago(ua, WithTransport(
22 | Transport{
23 | Transport: "udp",
24 | BindHost: "127.0.0.1",
25 | BindPort: 15060,
26 | },
27 | ))
28 | err := dg.ServeBackground(ctx, func(d *DialogServerSession) {
29 | t.Log("Call received")
30 | d.AnswerOptions(AnswerOptions{OnMediaUpdate: func(d *DialogMedia) {
31 |
32 | }})
33 | <-d.Context().Done()
34 | })
35 | require.NoError(t, err)
36 | }
37 |
38 | ua, _ := sipgo.NewUA()
39 | defer ua.Close()
40 |
41 | dg := newDialer(ua)
42 | err := dg.ServeBackground(context.TODO(), func(d *DialogServerSession) {})
43 | require.NoError(t, err)
44 |
45 | dialog, err := dg.Invite(ctx, sip.Uri{User: "dialer", Host: "127.0.0.1", Port: 15060}, InviteOptions{})
46 | require.NoError(t, err)
47 |
48 | err = dialog.ReInvite(ctx)
49 | require.NoError(t, err)
50 |
51 | dialog.Hangup(ctx)
52 | }
53 |
54 | func TestDialogClientInvite(t *testing.T) {
55 | reqCh := make(chan *sip.Request)
56 | dg := testDiagoClient(t, func(req *sip.Request) *sip.Response {
57 | reqCh <- req
58 | return sip.NewResponseFromRequest(req, 500, "", nil)
59 | })
60 |
61 | t.Run("WithCallerid", func(t *testing.T) {
62 | opts := InviteClientOptions{}
63 | opts.WithCaller("Test", "123456", "example.com")
64 | dialog, err := dg.NewDialog(sip.Uri{User: "alice", Host: "localhost"}, NewDialogOptions{})
65 | require.NoError(t, err)
66 | go dialog.Invite(context.Background(), opts)
67 | req := <-reqCh
68 | assert.Equal(t, "Test", req.From().DisplayName)
69 | assert.Equal(t, "123456", req.From().Address.User)
70 | assert.NotEmpty(t, req.From().Params["tag"])
71 | })
72 |
73 | t.Run("WithAnonymous", func(t *testing.T) {
74 | opts := InviteClientOptions{}
75 | opts.WithAnonymousCaller()
76 | dialog, err := dg.NewDialog(sip.Uri{User: "alice", Host: "localhost"}, NewDialogOptions{})
77 | require.NoError(t, err)
78 | go dialog.Invite(context.Background(), opts)
79 | req := <-reqCh
80 | assert.Equal(t, "Anonymous", req.From().DisplayName)
81 | assert.Equal(t, "anonymous", req.From().Address.User)
82 | assert.NotEmpty(t, req.From().Params["tag"])
83 | })
84 | }
85 |
--------------------------------------------------------------------------------
/dialog_server_session_test.go:
--------------------------------------------------------------------------------
1 | package diago
2 |
3 | import (
4 | "context"
5 | "sync"
6 | "testing"
7 | "time"
8 |
9 | "github.com/emiago/diago/media"
10 | "github.com/emiago/sipgo"
11 | "github.com/emiago/sipgo/sip"
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestIntegrationDialogServerEarlyMedia(t *testing.T) {
17 | ctx, cancel := context.WithCancel(context.Background())
18 | defer cancel()
19 |
20 | var dialer *Diago
21 | {
22 | ua, _ := sipgo.NewUA(sipgo.WithUserAgent("server"))
23 | defer ua.Close()
24 |
25 | dg := NewDiago(ua, WithTransport(
26 | Transport{
27 | Transport: "udp",
28 | BindHost: "127.0.0.1",
29 | BindPort: 15020,
30 | },
31 | ))
32 |
33 | // Run listener to accepte reinvites, but it should not receive any request
34 | err := dg.ServeBackground(ctx, nil)
35 | require.NoError(t, err)
36 |
37 | dialer = dg
38 | }
39 |
40 | ua, _ := sipgo.NewUA()
41 | defer ua.Close()
42 |
43 | dg := NewDiago(ua, WithTransport(
44 | Transport{
45 | Transport: "udp",
46 | BindHost: "127.0.0.1",
47 | BindPort: 15010,
48 | },
49 | ))
50 |
51 | waitDialog := make(chan *DialogServerSession)
52 | err := dg.ServeBackground(ctx, func(d *DialogServerSession) {
53 | t.Log("Call received")
54 | waitDialog <- d
55 | <-d.Context().Done()
56 | })
57 | require.NoError(t, err)
58 |
59 | allResponses := []sip.Response{}
60 | wg := sync.WaitGroup{}
61 | wg.Add(1)
62 | go func() {
63 | defer wg.Done()
64 | dialog, err := dialer.Invite(ctx, sip.Uri{User: "dialer", Host: "127.0.0.1", Port: 15010}, InviteOptions{
65 | OnResponse: func(res *sip.Response) error {
66 | allResponses = append(allResponses, *res.Clone())
67 | return nil
68 | },
69 | })
70 | if err != nil {
71 | t.Log("Failed to dial", err)
72 | return
73 | }
74 | defer dialog.Close()
75 | <-dialog.Context().Done()
76 | t.Log("Dialog done")
77 | }()
78 |
79 | d := <-waitDialog
80 |
81 | err = d.ProgressMedia()
82 | require.NoError(t, err)
83 |
84 | // It is valid to also send 180
85 | time.Sleep(500 * time.Millisecond)
86 | require.NoError(t, d.Ringing())
87 |
88 | // We can play some file ringtone
89 | playback, err := d.PlaybackCreate()
90 | require.NoError(t, err)
91 | _, err = playback.PlayFile("testdata/files/demo-echodone.wav")
92 | require.NoError(t, err)
93 |
94 | // We can now answer
95 | err = d.Answer()
96 | require.NoError(t, err)
97 |
98 | // New playback is needed to follow new media session
99 | playback, err = d.PlaybackCreate()
100 | require.NoError(t, err)
101 | _, err = playback.PlayFile("testdata/files/demo-echodone.wav")
102 | require.NoError(t, err)
103 | d.Hangup(context.TODO())
104 |
105 | wg.Wait()
106 | assert.Equal(t, 183, allResponses[0].StatusCode)
107 | assert.Equal(t, 180, allResponses[1].StatusCode)
108 | assert.Equal(t, 200, allResponses[2].StatusCode)
109 | }
110 |
111 | func TestIntegrationDialogServerReinvite(t *testing.T) {
112 | ctx, cancel := context.WithCancel(context.Background())
113 | defer cancel()
114 |
115 | {
116 | ua, _ := sipgo.NewUA(sipgo.WithUserAgent("server"))
117 | defer ua.Close()
118 |
119 | dg := NewDiago(ua, WithTransport(
120 | Transport{
121 | Transport: "udp",
122 | BindHost: "127.0.0.1",
123 | BindPort: 15070,
124 | },
125 | ))
126 |
127 | // Run listener to accepte reinvites, but it should not receive any request
128 | err := dg.ServeBackground(ctx, nil)
129 | require.NoError(t, err)
130 |
131 | go func() {
132 | dialog, err := dg.Invite(ctx, sip.Uri{User: "dialer", Host: "127.0.0.1", Port: 15060}, InviteOptions{})
133 | require.NoError(t, err)
134 | <-dialog.Context().Done()
135 | t.Log("Dialog done")
136 | }()
137 | }
138 |
139 | ua, _ := sipgo.NewUA()
140 | defer ua.Close()
141 |
142 | dg := NewDiago(ua, WithTransport(
143 | Transport{
144 | Transport: "udp",
145 | BindHost: "127.0.0.1",
146 | BindPort: 15060,
147 | },
148 | ))
149 |
150 | waitDialog := make(chan *DialogServerSession)
151 | err := dg.ServeBackground(ctx, func(d *DialogServerSession) {
152 | t.Log("Call received")
153 | waitDialog <- d
154 | <-d.Context().Done()
155 | })
156 | require.NoError(t, err)
157 | d := <-waitDialog
158 |
159 | err = d.Answer()
160 | require.NoError(t, err)
161 | err = d.ReInvite(d.Context())
162 | require.NoError(t, err)
163 |
164 | d.Hangup(context.TODO())
165 | }
166 |
167 | func TestIntegrationDialogServerPlayback(t *testing.T) {
168 | rtpBuf := newRTPWriterBuffer()
169 | dialog := &DialogServerSession{
170 | DialogMedia: DialogMedia{
171 | mediaSession: &media.MediaSession{Codecs: []media.Codec{media.CodecAudioUlaw}},
172 | RTPPacketWriter: media.NewRTPPacketWriter(rtpBuf, media.CodecAudioUlaw),
173 | },
174 | }
175 |
176 | playback, err := dialog.PlaybackCreate()
177 | require.NoError(t, err)
178 |
179 | initTS := dialog.RTPPacketWriter.InitTimestamp()
180 | _, err = playback.PlayFile("testdata/files/demo-echodone.wav")
181 | require.NoError(t, err)
182 | diffTS := dialog.RTPPacketWriter.PacketHeader.Timestamp - initTS
183 | assert.Greater(t, diffTS, uint32(1000))
184 |
185 | time.Sleep(100 * time.Millisecond) // 4 frames
186 | initTS = dialog.RTPPacketWriter.InitTimestamp()
187 | _, err = playback.PlayFile("testdata/files/demo-echodone.wav")
188 | require.NoError(t, err)
189 | diffTS2 := dialog.RTPPacketWriter.PacketHeader.Timestamp - initTS
190 | t.Log(initTS, diffTS2)
191 |
192 | // Timestamp should be offset more than previous diff by Sleep
193 | assert.Greater(t, diffTS2, diffTS+5*media.CodecAudioUlaw.SampleTimestamp())
194 | }
195 |
--------------------------------------------------------------------------------
/dialog_session.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "context"
8 | "fmt"
9 | "log/slog"
10 | "strings"
11 |
12 | "github.com/emiago/sipgo"
13 | "github.com/emiago/sipgo/sip"
14 | )
15 |
16 | type DialogSession interface {
17 | Id() string
18 | Context() context.Context
19 | Hangup(ctx context.Context) error
20 | Media() *DialogMedia
21 | DialogSIP() *sipgo.Dialog
22 | Do(ctx context.Context, req *sip.Request) (*sip.Response, error)
23 | }
24 |
25 | //
26 | // Here are many common functions built for dialog
27 | //
28 |
29 | func dialogRefer(ctx context.Context, d DialogSession, recipient sip.Uri, referTo sip.Uri, headers ...sip.Header,) error {
30 | if d.DialogSIP().LoadState() != sip.DialogStateConfirmed {
31 | return fmt.Errorf("Can only be called on answered dialog")
32 | }
33 |
34 | req := sip.NewRequest(sip.REFER, recipient)
35 | // Invite request tags must be preserved but switched
36 | req.AppendHeader(sip.NewHeader("Refer-To", referTo.String()))
37 |
38 | for _, h := range headers {
39 | if h != nil {
40 | req.AppendHeader(h)
41 | }
42 | }
43 |
44 | res, err := d.Do(ctx, req)
45 | if err != nil {
46 | return err
47 | }
48 |
49 | if res.StatusCode != sip.StatusAccepted {
50 | return sipgo.ErrDialogResponse{
51 | Res: res,
52 | }
53 | }
54 | return nil
55 | }
56 |
57 | func dialogHandleReferNotify(d DialogSession, req *sip.Request, tx sip.ServerTransaction) {
58 | // TODO how to know this is refer
59 | contentType := req.ContentType().Value()
60 | // For now very basic check
61 | if !strings.HasPrefix(contentType, "message/sipfrag;version=2.0") {
62 | tx.Respond(sip.NewResponseFromRequest(req, sip.StatusBadRequest, "Bad Request", nil))
63 | return
64 | }
65 |
66 | frag := string(req.Body())
67 | if len(frag) < len("SIP/2.0 100 xx") {
68 | tx.Respond(sip.NewResponseFromRequest(req, sip.StatusBadRequest, "Bad Request", nil))
69 | return
70 | }
71 |
72 | tx.Respond(sip.NewResponseFromRequest(req, sip.StatusOK, "OK", nil))
73 |
74 | slog.Info("Handling NOTIFY: " + string(req.Body()))
75 | switch frag[:11] {
76 | case "SIP/2.0 100":
77 | case "SIP/2.0 200":
78 | d.Hangup(d.Context())
79 | }
80 | }
81 |
82 | func dialogHandleRefer(d DialogSession, dg *Diago, req *sip.Request, tx sip.ServerTransaction, onReferDialog func(referDialog *DialogClientSession)) {
83 | referTo := req.GetHeader("Refer-To")
84 | // https://datatracker.ietf.org/doc/html/rfc3515#section-2.4.2
85 | // An agent responding to a REFER method MUST return a 400 (Bad Request)
86 | // if the request contained zero or more than one Refer-To header field
87 | // values.
88 | log := dg.log
89 | if referTo == nil {
90 | log.Info("Received REFER without Refer-To header")
91 | tx.Respond(sip.NewResponseFromRequest(req, 400, "Bad Request", nil))
92 | return
93 | }
94 |
95 | referToUri := sip.Uri{}
96 | if err := sip.ParseUri(referTo.Value(), &referToUri); err != nil {
97 | log.Info("Received REFER bud failed to parse Refer-To uri", "error", err)
98 | tx.Respond(sip.NewResponseFromRequest(req, 400, "Bad Request", nil))
99 | return
100 | }
101 |
102 | contact := req.Contact()
103 | if contact == nil {
104 | tx.Respond(sip.NewResponseFromRequest(req, 400, "Bad Request", []byte("No Contact Header")))
105 | return
106 | }
107 |
108 | // TODO can we locate this more checks
109 | log.Info("Accepting refer")
110 | // emptySess := &DialogClientSession{
111 | // DialogClientSession: &sipgo.DialogClientSession{
112 | // Dialog: sipgo.Dialog{
113 | // InviteRequest: sip.NewRequest(),
114 | // },
115 | // },
116 | // }
117 | // emptySess.Init()
118 | // onRefDialog(&DialogClientSession{})
119 |
120 | tx.Respond(sip.NewResponseFromRequest(req, 202, "Accepted", nil))
121 | // TODO after this we could get BYE immediately, but caller would not be able
122 | // to take control over refer dialog
123 |
124 | addSipFrag := func(req *sip.Request, statusCode int, reason string) {
125 | req.AppendHeader(sip.NewHeader("Event", "refer"))
126 | req.AppendHeader(sip.NewHeader("Content-Type", "message/sipfrag;version=2.0"))
127 | frag := fmt.Sprintf("SIP/2.0 %d %s", statusCode, reason)
128 | req.SetBody([]byte(frag))
129 | }
130 |
131 | ctx := d.Context()
132 |
133 | notify := sip.NewRequest(sip.NOTIFY, contact.Address)
134 |
135 | notify100 := notify.Clone()
136 | addSipFrag(notify100, 100, "Trying")
137 |
138 | // FROM, TO, CALLID must be same to make SUBSCRIBE working
139 | _, err := d.Do(ctx, notify100)
140 | if err != nil {
141 | log.Info("REFER NOTIFY 100 failed to sent", "error", err)
142 | return
143 | }
144 |
145 | referDialog, err := dg.Invite(ctx, referToUri, InviteOptions{})
146 | if err != nil {
147 | // DO notify?
148 | log.Error("REFER dialog failed to dial", "error", err)
149 | return
150 | }
151 | // We send ref dialog to processing. After sending 200 OK this session will terminate
152 | // TODO this should be called before Invite started as caller needs to be notified before
153 | onReferDialog(referDialog)
154 |
155 | notify200 := notify.Clone()
156 | addSipFrag(notify200, 200, "OK")
157 | _, err = d.Do(ctx, notify200)
158 | if err != nil {
159 | log.Info("REFER NOTIFY 100 failed to sent", "error", err)
160 | return
161 | }
162 |
163 | // Now this dialog will receive BYE and it will terminate
164 | // We need to send this referDialog to control of caller
165 | }
166 |
--------------------------------------------------------------------------------
/dialog_session_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "context"
8 | "math/rand/v2"
9 | "testing"
10 | "time"
11 |
12 | "github.com/emiago/diago/media"
13 | "github.com/emiago/sipgo"
14 | "github.com/emiago/sipgo/sip"
15 | "github.com/stretchr/testify/assert"
16 | "github.com/stretchr/testify/require"
17 | )
18 |
19 | func newDialer(ua *sipgo.UserAgent) *Diago {
20 | return NewDiago(ua, WithTransport(Transport{Transport: "udp", BindHost: "127.0.0.1", BindPort: 0}))
21 | }
22 |
23 | func dialogEcho(sess DialogSession) error {
24 | audioR, err := sess.Media().AudioReader()
25 | if err != nil {
26 | return err
27 | }
28 |
29 | audioW, err := sess.Media().AudioWriter()
30 | if err != nil {
31 | return err
32 | }
33 |
34 | _, err = media.Copy(audioR, audioW)
35 | if err != nil {
36 | return err
37 | }
38 | return nil
39 | }
40 |
41 | func TestIntegrationInbound(t *testing.T) {
42 | ctx, cancel := context.WithCancel(context.Background())
43 | defer cancel()
44 |
45 | // Create transaction users, as many as needed.
46 | ua, _ := sipgo.NewUA(
47 | sipgo.WithUserAgent("inbound"),
48 | )
49 | defer ua.Close()
50 |
51 | dg := NewDiago(ua)
52 |
53 | err := dg.ServeBackground(ctx, func(d *DialogServerSession) {
54 | // Add some routing
55 | if d.ToUser() == "alice" {
56 | d.Trying()
57 | d.Ringing()
58 | d.Answer()
59 |
60 | dialogEcho(d)
61 | <-d.Context().Done()
62 | return
63 | }
64 |
65 | d.Respond(sip.StatusForbidden, "Forbidden", nil)
66 |
67 | <-d.Context().Done()
68 | })
69 | require.NoError(t, err)
70 |
71 | // Transaction User is basically driving dialog session
72 | // It can be inbound/UAS or outbound/UAC behavior
73 |
74 | // TU can ReceiveCall and it will create a DialogSessionServer
75 | // TU can Dial endpoint and create a DialogSessionClient (Channel)
76 | // DialogSessionClient can be bridged with other sessions
77 |
78 | {
79 | ua, _ := sipgo.NewUA()
80 | defer ua.Close()
81 |
82 | phone := newDialer(ua)
83 | // Start listener in order to reuse UDP listener
84 | err := phone.ServeBackground(context.TODO(), func(d *DialogServerSession) {})
85 | require.NoError(t, err)
86 |
87 | // Forbiddden
88 | _, err = phone.Invite(context.TODO(), sip.Uri{User: "noroute", Host: "127.0.0.1", Port: 5060}, InviteOptions{})
89 | require.Error(t, err)
90 |
91 | // Answered call
92 | dialog, err := phone.Invite(context.TODO(), sip.Uri{User: "alice", Host: "127.0.0.1", Port: 5060}, InviteOptions{})
93 | require.NoError(t, err)
94 | defer dialog.Close()
95 |
96 | // Confirm media traveling
97 | audioR, err := dialog.AudioReader()
98 | require.NoError(t, err)
99 |
100 | audioW, err := dialog.AudioWriter()
101 | require.NoError(t, err)
102 |
103 | writeN, _ := audioW.Write([]byte("my audio"))
104 | readN, _ := audioR.Read(make([]byte, 100))
105 | assert.Equal(t, writeN, readN, "media echo failed")
106 | dialog.Hangup(ctx)
107 | }
108 | }
109 |
110 | func TestIntegrationBridging(t *testing.T) {
111 | ctx, cancel := context.WithCancel(context.Background())
112 | defer cancel()
113 | // Create transaction users, as many as needed.
114 | ua, _ := sipgo.NewUA(
115 | sipgo.WithUserAgent("inbound"),
116 | )
117 | defer ua.Close()
118 | tu := NewDiago(ua, WithTransport(
119 | Transport{
120 | Transport: "udp",
121 | BindHost: "127.0.0.1",
122 | BindPort: 5090,
123 | },
124 | ))
125 |
126 | err := tu.ServeBackground(ctx, func(in *DialogServerSession) {
127 | in.Trying()
128 | in.Ringing()
129 | in.Answer()
130 |
131 | inCtx := in.Context()
132 | ctx, cancel := context.WithTimeout(inCtx, 15*time.Second)
133 | defer cancel()
134 |
135 | // Wa want to bridge this call with originator
136 | bridge := NewBridge()
137 | // Add us in bridge
138 | if err := bridge.AddDialogSession(in); err != nil {
139 | t.Log("Adding dialog in bridge failed", err)
140 | return
141 | }
142 |
143 | out, err := tu.InviteBridge(ctx, sip.Uri{User: "test", Host: "127.0.0.200", Port: 5090}, &bridge, InviteOptions{})
144 | if err != nil {
145 | t.Log("Dialing failed", err)
146 | return
147 | }
148 |
149 | outCtx := out.Context()
150 | defer func() {
151 | hctx, hcancel := context.WithTimeout(outCtx, 5*time.Second)
152 | out.Hangup(hctx)
153 | hcancel()
154 | }()
155 |
156 | // This is beauty, as you can even easily detect who hangups
157 | select {
158 | case <-inCtx.Done():
159 | case <-outCtx.Done():
160 | }
161 |
162 | // How to now do bridging
163 | })
164 | assert.NoError(t, err)
165 |
166 | {
167 | ua, _ := sipgo.NewUA()
168 | defer ua.Close()
169 |
170 | dg := NewDiago(ua, WithTransport(
171 | Transport{
172 | Transport: "udp",
173 | BindHost: "127.0.0.200",
174 | BindPort: 5090,
175 | },
176 | ))
177 |
178 | err := dg.ServeBackground(context.Background(), func(d *DialogServerSession) {
179 | ctx := d.Context()
180 | err = d.Answer()
181 | require.NoError(t, err)
182 |
183 | // ms := d.mediaSession
184 | buf := make([]byte, media.RTPBufSize)
185 | r, _ := d.AudioReader()
186 | n, err := r.Read(buf)
187 | require.NoError(t, err)
188 |
189 | w, _ := d.AudioWriter()
190 | w.Write(buf[:n])
191 | require.NoError(t, err)
192 |
193 | <-ctx.Done()
194 | })
195 | require.NoError(t, err)
196 | }
197 |
198 | {
199 | ua, _ := sipgo.NewUA()
200 | defer ua.Close()
201 |
202 | dg := newDialer(ua)
203 | dialog, err := dg.Invite(context.TODO(), sip.Uri{Host: "127.0.0.1", Port: 5090}, InviteOptions{})
204 | require.NoError(t, err)
205 | defer dialog.Close()
206 |
207 | w, _ := dialog.AudioWriter()
208 | _, err = w.Write([]byte("1234"))
209 | require.NoError(t, err)
210 |
211 | buf := make([]byte, media.RTPBufSize)
212 | r, _ := dialog.AudioReader()
213 | r.Read(buf)
214 | require.NoError(t, err)
215 |
216 | t.Log("Hanguping")
217 | dialog.Hangup(ctx)
218 | }
219 | }
220 |
221 | func TestIntegrationDialogCancel(t *testing.T) {
222 | ctx, cancel := context.WithCancel(context.Background())
223 | defer cancel()
224 |
225 | ua, _ := sipgo.NewUA()
226 | defer ua.Close()
227 | port := 15000 + rand.IntN(999)
228 | dg := NewDiago(ua, WithTransport(
229 | Transport{
230 | Transport: "udp",
231 | BindHost: "127.0.0.1",
232 | BindPort: port,
233 | },
234 | ))
235 |
236 | dg.ServeBackground(ctx, func(d *DialogServerSession) {
237 | ctx := d.Context()
238 | d.Trying()
239 | d.Ringing()
240 |
241 | <-ctx.Done()
242 | })
243 |
244 | {
245 | ua, _ := sipgo.NewUA()
246 | defer ua.Close()
247 |
248 | dg := newDialer(ua)
249 | dg.ServeBackground(context.TODO(), func(d *DialogServerSession) {})
250 |
251 | ctx, cancel := context.WithCancel(context.Background())
252 | defer cancel()
253 | _, err := dg.Invite(ctx, sip.Uri{User: "test", Host: "127.0.0.1", Port: port}, InviteOptions{
254 | OnResponse: func(res *sip.Response) error {
255 | if res.StatusCode == sip.StatusRinging {
256 | cancel()
257 | // return context.Canceled
258 | }
259 | return nil
260 | },
261 | })
262 | require.ErrorIs(t, err, context.Canceled)
263 | }
264 |
265 | }
266 |
--------------------------------------------------------------------------------
/digest_auth.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "crypto/rand"
8 | "encoding/base64"
9 | "errors"
10 | "fmt"
11 | "sync"
12 | "time"
13 |
14 | "github.com/emiago/sipgo/sip"
15 | "github.com/icholy/digest"
16 | )
17 |
18 | type DigestAuth struct {
19 | Username string
20 | Password string
21 | Realm string
22 | Expire time.Duration
23 | }
24 |
25 | func (a *DigestAuth) expire() time.Duration {
26 | if a.Expire > 0 {
27 | return a.Expire
28 | }
29 | return 5 * time.Second
30 | }
31 |
32 | type digestChallengeEntry struct {
33 | digest.Challenge
34 | expireTimer *time.Timer
35 | }
36 |
37 | type DigestAuthServer struct {
38 | mu sync.Mutex
39 | cache map[string]*digestChallengeEntry
40 | }
41 |
42 | func NewDigestServer() *DigestAuthServer {
43 | t := &DigestAuthServer{
44 | cache: make(map[string]*digestChallengeEntry),
45 | }
46 | return t
47 | }
48 |
49 | func (s *DigestAuthServer) Close() {
50 | s.mu.Lock()
51 | defer s.mu.Unlock()
52 | for _, v := range s.cache {
53 | v.expireTimer.Stop()
54 | }
55 | }
56 |
57 | var (
58 | ErrDigestAuthNoChallenge = errors.New("no challenge")
59 | ErrDigestAuthBadCreds = errors.New("bad credentials")
60 | )
61 |
62 | // AuthorizeRequest authorizes request. Returns SIP response that can be passed with error
63 | func (s *DigestAuthServer) AuthorizeRequest(req *sip.Request, auth DigestAuth) (res *sip.Response, err error) {
64 | h := req.GetHeader("Authorization")
65 | // https://www.rfc-editor.org/rfc/rfc2617#page-6
66 |
67 | if h == nil {
68 | nonce, err := generateNonce()
69 | if err != nil {
70 | return sip.NewResponseFromRequest(req, sip.StatusInternalServerError, "Internal Server Error", nil), err
71 | }
72 |
73 | e := &digestChallengeEntry{
74 | Challenge: digest.Challenge{
75 | Realm: auth.Realm,
76 | Nonce: nonce,
77 | // Opaque: "sipgo",
78 | Algorithm: "MD5",
79 | },
80 | }
81 |
82 | chal := &e.Challenge
83 |
84 | res := sip.NewResponseFromRequest(req, 401, "Unathorized", nil)
85 | res.AppendHeader(sip.NewHeader("WWW-Authenticate", chal.String()))
86 |
87 | s.mu.Lock()
88 | s.cache[nonce] = e
89 | s.mu.Unlock()
90 | e.expireTimer = time.AfterFunc(auth.expire(), func() {
91 | s.mu.Lock()
92 | delete(s.cache, nonce)
93 | s.mu.Unlock()
94 | })
95 |
96 | return res, nil
97 | }
98 |
99 | cred, err := digest.ParseCredentials(h.Value())
100 | if err != nil {
101 | return sip.NewResponseFromRequest(req, sip.StatusBadRequest, "Bad Request", nil), err
102 | }
103 |
104 | e, exists := s.cache[cred.Nonce]
105 | if !exists {
106 | return sip.NewResponseFromRequest(req, sip.StatusUnauthorized, "Unauthorized", nil), ErrDigestAuthNoChallenge
107 | }
108 | chal := &e.Challenge
109 |
110 | // Make digest and compare response
111 | digCred, err := digest.Digest(chal, digest.Options{
112 | Method: req.Method.String(),
113 | URI: cred.URI,
114 | Username: auth.Username,
115 | Password: auth.Password,
116 | })
117 |
118 | if err != nil {
119 | // Mostly due to unsupported digest alg
120 | return sip.NewResponseFromRequest(req, sip.StatusForbidden, "Forbidden", nil), err
121 | }
122 |
123 | if cred.Response != digCred.Response {
124 | return sip.NewResponseFromRequest(req, sip.StatusUnauthorized, "Unauthorized", nil), ErrDigestAuthBadCreds
125 | }
126 |
127 | return sip.NewResponseFromRequest(req, sip.StatusOK, "OK", nil), nil
128 | }
129 |
130 | func (s *DigestAuthServer) AuthorizeDialog(d *DialogServerSession, auth DigestAuth) error {
131 | if auth.Realm == "" {
132 | auth.Realm = "sipgo"
133 | }
134 |
135 | // https://www.rfc-editor.org/rfc/rfc2617#page-6
136 | req := d.InviteRequest
137 | res, err := s.AuthorizeRequest(req, auth)
138 | if err == nil && res.StatusCode != 200 {
139 | err = fmt.Errorf("not authorized")
140 | return errors.Join(err, d.WriteResponse(res))
141 | }
142 | return errors.Join(err, nil)
143 | }
144 |
145 | func generateNonce() (string, error) {
146 | nonceBytes := make([]byte, 32)
147 | _, err := rand.Read(nonceBytes)
148 | if err != nil {
149 | return "", fmt.Errorf("could not generate nonce")
150 | }
151 |
152 | return base64.URLEncoding.EncodeToString(nonceBytes), nil
153 | }
154 |
--------------------------------------------------------------------------------
/dtmf_reader_writer.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
--------------------------------------------------------------------------------
/examples/183ringing/README.md:
--------------------------------------------------------------------------------
1 | Run record as server
2 | ```bash
3 | go run ./examples/wav_record
4 | ```
5 |
6 | Dial in and after terminating call you should find recording under
7 | `/tmp/diago_record_.wav`
8 |
9 | ```sh
10 | gophone dial -media=file=demo_echotest.wav sip:112@127.0.0.1
11 | ```
12 |
--------------------------------------------------------------------------------
/examples/183ringing/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 | package main
4 |
5 | import (
6 | "context"
7 | "errors"
8 | "io"
9 | "log/slog"
10 | "os"
11 | "os/signal"
12 |
13 | "github.com/emiago/diago"
14 | "github.com/emiago/diago/examples"
15 | "github.com/emiago/diago/media"
16 | "github.com/emiago/sipgo"
17 | )
18 |
19 | // Dial this app with
20 | // gophone dial -media=audio "sip:123@127.0.0.1"
21 |
22 | func main() {
23 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
24 | defer cancel()
25 |
26 | examples.SetupLogger()
27 |
28 | err := start(ctx)
29 | if err != nil {
30 | slog.Error("PBX finished with error", "error", err)
31 | }
32 | }
33 |
34 | func start(ctx context.Context) error {
35 | // Setup our main transaction user
36 | ua, _ := sipgo.NewUA()
37 | tu := diago.NewDiago(ua)
38 |
39 | return tu.Serve(ctx, func(inDialog *diago.DialogServerSession) {
40 | slog.Info("New dialog request", "id", inDialog.ID)
41 | defer slog.Info("Dialog finished", "id", inDialog.ID)
42 | if err := AnswerEarly(inDialog); err != nil {
43 | slog.Error("Record finished with error", "error", err)
44 | }
45 | })
46 | }
47 |
48 | func AnswerEarly(inDialog *diago.DialogServerSession) error {
49 | inDialog.Trying() // Progress -> 100 Trying
50 | inDialog.Ringing() // Ringing -> 180 Response
51 | if err := inDialog.Answer(); err != nil {
52 | return err
53 | } // Answer -> 200 Response
54 |
55 | // Create wav file to store recording
56 | wawFile, err := os.OpenFile("/tmp/diago_record_"+inDialog.InviteRequest.CallID().Value()+".wav", os.O_RDWR|os.O_CREATE, 0755)
57 | if err != nil {
58 | return err
59 | }
60 | defer wawFile.Close()
61 |
62 | // Create recording audio pipeline
63 | rec, err := inDialog.AudioStereoRecordingCreate(wawFile)
64 | if err != nil {
65 | return err
66 | }
67 | // Must be closed for correct flushing
68 | defer func() {
69 | if err := rec.Close(); err != nil {
70 | slog.Error("Failed to close recording", "error", err)
71 | }
72 | }()
73 |
74 | // Do echo with audio reader and writer from recording object
75 | _, err = media.Copy(rec.AudioReader(), rec.AudioWriter())
76 | if errors.Is(err, io.EOF) {
77 | // Call finished
78 | return nil
79 | }
80 | return err
81 | }
82 |
--------------------------------------------------------------------------------
/examples/bridge/README.md:
--------------------------------------------------------------------------------
1 | Run bridge app that always bridges with bob on port `5090`
2 | ```bash
3 | go run ./examples/bridge sip:bob@127.0.0.1:5090
4 | ```
5 |
6 | Run receiver:
7 | ```bash
8 | gophone answer -ua bob -l 127.0.0.1:5090
9 | ```
10 |
11 | Dial server on `5060` be bridged with bob on `5090`
12 | ```sh
13 | gophone dial -ua alice sip:bob@127.0.0.1:5060
14 | ```
--------------------------------------------------------------------------------
/examples/bridge/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package main
5 |
6 | import (
7 | "context"
8 | "flag"
9 | "log/slog"
10 | "os"
11 | "os/signal"
12 | "time"
13 |
14 | "github.com/emiago/diago"
15 | "github.com/emiago/diago/examples"
16 | "github.com/emiago/sipgo"
17 | "github.com/emiago/sipgo/sip"
18 | )
19 |
20 | // Have receiver running:
21 | // gophone answer -l "127.0.0.1:5090"
22 | //
23 | // Run app:
24 | // go run . sip:uas@127.0.0.1:5090
25 | //
26 | // Run dialer:
27 | // gophone dial sip:bob@127.0.0.1
28 |
29 | func main() {
30 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
31 | defer cancel()
32 |
33 | examples.SetupLogger()
34 |
35 | flag.Parse()
36 | recipientUri := flag.Arg(0)
37 |
38 | err := start(ctx, recipientUri)
39 | if err != nil {
40 | slog.Error("PBX finished with error", "error", err)
41 | }
42 | }
43 |
44 | func start(ctx context.Context, recipientUri string) error {
45 | // Setup our main transaction user
46 | ua, _ := sipgo.NewUA()
47 | d := diago.NewDiago(ua)
48 |
49 | recipient := sip.Uri{}
50 | if err := sip.ParseUri(recipientUri, &recipient); err != nil {
51 | return err
52 | }
53 |
54 | return d.Serve(ctx, func(inDialog *diago.DialogServerSession) {
55 | BridgeCall(d, inDialog, recipient)
56 | })
57 | }
58 |
59 | func BridgeCall(d *diago.Diago, inDialog *diago.DialogServerSession, recipient sip.Uri) error {
60 | inDialog.Trying() // Progress -> 100 Trying
61 | inDialog.Ringing() // Ringing -> 180 Response
62 |
63 | inCtx := inDialog.Context()
64 | ctx, cancel := context.WithTimeout(inCtx, 5*time.Second)
65 | defer cancel()
66 |
67 | bridge := diago.NewBridge()
68 | // Now answer our in dialog
69 | inDialog.Answer()
70 | if err := bridge.AddDialogSession(inDialog); err != nil {
71 | return err
72 | }
73 |
74 | outDialog, err := d.InviteBridge(ctx, recipient, &bridge, diago.InviteOptions{})
75 | if err != nil {
76 | return err
77 | }
78 | defer outDialog.Close()
79 | outCtx := outDialog.Context()
80 |
81 | defer inDialog.Hangup(inCtx)
82 | defer outDialog.Hangup(outCtx)
83 |
84 | // You can even easily detect who hangups
85 | select {
86 | case <-inCtx.Done():
87 | case <-outCtx.Done():
88 | }
89 | return nil
90 | }
91 |
--------------------------------------------------------------------------------
/examples/dtmf/README.md:
--------------------------------------------------------------------------------
1 |
2 | Run playback as server
3 | ```bash
4 | go run ./examples/dtmf
5 | ```
6 |
7 | Dial in and send DTMF. On upper terminal you should see DTMF detected and printed
8 | ```sh
9 | gophone dial -dtmf="1234ABCD" sip:112@127.0.0.1
10 | ```
--------------------------------------------------------------------------------
/examples/dtmf/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package main
5 |
6 | import (
7 | "context"
8 | "log/slog"
9 | "os"
10 | "os/signal"
11 | "time"
12 |
13 | "github.com/emiago/diago"
14 | "github.com/emiago/diago/examples"
15 | "github.com/emiago/sipgo"
16 | )
17 |
18 | // Dial this app with
19 | // gophone dial -media=speaker "sip:123@127.0.0.1"
20 |
21 | func main() {
22 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
23 | defer cancel()
24 |
25 | examples.SetupLogger()
26 |
27 | err := start(ctx)
28 | if err != nil {
29 | slog.Error("PBX finished with error", "error", err)
30 | }
31 | }
32 |
33 | func start(ctx context.Context) error {
34 | // Setup our main transaction user
35 | ua, _ := sipgo.NewUA()
36 | tu := diago.NewDiago(ua)
37 |
38 | return tu.Serve(ctx, func(inDialog *diago.DialogServerSession) {
39 | slog.Info("New dialog request", "id", inDialog.ID)
40 | defer slog.Info("Dialog finished", "id", inDialog.ID)
41 | ReadDTMF(inDialog)
42 | })
43 | }
44 |
45 | func ReadDTMF(inDialog *diago.DialogServerSession) error {
46 | inDialog.Trying() // Progress -> 100 Trying
47 | inDialog.Ringing() // Ringing -> 180 Response
48 | inDialog.Answer()
49 | slog.Info("Reading DTMF")
50 |
51 | reader := inDialog.AudioReaderDTMF()
52 | return reader.Listen(func(dtmf rune) error {
53 | slog.Info("Received DTMF", "dtmf", string(dtmf))
54 | return nil
55 | }, 10*time.Second)
56 | }
57 |
--------------------------------------------------------------------------------
/examples/logger.go:
--------------------------------------------------------------------------------
1 | package examples
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 |
7 | "github.com/emiago/diago/media"
8 | "github.com/emiago/sipgo/sip"
9 | )
10 |
11 | func SetupLogger() {
12 | var lvl slog.Level
13 | if err := lvl.UnmarshalText([]byte(os.Getenv("LOG_LEVEL"))); err != nil {
14 | lvl = slog.LevelInfo
15 | }
16 | slog.SetLogLoggerLevel(lvl)
17 | media.RTPDebug = os.Getenv("RTP_DEBUG") == "true"
18 | media.RTCPDebug = os.Getenv("RTCP_DEBUG") == "true"
19 | sip.SIPDebug = os.Getenv("SIP_DEBUG") == "true"
20 | sip.TransactionFSMDebug = os.Getenv("SIP_TRANSACTION_DEBUG") == "true"
21 | }
22 |
--------------------------------------------------------------------------------
/examples/playback/README.md:
--------------------------------------------------------------------------------
1 |
2 | Run playback as server
3 | ```bash
4 | go run ./examples/playback
5 | ```
6 |
7 | Dial in and you should hear audio on your speakers
8 | ```sh
9 | gophone dial -media=speaker sip:112@127.0.0.1
10 | ```
--------------------------------------------------------------------------------
/examples/playback/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package main
5 |
6 | import (
7 | "context"
8 | "log/slog"
9 | "os"
10 | "os/signal"
11 |
12 | "github.com/emiago/diago"
13 | "github.com/emiago/diago/examples"
14 | "github.com/emiago/diago/testdata"
15 | "github.com/emiago/sipgo"
16 | )
17 |
18 | // Dial this app with
19 | // gophone dial -media=speaker "sip:123@127.0.0.1"
20 |
21 | func main() {
22 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
23 | defer cancel()
24 |
25 | examples.SetupLogger()
26 |
27 | err := start(ctx)
28 | if err != nil {
29 | slog.Error("PBX finished with error", "error", err)
30 | }
31 | }
32 |
33 | func start(ctx context.Context) error {
34 | // Setup our main transaction user
35 | ua, _ := sipgo.NewUA()
36 | tu := diago.NewDiago(ua)
37 |
38 | return tu.Serve(ctx, func(inDialog *diago.DialogServerSession) {
39 | slog.Info("New dialog request", "id", inDialog.ID)
40 | defer slog.Info("Dialog finished", "id", inDialog.ID)
41 | if err := Playback(inDialog); err != nil {
42 | slog.Error("Failed to play", "error", err)
43 | }
44 | })
45 | }
46 |
47 | func Playback(inDialog *diago.DialogServerSession) error {
48 | inDialog.Trying() // Progress -> 100 Trying
49 | inDialog.Ringing() // Ringing -> 180 Response
50 | inDialog.Answer() // Answer -> 200 Response
51 |
52 | playfile, _ := testdata.OpenFile("demo-echodone.wav")
53 | slog.Info("Playing a file", "file", "demo-echodone.wav")
54 |
55 | pb, err := inDialog.PlaybackCreate()
56 | if err != nil {
57 | return err
58 | }
59 | _, err = pb.Play(playfile, "audio/wav")
60 | return err
61 | }
62 |
--------------------------------------------------------------------------------
/examples/playback_control/README.md:
--------------------------------------------------------------------------------
1 | Run playback as server
2 | ```bash
3 | go run ./examples/playback_control
4 | ```
5 |
6 | Dial in and you should:
7 | - Hear audio
8 | - Audio muted
9 | - Audio umuted
10 | - Audio stopped
11 | ```sh
12 | gophone dial -media=speaker sip:112@127.0.0.1
13 | ```
14 |
--------------------------------------------------------------------------------
/examples/playback_control/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package main
5 |
6 | import (
7 | "context"
8 | "log/slog"
9 | "os"
10 | "os/signal"
11 | "time"
12 |
13 | "github.com/emiago/diago"
14 | "github.com/emiago/diago/examples"
15 | "github.com/emiago/diago/testdata"
16 | "github.com/emiago/sipgo"
17 | )
18 |
19 | // Dial this app with
20 | // gophone dial -media=audio "sip:123@127.0.0.1"
21 |
22 | func main() {
23 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
24 | defer cancel()
25 |
26 | examples.SetupLogger()
27 |
28 | err := start(ctx)
29 | if err != nil {
30 | slog.Error("PBX finished with error", "error", err)
31 | }
32 | }
33 |
34 | func start(ctx context.Context) error {
35 | // Setup our main transaction user
36 | ua, _ := sipgo.NewUA()
37 | tu := diago.NewDiago(ua)
38 |
39 | return tu.Serve(ctx, func(inDialog *diago.DialogServerSession) {
40 | slog.Info("New dialog request", "id", inDialog.ID)
41 | defer slog.Info("Dialog finished", "id", inDialog.ID)
42 |
43 | if err := Playback(inDialog); err != nil {
44 | slog.Error("Playback finished with error", "error", err)
45 | }
46 | })
47 | }
48 |
49 | func Playback(inDialog *diago.DialogServerSession) error {
50 | inDialog.Trying() // Progress -> 100 Trying
51 | inDialog.Ringing() // Ringing -> 180 Response
52 |
53 | playfile, _ := testdata.OpenFile("demo-echotest.wav")
54 | defer playfile.Close()
55 |
56 | slog.Info("Playing a file", "file", "demo-echotest.wav")
57 |
58 | inDialog.Answer() // Answer -> 200 Response
59 |
60 | pb, err := inDialog.PlaybackControlCreate()
61 | if err != nil {
62 | return err
63 | }
64 |
65 | playFinished := make(chan error)
66 | go func() {
67 | _, err = pb.Play(playfile, "audio/wav")
68 | playFinished <- err
69 | }()
70 |
71 | t1 := time.After(3 * time.Second)
72 | t2 := time.After(6 * time.Second)
73 | t3 := time.After(10 * time.Second)
74 | for {
75 | select {
76 | case <-t1:
77 | pb.Mute(true)
78 | slog.Info("Audio muted")
79 | case <-t2:
80 | pb.Mute(false)
81 | slog.Info("Audio unmuted")
82 | case <-t3:
83 | pb.Stop()
84 | slog.Info("Audio stopped")
85 | case err := <-playFinished:
86 | slog.Info("Play finished", "error", err)
87 | return nil
88 | case <-inDialog.Context().Done():
89 | slog.Info("Call hanguped", "error", inDialog.Context().Err())
90 | return nil
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/examples/readmedia/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package main
5 |
6 | import (
7 | "context"
8 | "errors"
9 | "io"
10 | "log/slog"
11 | "os"
12 | "os/signal"
13 | "time"
14 |
15 | "github.com/emiago/diago"
16 | "github.com/emiago/diago/audio"
17 | "github.com/emiago/diago/examples"
18 | "github.com/emiago/diago/media"
19 | "github.com/emiago/sipgo"
20 | )
21 |
22 | func main() {
23 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
24 | defer cancel()
25 |
26 | examples.SetupLogger()
27 |
28 | err := start(ctx)
29 | if err != nil {
30 | slog.Error("PBX finished with error", "error", err)
31 | }
32 | }
33 |
34 | func start(ctx context.Context) error {
35 | // Setup our main transaction user
36 | ua, _ := sipgo.NewUA()
37 | tu := diago.NewDiago(ua)
38 |
39 | return tu.Serve(ctx, func(inDialog *diago.DialogServerSession) {
40 | slog.Info("New dialog request", "id", inDialog.ID)
41 | defer slog.Info("Dialog finished", "id", inDialog.ID)
42 | ReadMedia(inDialog)
43 | })
44 | }
45 |
46 | func ReadMedia(inDialog *diago.DialogServerSession) error {
47 | inDialog.Trying() // Progress -> 100 Trying
48 | inDialog.Ringing() // Ringing -> 180 Response
49 | inDialog.Answer() // Answqer -> 200 Response
50 |
51 | lastPrint := time.Now()
52 | pktsCount := 0
53 | buf := make([]byte, media.RTPBufSize)
54 |
55 | // After answer we can access audio reader and read props
56 | m := diago.MediaProps{}
57 | audioReader, _ := inDialog.AudioReader(
58 | diago.WithAudioReaderMediaProps(&m),
59 | )
60 |
61 | decoder, err := audio.NewPCMDecoderReader(m.Codec.PayloadType, audioReader)
62 | if err != nil {
63 | return err
64 | }
65 | for {
66 | _, err := decoder.Read(buf)
67 | if err != nil {
68 | if errors.Is(err, io.EOF) {
69 | return nil
70 | }
71 | return err
72 | }
73 |
74 | pkt := inDialog.RTPPacketReader.PacketHeader
75 | if time.Since(lastPrint) > 3*time.Second {
76 | lastPrint = time.Now()
77 | slog.Info("Received packets", "PayloadType", pkt.PayloadType, "pkts", pktsCount)
78 | }
79 | pktsCount++
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/examples/register/README.md:
--------------------------------------------------------------------------------
1 |
2 | Run server and see how it registers
3 | ```bash
4 | go run ./examples/register -username -password sip:myuser@127.0.0.1:5060
5 | ```
6 |
7 |
--------------------------------------------------------------------------------
/examples/register/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package main
5 |
6 | import (
7 | "context"
8 | "flag"
9 | "fmt"
10 | "log/slog"
11 | "os"
12 | "os/signal"
13 |
14 | "github.com/emiago/diago"
15 | "github.com/emiago/diago/examples"
16 | "github.com/emiago/sipgo"
17 | "github.com/emiago/sipgo/sip"
18 | )
19 |
20 | // Run app:
21 | // go run . sip:user@myregistrar.com
22 |
23 | func main() {
24 | fUsername := flag.String("username", "", "Digest username")
25 | fPassword := flag.String("password", "", "Digest password")
26 | flag.Usage = func() {
27 | fmt.Fprintf(os.Stderr, "Usage: %s -username -password sip:123@example.com\n", os.Args[0])
28 | flag.PrintDefaults()
29 | }
30 | flag.Parse()
31 |
32 | // Setup signaling
33 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
34 | defer cancel()
35 |
36 | examples.SetupLogger()
37 |
38 | recipientUri := flag.Arg(0)
39 | if recipientUri == "" {
40 | flag.Usage()
41 | return
42 | }
43 |
44 | err := start(ctx, recipientUri, diago.RegisterOptions{
45 | Username: *fUsername,
46 | Password: *fPassword,
47 | })
48 | if err != nil {
49 | slog.Error("PBX finished with error", "error", err)
50 | }
51 | }
52 |
53 | func start(ctx context.Context, recipientURI string, regOpts diago.RegisterOptions) error {
54 | recipient := sip.Uri{}
55 | if err := sip.ParseUri(recipientURI, &recipient); err != nil {
56 | return fmt.Errorf("failed to parse register uri: %w", err)
57 | }
58 |
59 | // Setup our main transaction user
60 | useragent := regOpts.Username
61 | if useragent == "" {
62 | useragent = "change-me"
63 | }
64 |
65 | ua, _ := sipgo.NewUA(
66 | sipgo.WithUserAgent(useragent),
67 | sipgo.WithUserAgentHostname("localhost"),
68 | )
69 | defer ua.Close()
70 |
71 | tu := diago.NewDiago(ua, diago.WithTransport(
72 | diago.Transport{
73 | Transport: "udp",
74 | BindHost: "127.0.0.1",
75 | BindPort: 15060,
76 | },
77 | ))
78 |
79 | // Start listening incoming calls
80 | go func() {
81 | tu.Serve(ctx, func(inDialog *diago.DialogServerSession) {
82 | slog.Info("New dialog request", "id", inDialog.ID)
83 | defer slog.Info("Dialog finished", "id", inDialog.ID)
84 | })
85 | }()
86 |
87 | // Do register or fail on error
88 | return tu.Register(ctx, recipient, regOpts)
89 | }
90 |
--------------------------------------------------------------------------------
/examples/wav_record/README.md:
--------------------------------------------------------------------------------
1 | Run record as server
2 | ```bash
3 | go run ./examples/wav_record
4 | ```
5 |
6 | Dial in and after terminating call you should find recording under
7 | `/tmp/diago_record_.wav`
8 |
9 | ```sh
10 | gophone dial -media=file=demo_echotest.wav sip:112@127.0.0.1
11 | ```
12 |
--------------------------------------------------------------------------------
/examples/wav_record/main.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 | package main
4 |
5 | import (
6 | "context"
7 | "errors"
8 | "io"
9 | "log/slog"
10 | "os"
11 | "os/signal"
12 |
13 | "github.com/emiago/diago"
14 | "github.com/emiago/diago/examples"
15 | "github.com/emiago/diago/media"
16 | "github.com/emiago/sipgo"
17 | )
18 |
19 | // Dial this app with
20 | // gophone dial -media=audio "sip:123@127.0.0.1"
21 |
22 | func main() {
23 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
24 | defer cancel()
25 |
26 | examples.SetupLogger()
27 |
28 | err := start(ctx)
29 | if err != nil {
30 | slog.Error("PBX finished with error", "error", err)
31 | }
32 | }
33 |
34 | func start(ctx context.Context) error {
35 | // Setup our main transaction user
36 | ua, _ := sipgo.NewUA()
37 | tu := diago.NewDiago(ua)
38 |
39 | return tu.Serve(ctx, func(inDialog *diago.DialogServerSession) {
40 | slog.Info("New dialog request", "id", inDialog.ID)
41 | defer slog.Info("Dialog finished", "id", inDialog.ID)
42 | if err := Record(inDialog); err != nil {
43 | slog.Error("Record finished with error", "error", err)
44 | }
45 | })
46 | }
47 |
48 | func Record(inDialog *diago.DialogServerSession) error {
49 | inDialog.Trying() // Progress -> 100 Trying
50 | inDialog.Ringing() // Ringing -> 180 Response
51 | if err := inDialog.Answer(); err != nil {
52 | return err
53 | } // Answer -> 200 Response
54 |
55 | // Create wav file to store recording
56 | wawFile, err := os.OpenFile("/tmp/diago_record_"+inDialog.InviteRequest.CallID().Value()+".wav", os.O_RDWR|os.O_CREATE, 0755)
57 | if err != nil {
58 | return err
59 | }
60 | defer wawFile.Close()
61 |
62 | // Create recording audio pipeline
63 | rec, err := inDialog.AudioStereoRecordingCreate(wawFile)
64 | if err != nil {
65 | return err
66 | }
67 | // Must be closed for correct flushing
68 | defer func() {
69 | if err := rec.Close(); err != nil {
70 | slog.Error("Failed to close recording", "error", err)
71 | }
72 | }()
73 |
74 | // Do echo with audio reader and writer from recording object
75 | _, err = media.Copy(rec.AudioReader(), rec.AudioWriter())
76 | if errors.Is(err, io.EOF) {
77 | // Call finished
78 | return nil
79 | }
80 | return err
81 | }
82 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/emiago/diago
2 |
3 | go 1.22.4
4 |
5 | require (
6 | github.com/emiago/sipgo v0.32.1
7 | github.com/go-audio/riff v1.0.0
8 | github.com/icholy/digest v1.1.0
9 | github.com/pion/rtcp v1.2.14
10 | github.com/pion/rtp v1.8.9
11 | github.com/stretchr/testify v1.9.0
12 | github.com/zaf/g711 v1.4.0
13 | gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
14 | )
15 |
16 | require (
17 | github.com/davecgh/go-spew v1.1.1 // indirect
18 | github.com/gobwas/httphead v0.1.0 // indirect
19 | github.com/gobwas/pool v0.2.1 // indirect
20 | github.com/gobwas/ws v1.3.2 // indirect
21 | github.com/google/uuid v1.6.0 // indirect
22 | github.com/kr/text v0.2.0 // indirect
23 | github.com/pion/randutil v0.1.0 // indirect
24 | github.com/pmezard/go-difflib v1.0.0 // indirect
25 | github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect
26 | golang.org/x/sys v0.24.0 // indirect
27 | gopkg.in/yaml.v3 v3.0.1 // indirect
28 | )
29 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/emiago/sipgo v0.30.0 h1:ZgA5jXZOGA2Xx4HqOXKGPTLbnwh7NIIqhfGA0EdFoIE=
6 | github.com/emiago/sipgo v0.30.0/go.mod h1:DVr48FXa5HoRjYfSLDUKZ/1KZU4w23rBNtPMEyBmd3E=
7 | github.com/emiago/sipgo v0.31.0 h1:9DUYnvd0DSDZE8c2DXgkh/XSR2qipaultfuHiZUITZE=
8 | github.com/emiago/sipgo v0.31.0/go.mod h1:DVr48FXa5HoRjYfSLDUKZ/1KZU4w23rBNtPMEyBmd3E=
9 | github.com/emiago/sipgo v0.32.0 h1:jh63JlfNTzojUJBGEjHa+yBT6tnS4XFRBjpmO0OQIQA=
10 | github.com/emiago/sipgo v0.32.0/go.mod h1:QbpB7veL98PeOI5DAV0AALXVO4ytXd8HcV/wFVSCSnU=
11 | github.com/emiago/sipgo v0.32.1 h1:qY9TPd57WByZISkvm0zEOqyKZcV80beqVydo9hok9LE=
12 | github.com/emiago/sipgo v0.32.1/go.mod h1:QbpB7veL98PeOI5DAV0AALXVO4ytXd8HcV/wFVSCSnU=
13 | github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
14 | github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
15 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
16 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
17 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
18 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
19 | github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
20 | github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
21 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
22 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
23 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
24 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
25 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
26 | github.com/icholy/digest v0.1.22 h1:dRIwCjtAcXch57ei+F0HSb5hmprL873+q7PoVojdMzM=
27 | github.com/icholy/digest v0.1.22/go.mod h1:uLAeDdWKIWNFMH0wqbwchbTQOmJWhzSnL7zmqSPqEEc=
28 | github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
29 | github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
30 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
31 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
32 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
33 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
34 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
35 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
36 | github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
37 | github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
38 | github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
39 | github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
40 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
43 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
44 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
45 | github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM=
46 | github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
47 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
48 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
49 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
50 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
51 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
52 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
53 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
54 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
55 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
56 | github.com/zaf/g711 v1.4.0 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c=
57 | github.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo=
58 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
59 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
60 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
61 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
62 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
63 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
64 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
66 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
68 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
69 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
70 | gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
71 | gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
72 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
74 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
75 | gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
76 | gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
77 | gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
78 |
--------------------------------------------------------------------------------
/icons/diago-text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emiago/diago/8fe90424be3335f25a544659fb2bc2531cc23efc/icons/diago-text.png
--------------------------------------------------------------------------------
/icons/diago.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emiago/diago/8fe90424be3335f25a544659fb2bc2531cc23efc/icons/diago.png
--------------------------------------------------------------------------------
/main_benchmark_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "context"
8 | "crypto/rand"
9 | "fmt"
10 | "math/big"
11 | "os"
12 | "runtime"
13 | "strconv"
14 | "strings"
15 | "sync"
16 | "testing"
17 | "time"
18 |
19 | "github.com/emiago/diago/media"
20 | "github.com/emiago/sipgo"
21 | "github.com/emiago/sipgo/sip"
22 | "github.com/pion/rtp"
23 | "github.com/stretchr/testify/assert"
24 | "github.com/stretchr/testify/require"
25 | )
26 |
27 | // Run command
28 | // GOMAXPROCS=20 TEST_INTEGRATION=1 go test -bench=BenchmarkIntegrationClientServer -run $^ -benchmem -v . -benchtime=50x
29 | func BenchmarkIntegrationClientServer(t *testing.B) {
30 | if os.Getenv("TEST_INTEGRATION") == "" {
31 | t.Skip("Use TEST_INTEGRATION env value to run this test")
32 | return
33 | }
34 |
35 | testCases := []struct {
36 | transport string
37 | serverAddr string
38 | encrypted bool
39 | }{
40 | {transport: "udp", serverAddr: "127.1.1.100:5060"},
41 | // {transport: "tcp", serverAddr: "127.1.1.100:5060"},
42 | // {transport: "ws", serverAddr: "127.1.1.100:5061"},
43 | // {transport: "tls", serverAddr: "127.1.1.100:5062", encrypted: true},
44 | // {transport: "wss", serverAddr: "127.1.1.100:5063", encrypted: true},
45 | }
46 |
47 | ctx, shutdown := context.WithCancel(context.Background())
48 | wg := sync.WaitGroup{}
49 | t.Cleanup(func() {
50 | shutdown()
51 | wg.Wait()
52 | })
53 |
54 | tran := Transport{
55 | Transport: "udp",
56 | BindHost: "127.1.1.100",
57 | BindPort: 5060,
58 | }
59 | ua, _ := sipgo.NewUA()
60 | defer ua.Close()
61 | srv := NewDiago(ua, WithTransport(tran))
62 |
63 | err := srv.ServeBackground(ctx, func(d *DialogServerSession) {
64 | wg.Add(1)
65 | defer wg.Done()
66 |
67 | ctx := d.Context()
68 |
69 | err := d.Answer()
70 | if err != nil {
71 | t.Log(err.Error())
72 | return
73 | }
74 |
75 | pb, err := d.PlaybackCreate()
76 | if err != nil {
77 | t.Log(err.Error())
78 | return
79 | }
80 | _, err = pb.PlayFile("./testdata/files/demo-echodone.wav")
81 | if err != nil {
82 | t.Log(err.Error())
83 | return
84 | }
85 |
86 | err = d.Hangup(ctx)
87 | if err != nil {
88 | t.Log(err.Error())
89 | }
90 | })
91 | require.NoError(t, err)
92 |
93 | var maxInvitesPerSec chan struct{}
94 | maxInvitesPerSec = make(chan struct{}, 100)
95 | if v := os.Getenv("MAX_REQUESTS"); v != "" {
96 | t.Logf("Limiting number of requests: %s req/s", v)
97 | maxInvites, _ := strconv.Atoi(v)
98 | maxInvitesPerSec = make(chan struct{}, maxInvites)
99 | ctx, cancel := context.WithCancel(context.Background())
100 | defer cancel()
101 | go func() {
102 | for {
103 | select {
104 | case <-ctx.Done():
105 | return
106 | case <-time.After(1 * time.Second):
107 | for i := 0; i < maxInvites; i++ {
108 | <-maxInvitesPerSec
109 | }
110 | }
111 | }
112 | }()
113 | }
114 |
115 | for _, tc := range testCases {
116 | t.Run(tc.transport, func(t *testing.B) {
117 | t.ResetTimer()
118 | t.ReportAllocs()
119 |
120 | t.RunParallel(func(p *testing.PB) {
121 | // Build UAC
122 | // ua, _ := NewUA(WithUserAgenTLSConfig(clientTLS))
123 | ua, _ := sipgo.NewUA()
124 | defer ua.Close()
125 | // client, err := sipgo.NewClient(ua)
126 | // require.NoError(t, err)
127 | phone := NewDiago(ua)
128 | id, _ := rand.Int(rand.Reader, big.NewInt(int64(runtime.GOMAXPROCS(0))))
129 | for p.Next() {
130 | t.Log("Making call goroutine=", id)
131 | // If we are running in limit mode
132 | if maxInvitesPerSec != nil {
133 | maxInvitesPerSec <- struct{}{}
134 | }
135 | dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
136 | dialog, err := phone.Invite(dialCtx, sip.Uri{Host: tran.BindHost, Port: tran.BindPort, User: "dialer"}, InviteOptions{})
137 | cancel()
138 |
139 | require.NoError(t, err)
140 |
141 | mediapkts := 0
142 | outoforder := 0
143 | wg.Add(1)
144 | go func() {
145 | defer wg.Done()
146 |
147 | buf := make([]byte, media.RTPBufSize)
148 | var prevSeq uint16
149 | reader := dialog.mediaSession
150 | for {
151 | p := rtp.Packet{}
152 | _, err := reader.ReadRTP(buf, &p)
153 | if err != nil {
154 | return
155 | }
156 | if prevSeq > 0 && p.SequenceNumber != prevSeq+1 {
157 | outoforder++
158 | }
159 | mediapkts++
160 | prevSeq = p.SequenceNumber
161 | }
162 | }()
163 |
164 | start := time.Now()
165 | select {
166 | // Audio is 2 sec long
167 | case <-time.After(2 * time.Second):
168 | t.Log("NON SERVER hanguping")
169 | dialog.Hangup(context.TODO())
170 | case <-dialog.Context().Done():
171 | }
172 | callDuration := time.Since(start)
173 | dialog.Close()
174 | assert.Empty(t, outoforder, "Out of order media detected")
175 | assert.Greater(t, mediapkts, int(callDuration/(20*time.Millisecond))-10, "Not enough received packets")
176 | }
177 | })
178 |
179 | t.ReportMetric(float64(t.N)/t.Elapsed().Seconds(), "req/s")
180 |
181 | // ua, _ := NewUA(WithUserAgenTLSConfig(clientTLS))
182 | // client, err := NewClient(ua)
183 | // require.NoError(t, err)
184 |
185 | // for i := 0; i < t.N; i++ {
186 | // req, _, _ := createTestInvite(t, proto+":bob@"+tc.serverAddr, tc.transport, client.ip.String())
187 | // tx, err := client.TransactionRequest(ctx, req)
188 | // require.NoError(t, err)
189 |
190 | // res := <-tx.Responses()
191 | // assert.Equal(t, sip.StatusCode(200), res.StatusCode)
192 |
193 | // tx.Terminate()
194 | // }
195 | // t.ReportMetric(float64(t.N)/max(t.Elapsed().Seconds(), 1), "req/s")
196 | })
197 | }
198 | }
199 |
200 | func createTestInvite(t testing.TB, targetSipUri string, transport, addr string) (*sip.Request, string, string) {
201 | branch := sip.GenerateBranch()
202 | callid := "gotest-" + time.Now().Format(time.RFC3339Nano)
203 | ftag := fmt.Sprintf("%d", time.Now().UnixNano())
204 | return testCreateMessage(t, []string{
205 | "INVITE " + targetSipUri + " SIP/2.0",
206 | "Via: SIP/2.0/" + transport + " " + addr + ";branch=" + branch,
207 | "From: \"Alice\" ;tag=" + ftag,
208 | "To: \"Bob\" <" + targetSipUri + ">",
209 | "Call-ID: " + callid,
210 | "CSeq: 1 INVITE",
211 | "Content-Length: 0",
212 | "",
213 | "",
214 | }).(*sip.Request), callid, ftag
215 | }
216 |
217 | func testCreateMessage(t testing.TB, rawMsg []string) sip.Message {
218 | msg, err := sip.ParseMessage([]byte(strings.Join(rawMsg, "\r\n")))
219 | if err != nil {
220 | t.Fatal(err)
221 | }
222 | return msg
223 | }
224 |
--------------------------------------------------------------------------------
/media/.gitignore:
--------------------------------------------------------------------------------
1 | /astscale
2 | /dialog
3 | /goproxy
4 | /go.work*
5 | /gophone
6 | /soundplayer
7 | /.vscode
8 | /g711
9 | /pbx
10 | /rtp_session*.bck
11 | /rtp_playout_buffer*
12 | /MEDIA_QUALITY.md
13 | /asterisk-sounds-wav
14 | /audio/pcm_encoder.go
15 | /testdata
16 | /rfc*.txt
--------------------------------------------------------------------------------
/media/README.md:
--------------------------------------------------------------------------------
1 | # media
2 |
3 | Implemented:
4 | - [DTMF EVENT RFC2833](https://datatracker.ietf.org/doc/html/rfc2833)
5 |
6 |
7 |
8 | Everything is `io.Reader` and `io.Writer`
9 |
10 | We follow GO std lib and providing interface for Reader/Writer when it comes reading and writing media.
11 | This optimized and made easier usage of RTP framework, by providing end user standard library `io.Reader` `io.Writer`
12 | to pass his media.
13 |
14 | In other words chaining reader or writer allows to build interceptors, encoders, decoders without introducing
15 | overhead of contention or many memory allocations
16 |
17 | Features:
18 | - [x] Simple SDP build with formats alaw,ulaw,dtmf
19 | - [x] RTP/RTCP receiving and logging
20 | - [x] Extendable MediaSession handling for RTP/RTCP handling (ex microphone,speaker)
21 | - [x] DTMF encoder, decoder via RFC4733
22 | - [x] Minimal SDP package for audio
23 | - [x] Media Session, RTP Session handling
24 | - [x] RTCP monitoring
25 | - [ ] SDP codec fields manipulating
26 | - [ ] ... who knows
27 |
28 | ## Concepts
29 |
30 | - **Media Session** represents mapping between SDP media description and creates session based on local/remote addr
31 | - **RTP Session** is creating RTP/RTCP session. It is using media session underneath to add networking layer.
32 | - **RTP Packet Reader** is depackatizing RTP packets and providing payload as `io.Reader`. Normally it should be chained to RTP Session
33 | - **RTP Packet Writer** is packatizing payload to RTP packets as `io.Writer`. Normally it should be chained to RTP Session
34 |
35 | ## IO flow
36 |
37 | Reader:
38 | `AudioDecoder<->RTPPacketReader<->RTPSession<->MediaSession`
39 |
40 | Writer:
41 | `AudioEncoder<->RTPPackerWriter<->RTPSession<->MediaSession`
42 |
43 |
44 | ### more docs...
--------------------------------------------------------------------------------
/media/codec.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "errors"
8 | "fmt"
9 | "log/slog"
10 | "strconv"
11 | "strings"
12 | "time"
13 |
14 | "github.com/emiago/diago/media/sdp"
15 | )
16 |
17 | var (
18 | // Here are some codec constants that can be reused
19 | CodecAudioUlaw = Codec{PayloadType: 0, SampleRate: 8000, SampleDur: 20 * time.Millisecond, NumChannels: 1, Name: "PCMU"}
20 | CodecAudioAlaw = Codec{PayloadType: 8, SampleRate: 8000, SampleDur: 20 * time.Millisecond, NumChannels: 1, Name: "PCMA"}
21 | CodecAudioOpus = Codec{PayloadType: 96, SampleRate: 48000, SampleDur: 20 * time.Millisecond, NumChannels: 2, Name: "opus"}
22 | CodecTelephoneEvent8000 = Codec{PayloadType: 101, SampleRate: 8000, SampleDur: 20 * time.Millisecond, NumChannels: 1, Name: "telephone-event"}
23 | )
24 |
25 | type Codec struct {
26 | Name string
27 | PayloadType uint8
28 | SampleRate uint32
29 | SampleDur time.Duration
30 | NumChannels int // 1 or 2
31 | }
32 |
33 | func (c *Codec) String() string {
34 | return fmt.Sprintf("name=%s pt=%d rate=%d dur=%s channels=%d", c.Name, c.PayloadType, c.SampleRate, c.SampleDur.String(), c.NumChannels)
35 | }
36 |
37 | // SampleTimestamp returns number of samples as RTP Timestamp measure
38 | func (c *Codec) SampleTimestamp() uint32 {
39 | return uint32(float64(c.SampleRate) * c.SampleDur.Seconds())
40 | }
41 |
42 | // Samples16 returns PCM 16 bit samples size
43 | func (c *Codec) Samples16() int {
44 | return c.SamplesPCM(16)
45 | }
46 |
47 | // Samples is samples in pcm
48 | func (c *Codec) SamplesPCM(bitSize int) int {
49 | return bitSize / 8 * int(float64(c.SampleRate)*c.SampleDur.Seconds()) * c.NumChannels
50 | }
51 |
52 | func CodecFromSession(s *MediaSession) Codec {
53 | for _, codec := range s.Codecs {
54 | if codec.Name == "telephone-event" {
55 | continue
56 | }
57 |
58 | return codec
59 | }
60 |
61 | return s.Codecs[0]
62 | }
63 |
64 | // Deprecated: Use CodecAudioFromPayloadType
65 | func CodecFromPayloadType(payloadType uint8) Codec {
66 | f := strconv.Itoa(int(payloadType))
67 | return mapSupportedCodec(f)
68 | }
69 |
70 | func CodecAudioFromPayloadType(payloadType uint8) (Codec, error) {
71 | f := strconv.Itoa(int(payloadType))
72 | switch f {
73 | case sdp.FORMAT_TYPE_ALAW:
74 | return CodecAudioAlaw, nil
75 | case sdp.FORMAT_TYPE_ULAW:
76 | return CodecAudioUlaw, nil
77 | case sdp.FORMAT_TYPE_OPUS:
78 | return CodecAudioOpus, nil
79 | case sdp.FORMAT_TYPE_TELEPHONE_EVENT:
80 | return CodecTelephoneEvent8000, nil
81 | }
82 | return Codec{}, fmt.Errorf("non supported codec: %d", payloadType)
83 | }
84 |
85 | func mapSupportedCodec(f string) Codec {
86 | // TODO: Here we need to be more explicit like matching sample rate, channels and other
87 |
88 | switch f {
89 | case sdp.FORMAT_TYPE_ALAW:
90 | return CodecAudioAlaw
91 | case sdp.FORMAT_TYPE_ULAW:
92 | return CodecAudioUlaw
93 | case sdp.FORMAT_TYPE_OPUS:
94 | return CodecAudioOpus
95 | case sdp.FORMAT_TYPE_TELEPHONE_EVENT:
96 | return CodecTelephoneEvent8000
97 | default:
98 | slog.Warn("Unsupported format. Using default clock rate", "format", f)
99 | }
100 | // Format as default
101 | pt, err := sdp.FormatNumeric(f)
102 | if err != nil {
103 | slog.Warn("Format is non numeric value", "format", f)
104 | }
105 | return Codec{
106 | PayloadType: pt,
107 | SampleRate: 8000,
108 | SampleDur: 20 * time.Millisecond,
109 | }
110 | }
111 |
112 | // func CodecsFromSDP(log *slog.Logger, sd sdp.SessionDescription, codecsAudio []Codec) error {
113 | // md, err := sd.MediaDescription("audio")
114 | // if err != nil {
115 | // return err
116 | // }
117 |
118 | // codecs := make([]Codec, len(md.Formats))
119 | // attrs := sd.Values("a")
120 | // n, err := CodecsFromSDPRead(log, md, attrs, codecs)
121 | // if err != nil {
122 | // return err
123 | // }
124 | // codecs = codecs[:n]
125 | // }
126 |
127 | // CodecsFromSDP will try to parse as much as possible, but it will return also error in case
128 | // some properties could not be read
129 | // You can take what is parsed or return error
130 | func CodecsFromSDPRead(formats []string, attrs []string, codecsAudio []Codec) (int, error) {
131 | n := 0
132 | var rerr error
133 | for _, f := range formats {
134 | if f == "0" {
135 | codecsAudio[n] = CodecAudioUlaw
136 | n++
137 | continue
138 | }
139 |
140 | if f == "8" {
141 | codecsAudio[n] = CodecAudioAlaw
142 | n++
143 | continue
144 | }
145 |
146 | pt64, err := strconv.ParseUint(f, 10, 8)
147 | if err != nil {
148 | rerr = errors.Join(rerr, fmt.Errorf("format type failed to conv to integer, skipping f=%s: %w", f, err))
149 | continue
150 | }
151 | pt := uint8(pt64)
152 |
153 | for _, a := range attrs {
154 | // a=rtpmap: / [/]
155 | pref := "rtpmap:" + f + " "
156 | if strings.HasPrefix(a, pref) {
157 | // Check properties of this codec
158 | str := a[len(pref):]
159 | // TODO use more efficient reading props
160 | props := strings.Split(str, " ")
161 | firstProp := props[0]
162 | propsCodec := strings.Split(firstProp, "/")
163 | if len(propsCodec) < 2 {
164 | rerr = errors.Join(rerr, fmt.Errorf("bad rtmap property a=%s", a))
165 | continue
166 | }
167 |
168 | encodingName := propsCodec[0]
169 | sampleRateStr := propsCodec[1]
170 | sampleRate64, err := strconv.ParseUint(sampleRateStr, 10, 32)
171 | if err != nil {
172 | rerr = errors.Join(rerr, fmt.Errorf("sample rate failed to parse a=%s: %w", a, err))
173 | continue
174 | }
175 |
176 | codec := Codec{
177 | Name: encodingName,
178 | PayloadType: pt,
179 | SampleRate: uint32(sampleRate64),
180 | // TODO check ptime ?
181 | SampleDur: 20 * time.Millisecond,
182 | NumChannels: 1,
183 | }
184 |
185 | if len(propsCodec) == 3 {
186 | numChannels, err := strconv.ParseUint(propsCodec[2], 10, 32)
187 | if err == nil {
188 | codec.NumChannels = int(numChannels)
189 | }
190 | }
191 | codecsAudio[n] = codec
192 | n++
193 | }
194 | }
195 | }
196 | return n, nil
197 | }
198 |
--------------------------------------------------------------------------------
/media/images/design.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emiago/diago/8fe90424be3335f25a544659fb2bc2531cc23efc/media/images/design.png
--------------------------------------------------------------------------------
/media/media_session_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "io"
8 | "net"
9 | "testing"
10 |
11 | "github.com/emiago/diago/media/sdp"
12 | "github.com/emiago/sipgo/fakes"
13 | "github.com/pion/rtcp"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestMediaPortRange(t *testing.T) {
19 | RTPPortStart = 5000
20 | RTPPortEnd = 5010
21 |
22 | sessions := []*MediaSession{}
23 | for i := RTPPortStart; i < RTPPortEnd; i += 2 {
24 | require.Equal(t, i-RTPPortStart, int(rtpPortOffset.Load()))
25 | mess, err := NewMediaSession(net.IPv4(127, 0, 0, 1), 0)
26 | t.Log(mess.rtpConn.LocalAddr(), mess.rtcpConn.LocalAddr())
27 | require.NoError(t, err)
28 | sessions = append(sessions, mess)
29 | }
30 |
31 | for _, s := range sessions {
32 | s.Close()
33 | }
34 |
35 | }
36 |
37 | func TestDTMFEncodeDecode(t *testing.T) {
38 | // Example payload for DTMF digit '1' with volume 10 and duration 1000
39 | // Event: 0x01 (DTMF digit '1')
40 | // E bit: 0x80 (End of Event)
41 | // Volume: 0x0A (Volume 10)
42 | // Duration: 0x03E8 (Duration 1000)
43 | payload := []byte{0x01, 0x8A, 0x03, 0xE8}
44 |
45 | event := DTMFEvent{}
46 | err := DTMFDecode(payload, &event)
47 | if err != nil {
48 | t.Fatalf("Error decoding payload: %v", err)
49 | }
50 |
51 | if event.Event != 0x01 {
52 | t.Errorf("Unexpected Event. got: %v, want: %v", event.Event, 0x01)
53 | }
54 |
55 | if event.EndOfEvent != true {
56 | t.Errorf("Unexpected EndOfEvent. got: %v, want: %v", event.EndOfEvent, true)
57 | }
58 |
59 | if event.Volume != 0x0A {
60 | t.Errorf("Unexpected Volume. got: %v, want: %v", event.Volume, 0x0A)
61 | }
62 |
63 | if event.Duration != 0x03E8 {
64 | t.Errorf("Unexpected Duration. got: %v, want: %v", event.Duration, 0x03E8)
65 | }
66 |
67 | encoded := DTMFEncode(event)
68 | require.Equal(t, payload, encoded)
69 | }
70 |
71 | func TestReadRTCP(t *testing.T) {
72 | session := &MediaSession{}
73 | reader, writer := io.Pipe()
74 | session.rtcpConn = &fakes.UDPConn{
75 | Reader: reader,
76 | }
77 |
78 | go func() {
79 | pkts := []rtcp.Packet{
80 | &rtcp.SenderReport{},
81 | &rtcp.ReceiverReport{},
82 | }
83 | data, err := rtcp.Marshal(pkts)
84 | if err != nil {
85 | return
86 | }
87 | writer.Write(data)
88 | }()
89 | pkts := make([]rtcp.Packet, 5)
90 | n, err := session.ReadRTCP(make([]byte, 1600), pkts)
91 | require.NoError(t, err)
92 | require.Equal(t, 2, n)
93 | require.IsType(t, &rtcp.SenderReport{}, pkts[0])
94 | require.IsType(t, &rtcp.ReceiverReport{}, pkts[1])
95 |
96 | }
97 |
98 | func TestMediaSessionExternalIP(t *testing.T) {
99 | m := &MediaSession{
100 | Laddr: net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)},
101 | Mode: sdp.ModeSendrecv,
102 | ExternalIP: net.IPv4(1, 1, 1, 1),
103 | }
104 |
105 | data := m.LocalSDP()
106 | sd := sdp.SessionDescription{}
107 | err := sdp.Unmarshal(data, &sd)
108 | require.NoError(t, err)
109 |
110 | connInfo, err := sd.ConnectionInformation()
111 | require.NoError(t, err)
112 | assert.NotEmpty(t, connInfo.IP.To4())
113 | assert.Equal(t, m.ExternalIP.To4(), connInfo.IP.To4())
114 | }
115 |
116 | func TestMediaSessionUpdateCodec(t *testing.T) {
117 | newM := func() *MediaSession {
118 | return &MediaSession{
119 | Codecs: []Codec{
120 | CodecAudioUlaw, CodecAudioAlaw, CodecTelephoneEvent8000,
121 | },
122 | }
123 | }
124 |
125 | m := newM()
126 | m.updateRemoteCodecs([]Codec{CodecAudioAlaw, CodecAudioUlaw})
127 | assert.Equal(t, []Codec{CodecAudioAlaw, CodecAudioUlaw}, m.Codecs)
128 |
129 | m = newM()
130 | m.updateRemoteCodecs([]Codec{CodecAudioAlaw})
131 | assert.Equal(t, []Codec{CodecAudioAlaw}, m.Codecs)
132 |
133 | m = newM()
134 | m.updateRemoteCodecs([]Codec{})
135 | assert.Equal(t, []Codec{}, m.Codecs)
136 |
137 | m = newM()
138 | m.updateRemoteCodecs([]Codec{{Name: "NonExisting"}})
139 | assert.Equal(t, []Codec{}, m.Codecs)
140 | }
141 |
142 | func TestMediaSessionUpdateSDP(t *testing.T) {
143 | sd := `v=0
144 | o=- 3948988145 3948988145 IN IP4 192.168.178.54
145 | s=Sip Go Media
146 | c=IN IP4 192.168.178.54
147 | t=0 0
148 | m=audio 34391 RTP/AVP 0 8 96 101
149 | a=rtpmap:0 PCMU/8000
150 | a=rtpmap:8 PCMA/8000
151 | a=rtpmap:96 opus/48000/2
152 | a=rtpmap:101 telephone-event/8000
153 | a=fmtp:101 0-16
154 | a=ptime:20
155 | a=maxptime:20
156 | a=sendrecv`
157 |
158 | m := MediaSession{
159 | Codecs: []Codec{
160 | CodecAudioAlaw, CodecAudioUlaw, CodecAudioOpus, CodecTelephoneEvent8000,
161 | },
162 | }
163 | err := m.RemoteSDP([]byte(sd))
164 | require.NoError(t, err)
165 |
166 | require.Len(t, m.Codecs, 4)
167 | assert.Equal(t, CodecAudioUlaw, m.Codecs[0])
168 | assert.Equal(t, CodecAudioAlaw, m.Codecs[1])
169 | assert.Equal(t, CodecAudioOpus, m.Codecs[2])
170 | assert.Equal(t, CodecTelephoneEvent8000, m.Codecs[3])
171 | }
172 |
--------------------------------------------------------------------------------
/media/media_stream.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | type MediaStreamer interface {
7 | MediaStream(s *MediaSession) error
8 | }
9 |
10 | // TODO buid basic handling of media session
11 | // - logger
12 | // - mic
13 | // - speaker
14 |
--------------------------------------------------------------------------------
/media/rtp_dtmf.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "encoding/binary"
8 | "fmt"
9 | )
10 |
11 | // DTMF event mapping (RFC 4733)
12 | var dtmfEventMapping = map[rune]byte{
13 | '0': 0,
14 | '1': 1,
15 | '2': 2,
16 | '3': 3,
17 | '4': 4,
18 | '5': 5,
19 | '6': 6,
20 | '7': 7,
21 | '8': 8,
22 | '9': 9,
23 | '*': 10,
24 | '#': 11,
25 | 'A': 12,
26 | 'B': 13,
27 | 'C': 14,
28 | 'D': 15,
29 | }
30 |
31 | var dtmfEventMappingRev = map[byte]rune{
32 | 0: '0',
33 | 1: '1',
34 | 2: '2',
35 | 3: '3',
36 | 4: '4',
37 | 5: '5',
38 | 6: '6',
39 | 7: '7',
40 | 8: '8',
41 | 9: '9',
42 | 10: '*',
43 | 11: '#',
44 | 12: 'A',
45 | 13: 'B',
46 | 14: 'C',
47 | 15: 'D',
48 | }
49 |
50 | func DTMFToRune(dtmf uint8) rune {
51 | return dtmfEventMappingRev[dtmf]
52 | }
53 |
54 | // RTPDTMFEncode creates series of DTMF redudant events which should be encoded as payload
55 | // It is currently only 8000 sample rate considered for telophone event
56 | func RTPDTMFEncode(char rune) []DTMFEvent {
57 | event := dtmfEventMapping[char]
58 |
59 | events := make([]DTMFEvent, 7)
60 |
61 | for i := 0; i < 4; i++ {
62 | d := DTMFEvent{
63 | Event: event,
64 | EndOfEvent: false,
65 | Volume: 10,
66 | Duration: 160 * (uint16(i) + 1),
67 | }
68 | events[i] = d
69 | }
70 |
71 | // End events with redudancy
72 | for i := 4; i < 7; i++ {
73 | d := DTMFEvent{
74 | Event: event,
75 | EndOfEvent: true,
76 | Volume: 10,
77 | Duration: 160 * 5, // Must not be increased for end event
78 | }
79 | events[i] = d
80 | }
81 |
82 | return events
83 | }
84 |
85 | // DTMFEvent represents a DTMF event
86 | type DTMFEvent struct {
87 | Event uint8
88 | EndOfEvent bool
89 | Volume uint8
90 | Duration uint16
91 | }
92 |
93 | func (ev *DTMFEvent) String() string {
94 | out := "RTP DTMF Event:\n"
95 | out += fmt.Sprintf("\tEvent: %d\n", ev.Event)
96 | out += fmt.Sprintf("\tEndOfEvent: %v\n", ev.EndOfEvent)
97 | out += fmt.Sprintf("\tVolume: %d\n", ev.Volume)
98 | out += fmt.Sprintf("\tDuration: %d\n", ev.Duration)
99 | return out
100 | }
101 |
102 | // DecodeRTPPayload decodes an RTP payload into a DTMF event
103 | func DTMFDecode(payload []byte, d *DTMFEvent) error {
104 | if len(payload) < 4 {
105 | return fmt.Errorf("payload too short")
106 | }
107 |
108 | d.Event = payload[0]
109 | d.EndOfEvent = payload[1]&0x80 != 0
110 | d.Volume = payload[1] & 0x7F
111 | d.Duration = binary.BigEndian.Uint16(payload[2:4])
112 | // d.Duration = uint16(payload[2])<<8 | uint16(payload[3])
113 | return nil
114 | }
115 |
116 | func DTMFEncode(d DTMFEvent) []byte {
117 | header := make([]byte, 4)
118 | header[0] = d.Event
119 |
120 | if d.EndOfEvent {
121 | header[1] = 0x80
122 | }
123 | header[1] |= d.Volume & 0x3F
124 | binary.BigEndian.PutUint16(header[2:4], d.Duration)
125 | return header
126 | }
127 |
--------------------------------------------------------------------------------
/media/rtp_dtmf_reader.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "context"
8 | "io"
9 | "log/slog"
10 | )
11 |
12 | type RTPDtmfReader struct {
13 | codec Codec // Depends on media session. Defaults to 101 per current mapping
14 | reader io.Reader
15 | packetReader *RTPPacketReader
16 |
17 | lastEv DTMFEvent
18 | dtmf rune
19 | dtmfSet bool
20 | }
21 |
22 | // RTP DTMF writer is midleware for reading DTMF events
23 | // It reads from io Reader and checks packet Reader
24 | func NewRTPDTMFReader(codec Codec, packetReader *RTPPacketReader, reader io.Reader) *RTPDtmfReader {
25 | return &RTPDtmfReader{
26 | codec: codec,
27 | packetReader: packetReader,
28 | reader: reader,
29 | // dmtfs: make([]rune, 0, 5), // have some
30 | }
31 | }
32 |
33 | // Write is RTP io.Writer which adds more sync mechanism
34 | func (w *RTPDtmfReader) Read(b []byte) (int, error) {
35 | n, err := w.reader.Read(b)
36 | if err != nil {
37 | // Signal our reader that no more dtmfs will be read
38 | // close(w.dtmfCh)
39 | return n, err
40 | }
41 |
42 | // Check is this DTMF
43 | hdr := w.packetReader.PacketHeader
44 | if hdr.PayloadType != w.codec.PayloadType {
45 | return n, nil
46 | }
47 |
48 | // Now decode DTMF
49 | ev := DTMFEvent{}
50 | if err := DTMFDecode(b, &ev); err != nil {
51 | slog.Error("Failed to decode DTMF event", "error", err)
52 | }
53 | w.processDTMFEvent(ev)
54 | return n, nil
55 | }
56 |
57 | func (w *RTPDtmfReader) processDTMFEvent(ev DTMFEvent) {
58 | if slog.Default().Handler().Enabled(context.Background(), slog.LevelDebug) {
59 | // Expensive call on logger
60 | slog.Debug("Processing DTMF event", "ev", ev)
61 | }
62 | if ev.EndOfEvent {
63 | if w.lastEv.Duration == 0 {
64 | return
65 | }
66 | // Does this match to our last ev
67 | // Consider Event can be 0, that is why we check is also lastEv.Duration set
68 | if w.lastEv.Event != ev.Event {
69 | return
70 | }
71 |
72 | dur := ev.Duration - w.lastEv.Duration
73 | if dur <= 3*160 { // Expect at least ~50ms duration
74 | slog.Debug("Received DTMF packet but short duration", "dur", dur)
75 | return
76 | }
77 |
78 | w.dtmf = DTMFToRune(ev.Event)
79 | w.dtmfSet = true
80 | w.lastEv = DTMFEvent{}
81 | return
82 | }
83 | if w.lastEv.Duration > 0 && w.lastEv.Event == ev.Event {
84 | return
85 | }
86 | w.lastEv = ev
87 | }
88 |
89 | func (w *RTPDtmfReader) ReadDTMF() (rune, bool) {
90 | defer func() { w.dtmfSet = false }()
91 | return w.dtmf, w.dtmfSet
92 | // dtmf, ok := <-w.dtmfCh
93 | // return DTMFToRune(dtmf), ok
94 | }
95 |
--------------------------------------------------------------------------------
/media/rtp_dtmf_reader_test.go:
--------------------------------------------------------------------------------
1 | package media
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestDTMFReader(t *testing.T) {
11 | r := RTPDtmfReader{}
12 |
13 | // DTMF 109
14 | sequence := []DTMFEvent{
15 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 160},
16 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 320},
17 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 480},
18 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 640},
19 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
20 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
21 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
22 | {Event: 0, EndOfEvent: false, Volume: 10, Duration: 160},
23 | {Event: 0, EndOfEvent: false, Volume: 10, Duration: 320},
24 | {Event: 0, EndOfEvent: false, Volume: 10, Duration: 480},
25 | {Event: 0, EndOfEvent: false, Volume: 10, Duration: 640},
26 | {Event: 0, EndOfEvent: true, Volume: 10, Duration: 800},
27 | {Event: 0, EndOfEvent: true, Volume: 10, Duration: 800},
28 | {Event: 0, EndOfEvent: true, Volume: 10, Duration: 800},
29 | {Event: 9, EndOfEvent: false, Volume: 10, Duration: 160},
30 | {Event: 9, EndOfEvent: false, Volume: 10, Duration: 320},
31 | {Event: 9, EndOfEvent: false, Volume: 10, Duration: 480},
32 | {Event: 9, EndOfEvent: false, Volume: 10, Duration: 640},
33 | {Event: 9, EndOfEvent: true, Volume: 10, Duration: 800},
34 | {Event: 9, EndOfEvent: true, Volume: 10, Duration: 800},
35 | {Event: 9, EndOfEvent: true, Volume: 10, Duration: 800},
36 | }
37 |
38 | detected := strings.Builder{}
39 | for _, ev := range sequence {
40 | r.processDTMFEvent(ev)
41 | dtmf, set := r.ReadDTMF()
42 | if set {
43 | detected.WriteRune(dtmf)
44 | }
45 | }
46 |
47 | assert.Equal(t, "109", detected.String())
48 | }
49 |
50 | func TestDTMFReaderRepeated(t *testing.T) {
51 | r := RTPDtmfReader{}
52 |
53 | // DTMF 109
54 | sequence := []DTMFEvent{
55 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 160},
56 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 320},
57 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 480},
58 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 640},
59 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
60 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
61 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
62 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 160},
63 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 320},
64 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 480},
65 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 640},
66 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
67 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
68 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
69 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 160},
70 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 320},
71 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 480},
72 | {Event: 1, EndOfEvent: false, Volume: 10, Duration: 640},
73 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
74 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
75 | {Event: 1, EndOfEvent: true, Volume: 10, Duration: 800},
76 | }
77 |
78 | detected := strings.Builder{}
79 | for _, ev := range sequence {
80 | r.processDTMFEvent(ev)
81 | dtmf, set := r.ReadDTMF()
82 | if set {
83 | detected.WriteRune(dtmf)
84 | }
85 | }
86 |
87 | assert.Equal(t, "111", detected.String())
88 | }
89 |
--------------------------------------------------------------------------------
/media/rtp_dtmf_writer.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "io"
8 | "sync"
9 | "time"
10 | )
11 |
12 | type RTPDtmfWriter struct {
13 | codec Codec
14 | writer io.Writer
15 | packetWriter *RTPPacketWriter
16 |
17 | mu sync.Mutex
18 | }
19 |
20 | // RTP DTMF writer is midleware for passing RTP DTMF event.
21 | // If it is chained it uses to block writer while writing DTFM events
22 | func NewRTPDTMFWriter(codec Codec, rtpPacketizer *RTPPacketWriter, writer io.Writer) *RTPDtmfWriter {
23 | return &RTPDtmfWriter{
24 | codec: codec,
25 | packetWriter: rtpPacketizer,
26 | writer: writer,
27 | }
28 | }
29 |
30 | // Write is RTP io.Writer which adds more sync mechanism
31 | func (w *RTPDtmfWriter) Write(b []byte) (int, error) {
32 | // If locked it means writer is currently writing DTMF over same stream
33 | w.mu.Lock()
34 | defer w.mu.Unlock()
35 | // Write whatever is intended
36 | n, err := w.writer.Write(b)
37 | if err != nil {
38 | return n, err
39 | }
40 |
41 | return n, nil
42 | }
43 |
44 | func (w *RTPDtmfWriter) WriteDTMF(dtmf rune) error {
45 | w.mu.Lock()
46 | defer w.mu.Unlock()
47 | return w.writeDTMF(dtmf)
48 | }
49 |
50 | func (w *RTPDtmfWriter) writeDTMF(dtmf rune) error {
51 | // DTMF events are send directly to packet writer as they are different Codec
52 | packetWriter := w.packetWriter
53 |
54 | evs := RTPDTMFEncode(dtmf)
55 | ticker := time.NewTicker(w.codec.SampleDur)
56 | defer ticker.Stop()
57 | for i, e := range evs {
58 | data := DTMFEncode(e)
59 | marker := i == 0
60 |
61 | // https://datatracker.ietf.org/doc/html/rfc2833#section-3.6
62 | // An audio source SHOULD start transmitting event packets as soon as it
63 | // recognizes an event and every 50 ms thereafter or the packet interval
64 | // for the audio codec used for this session
65 |
66 | <-ticker.C
67 | // We are simulating RTP clock rate
68 | // timestamp should not be increased for dtmf
69 | _, err := packetWriter.WriteSamples(data, 0, marker, w.codec.PayloadType)
70 | if err != nil {
71 | return err
72 | }
73 | }
74 | return nil
75 | }
76 |
--------------------------------------------------------------------------------
/media/rtp_examples_test.go:
--------------------------------------------------------------------------------
1 | package media
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "os"
7 |
8 | "github.com/pion/rtp"
9 | )
10 |
11 | type rtpBuffer struct {
12 | buf []rtp.Packet
13 | }
14 |
15 | func (b *rtpBuffer) WriteRTP(p *rtp.Packet) error {
16 | b.buf = append(b.buf, *p)
17 | return nil
18 | }
19 |
20 | // Example on how to generate RTP packets from audio bytes
21 | func Example_audio2RTPGenerator() {
22 | // Create some audio
23 | audioAlawBuf := make([]byte, 4*160)
24 | copy(audioAlawBuf, bytes.Repeat([]byte("0123456789"), CodecAudioAlaw.Samples16()*2/10))
25 |
26 | // Create Packet writer and pass RTP buff
27 | rtpBuf := &rtpBuffer{}
28 | rtpGenerator := NewRTPPacketWriter(rtpBuf, CodecAudioAlaw)
29 | WriteAll(rtpGenerator, audioAlawBuf, 160)
30 |
31 | // Now we have RTP packets ready to use from audio
32 | for _, p := range rtpBuf.buf {
33 | fmt.Fprint(os.Stderr, p.String())
34 | }
35 | fmt.Println(len(rtpBuf.buf))
36 | // Output: 4
37 | }
38 |
--------------------------------------------------------------------------------
/media/rtp_packet_reader.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "errors"
8 | "io"
9 | "log/slog"
10 | "net"
11 | "sync"
12 |
13 | "github.com/pion/rtcp"
14 | "github.com/pion/rtp"
15 | )
16 |
17 | // var rtpBufPool = &sync.Pool{
18 | // New: func() any { return make([]byte, RTPBufSize) },
19 | // }
20 |
21 | type RTPReader interface {
22 | ReadRTP(buf []byte, p *rtp.Packet) (int, error)
23 | }
24 |
25 | type RTPReaderRaw interface {
26 | ReadRTPRaw(buf []byte) (int, error)
27 | }
28 |
29 | type RTCPReader interface {
30 | ReadRTCP(buf []byte, pkts []rtcp.Packet) (n int, err error)
31 | }
32 |
33 | type RTPCReaderRaw interface {
34 | ReadRTCPRaw(buf []byte) (int, error)
35 | }
36 |
37 | // RTPPacketReader reads RTP packet and extracts payload and header
38 | type RTPPacketReader struct {
39 | mu sync.RWMutex
40 | log *slog.Logger
41 |
42 | reader RTPReader
43 |
44 | // PacketHeader is stored after calling Read
45 | // Safe to read only in same goroutine as Read
46 | PacketHeader rtp.Header
47 | // packet is temporarly packet holder for header and data
48 | packet rtp.Packet
49 |
50 | // payloadType uint8
51 | seqReader RTPExtendedSequenceNumber
52 |
53 | unreadPayload []byte
54 | unread int
55 | // We want to track our last SSRC.
56 | lastSSRC uint32
57 | }
58 |
59 | // NewRTPPacketReaderSession just helper constructor
60 | func NewRTPPacketReaderSession(sess *RTPSession) *RTPPacketReader {
61 | r := newRTPPacketReaderMedia(sess.Sess)
62 | r.reader = sess
63 | return r
64 | }
65 |
66 | // used for tests only
67 | func newRTPPacketReaderMedia(sess *MediaSession) *RTPPacketReader {
68 | codec := CodecFromSession(sess)
69 | w := NewRTPPacketReader(sess, codec)
70 | return w
71 | }
72 |
73 | func NewRTPPacketReader(reader RTPReader, codec Codec) *RTPPacketReader {
74 | w := RTPPacketReader{
75 | reader: reader,
76 | // payloadType: codec.PayloadType,
77 | seqReader: RTPExtendedSequenceNumber{},
78 | // unreadPayload: make([]byte, RTPBufSize),
79 | // rtpBuffer: make([]byte, RTPBufSize),
80 | log: slog.Default().With("caller", "media"),
81 | }
82 |
83 | return &w
84 | }
85 |
86 | // Read Implements io.Reader and extracts Payload from RTP packet
87 | // has no input queue or sorting control of packets
88 | // Buffer is used for reading headers and Headers are stored in PacketHeader
89 | //
90 | // NOTE: Consider that if you are passsing smaller buffer than RTP header+payload, io.ErrShortBuffer is returned
91 | func (r *RTPPacketReader) Read(b []byte) (int, error) {
92 | if r.unread > 0 {
93 | n := r.readPayload(b, r.unreadPayload[:r.unread])
94 | return n, nil
95 | }
96 |
97 | var n int
98 |
99 | // For io.ReadAll buffer size is constantly changing and starts small. Normally user should set buf > RTPBufSize
100 | // Use unread buffer and still avoid alloc
101 | buf := b
102 | unreadPayload := b
103 | if len(b) < RTPBufSize {
104 | if r.unreadPayload == nil {
105 | r.log.Debug("Read RTP buf is < RTPBufSize!!! Creating larger buffer!!!")
106 | r.unreadPayload = make([]byte, RTPBufSize)
107 | }
108 |
109 | buf = r.unreadPayload
110 | unreadPayload = r.unreadPayload
111 | }
112 |
113 | pkt := &r.packet
114 | pkt.Payload = unreadPayload
115 |
116 | r.mu.RLock()
117 | reader := r.reader
118 | r.mu.RUnlock()
119 |
120 | rtpN, err := reader.ReadRTP(buf, pkt)
121 | if err != nil {
122 | // For now underhood IO should only net closed
123 | // Here we are returning EOF to be io package compatilble
124 | // like with func io.ReadAll
125 | if errors.Is(err, net.ErrClosed) {
126 | return 0, io.EOF
127 | }
128 | return 0, err
129 | }
130 | if rtpN == 0 {
131 | // ZERO Payload?
132 | r.log.Warn("ZERO Payload on RTP")
133 | return 0, nil
134 | }
135 |
136 | payloadSize := rtpN - pkt.Header.MarshalSize() - int(pkt.PaddingSize)
137 | // In case of DTMF we can receive different payload types
138 | // if pt != pkt.PayloadType {
139 | // return 0, fmt.Errorf("payload type does not match. expected=%d, actual=%d", pt, pkt.PayloadType)
140 | // }
141 |
142 | // If we are tracking this source, do check are we keep getting pkts in sequence
143 | if r.lastSSRC == pkt.SSRC {
144 | prevSeq := r.seqReader.ReadExtendedSeq()
145 | if err := r.seqReader.UpdateSeq(pkt.SequenceNumber); err != nil {
146 | r.log.Warn(err.Error())
147 | }
148 |
149 | newSeq := r.seqReader.ReadExtendedSeq()
150 | if prevSeq+1 != newSeq {
151 | r.log.Debug("Out of order pkt received", "expected", prevSeq+1, "actual", newSeq, "real", pkt.SequenceNumber)
152 | }
153 | } else {
154 | r.seqReader.InitSeq(pkt.SequenceNumber)
155 | }
156 |
157 | r.lastSSRC = pkt.SSRC
158 | r.PacketHeader = pkt.Header
159 | // Is there better way to compare this?
160 | if len(b) != len(unreadPayload) {
161 | // We are not using passed buffer. We need to copy payload
162 | pkt.Payload = pkt.Payload[:payloadSize]
163 | n = r.readPayload(b, pkt.Payload)
164 | return n, nil
165 |
166 | }
167 | return payloadSize, nil
168 | }
169 |
170 | func (r *RTPPacketReader) readPayload(b []byte, payload []byte) int {
171 | n := copy(b, payload)
172 | if n < len(payload) {
173 | written := copy(r.unreadPayload, payload[n:])
174 | if written < len(payload[n:]) {
175 | r.log.Error("Payload is huge, it will be unread")
176 | }
177 | r.unread = written
178 | } else {
179 | r.unread = 0
180 | }
181 | return n
182 | }
183 |
184 | func (r *RTPPacketReader) Reader() RTPReader {
185 | r.mu.RLock()
186 | defer r.mu.RUnlock()
187 | return r.reader
188 | }
189 |
190 | func (r *RTPPacketReader) UpdateRTPSession(rtpSess *RTPSession) {
191 | r.UpdateReader(rtpSess)
192 | // codec := CodecFromSession(rtpSess.Sess)
193 | // r.mu.Lock()
194 | // r.RTPSession = rtpSess
195 | // // r.payloadType = codec.PayloadType
196 | // r.reader = rtpSess
197 | // r.mu.Unlock()
198 | }
199 |
200 | func (r *RTPPacketReader) UpdateReader(reader RTPReader) {
201 | // codec := CodecFromSession(rtpSess.Sess)
202 | r.mu.Lock()
203 | r.reader = reader
204 | r.mu.Unlock()
205 | }
206 |
--------------------------------------------------------------------------------
/media/rtp_packet_reader_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "bytes"
8 | "io"
9 | "net"
10 | "testing"
11 |
12 | "github.com/emiago/sipgo/fakes"
13 | "github.com/pion/rtp"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func fakeMediaSessionReader(lport int, rtpReader io.Reader) *MediaSession {
18 | sess := &MediaSession{
19 | Codecs: []Codec{CodecAudioAlaw, CodecAudioUlaw},
20 | Laddr: net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: lport},
21 | }
22 |
23 | conn := &fakes.UDPConn{
24 | Reader: rtpReader,
25 | }
26 | sess.rtpConn = conn
27 | return sess
28 | }
29 |
30 | func TestRTPReader(t *testing.T) {
31 | rtpConn := bytes.NewBuffer([]byte{})
32 | sess := fakeMediaSessionReader(0, rtpConn)
33 | rtpSess := NewRTPSession(sess)
34 | rtpReader := NewRTPPacketReaderSession(rtpSess)
35 |
36 | payload := []byte("12312313")
37 | N := 10
38 | buf := make([]byte, 3200)
39 | for i := 0; i < N; i++ {
40 | writePkt := rtp.Packet{
41 | Header: rtp.Header{
42 | SSRC: 1234,
43 | Version: 2,
44 | PayloadType: 8,
45 | SequenceNumber: uint16(i),
46 | Timestamp: 160 * uint32(i),
47 | Marker: i == 0,
48 | },
49 | Payload: payload,
50 | }
51 | data, _ := writePkt.Marshal()
52 | rtpConn.Reset()
53 | rtpConn.Write(data)
54 | // conn.Reader = bytes.NewBuffer(data)
55 |
56 | n, err := rtpReader.Read(buf)
57 | require.NoError(t, err)
58 |
59 | pkt := rtpReader.PacketHeader
60 | require.Equal(t, writePkt.PayloadType, pkt.PayloadType)
61 | require.Equal(t, writePkt.SSRC, pkt.SSRC)
62 | require.Equal(t, i == 0, pkt.Marker)
63 | require.Equal(t, len(payload), n)
64 | require.Equal(t, rtpReader.seqReader.ReadExtendedSeq(), uint64(writePkt.SequenceNumber))
65 | }
66 | }
67 |
68 | func BenchmarkRTPReader(b *testing.B) {
69 | rtpConn := bytes.NewBuffer([]byte{})
70 | sess := fakeMediaSessionReader(0, rtpConn)
71 | rtpSess := NewRTPSession(sess)
72 | rtpReader := NewRTPPacketReaderSession(rtpSess)
73 |
74 | payload := []byte("12312313")
75 | buf := make([]byte, 3200)
76 | b.ResetTimer()
77 | for i := 0; i < b.N; i++ {
78 | writePkt := rtp.Packet{
79 | Header: rtp.Header{
80 | SSRC: 1234,
81 | Version: 2,
82 | PayloadType: 8,
83 | SequenceNumber: uint16(i % (1 << 16)),
84 | Timestamp: 160 * uint32(i),
85 | Marker: i == 0,
86 | },
87 | Payload: payload,
88 | }
89 | data, _ := writePkt.Marshal()
90 | rtpConn.Write(data)
91 |
92 | _, err := rtpReader.Read(buf)
93 | require.NoError(b, err)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/media/rtp_packet_writer.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "math/rand"
8 | "sync"
9 | "time"
10 |
11 | "github.com/pion/rtcp"
12 | "github.com/pion/rtp"
13 | )
14 |
15 | type RTPWriter interface {
16 | WriteRTP(p *rtp.Packet) error
17 | }
18 | type RTPWriterRaw interface {
19 | WriteRTPRaw(buf []byte) (int, error) // -> io.Writer
20 | }
21 |
22 | type RTCPWriter interface {
23 | WriteRTCP(p rtcp.Packet) error
24 | }
25 |
26 | type RTCPWriterRaw interface {
27 | WriteRTCPRaw(buf []byte) (int, error) // -> io.Writer
28 | }
29 |
30 | // RTPPacketWriter packetize any payload before pushing to active media session
31 | // It creates SSRC as identifier and all packets sent will be with this SSRC
32 | // For multiple streams, multiple RTP Writer needs to be created
33 | type RTPPacketWriter struct {
34 | mu sync.RWMutex
35 | writer RTPWriter
36 | clockTicker *time.Ticker
37 |
38 | // After each write packet header is saved for more reading
39 | PacketHeader rtp.Header
40 | // packet is temporarly packet holder for header and data
41 | packet rtp.Packet
42 | // SSRC is readOnly and it is not changed
43 | SSRC uint32
44 |
45 | payloadType uint8
46 | sampleRate uint32
47 |
48 | // Internals
49 | // clock rate is decided based on media
50 | sampleRateTimestamp uint32
51 | lastSampleTime time.Time
52 | seqWriter RTPExtendedSequenceNumber
53 | nextTimestamp uint32
54 | initTimestamp uint32
55 | }
56 |
57 | // RTPPacketWriter packetize payload in RTP packet before passing on media session
58 | // Not having:
59 | // - random Timestamp
60 | // - allow different clock rate
61 | // - CSRC contribution source
62 | // - Silence detection and marker set
63 | // updateClockRate- Padding and encryyption
64 | func NewRTPPacketWriter(writer RTPWriter, codec Codec) *RTPPacketWriter {
65 | w := RTPPacketWriter{
66 | writer: writer,
67 | seqWriter: NewRTPSequencer(),
68 | payloadType: codec.PayloadType,
69 | sampleRate: codec.SampleRate,
70 | SSRC: rand.Uint32(),
71 | // initTimestamp: rand.Uint32(), // TODO random start timestamp
72 | // MTU: 1500,
73 |
74 | // TODO: CSRC CSRC is contribution source identifiers.
75 | // This is set when media is passed trough mixer/translators and original SSRC wants to be preserverd
76 | }
77 |
78 | w.nextTimestamp = w.initTimestamp
79 | w.updateClockRate(codec)
80 | return &w
81 | }
82 |
83 | // NewRTPPacketWriterSession creates RTPPacketWriter and attaches RTP Session expected values
84 | func NewRTPPacketWriterSession(sess *RTPSession) *RTPPacketWriter {
85 | codec := CodecFromSession(sess.Sess)
86 | w := NewRTPPacketWriter(sess, codec)
87 | // We need to add our SSRC due to sender report, which can be empty until data comes
88 | // It is expected that nothing travels yet through rtp session
89 | // sess.writeStats.SSRC = w.SSRC
90 | // sess.writeStats.sampleRate = w.sampleRate
91 | w.writer = sess
92 | return w
93 | }
94 |
95 | func (w *RTPPacketWriter) updateClockRate(cod Codec) {
96 | w.sampleRateTimestamp = cod.SampleTimestamp()
97 | if w.clockTicker != nil {
98 | w.clockTicker.Reset(cod.SampleDur)
99 | } else {
100 | w.clockTicker = time.NewTicker(cod.SampleDur)
101 | }
102 | }
103 |
104 | // ResetTimestamp can mark new stream comming. If stream is continuous it will add timestamp difference
105 | // MUST Not be called during stream Write
106 | func (p *RTPPacketWriter) ResetTimestamp() {
107 | p.mu.Lock()
108 | defer p.mu.Unlock()
109 |
110 | if !p.lastSampleTime.IsZero() {
111 | // Detect delays in audio and update RTP timestamp
112 | t := time.Now()
113 | diff := t.Sub(p.lastSampleTime)
114 | diffTimestamp := uint32(diff.Seconds() * float64(p.sampleRate))
115 |
116 | p.nextTimestamp += diffTimestamp
117 | p.initTimestamp = p.nextTimestamp // This will now make Marker true
118 | return
119 | }
120 | }
121 |
122 | // InitTimestamp returns init RTP timestamp
123 | func (p *RTPPacketWriter) InitTimestamp() uint32 {
124 | p.mu.RLock()
125 | defer p.mu.RUnlock()
126 | return p.initTimestamp
127 | }
128 |
129 | func (p *RTPPacketWriter) DelayTimestamp(ofsset uint32) {
130 | p.mu.Lock()
131 | defer p.mu.Unlock()
132 | p.nextTimestamp += ofsset
133 | }
134 |
135 | // Write implements io.Writer and does payload RTP packetization
136 | // Media clock rate is determined
137 | // For more control or dynamic payload WriteSamples can be used
138 | // It is not thread safe and order of payload frames is required
139 | func (p *RTPPacketWriter) Write(b []byte) (int, error) {
140 | p.mu.RLock()
141 | n, err := p.WriteSamples(b, p.sampleRateTimestamp, p.nextTimestamp == p.initTimestamp, p.payloadType)
142 | p.mu.RUnlock()
143 | p.lastSampleTime = <-p.clockTicker.C
144 | return n, err
145 | }
146 |
147 | // WriteSamples allows to skip default packet rate.
148 | // This is useful if you need to write different payload but keeping same SSRC
149 | func (p *RTPPacketWriter) WriteSamples(payload []byte, sampleRateTimestamp uint32, marker bool, payloadType uint8) (int, error) {
150 | writer := p.writer
151 | pkt := &p.packet
152 | pkt.Header = rtp.Header{
153 | Version: 2,
154 | Padding: false,
155 | Extension: false,
156 | Marker: marker,
157 | PayloadType: payloadType,
158 | // Timestamp should increase linear and monotonic for media clock
159 | // Payload must be in same clock rate
160 | // TODO: what about wrapp arround
161 | Timestamp: p.nextTimestamp,
162 | SequenceNumber: p.seqWriter.NextSeqNumber(),
163 | SSRC: p.SSRC,
164 | // CSRC: []uint32{},
165 | }
166 | pkt.Payload = payload
167 | p.nextTimestamp += sampleRateTimestamp
168 |
169 | err := writer.WriteRTP(pkt)
170 | // store header for reading. NOTE: in case pointers in header, do nil first
171 | p.PacketHeader = pkt.Header
172 | return len(pkt.Payload), err
173 | }
174 |
175 | func (w *RTPPacketWriter) Writer() RTPWriter {
176 | w.mu.RLock()
177 | defer w.mu.RUnlock()
178 | return w.writer
179 | }
180 |
181 | // UpdateRTPSession updates rtp writer from current rtp session due to REINVITE
182 | // It is expected that this is now new RTP Session and it is expected tha:
183 | // - Statistics will be reset (SSRC=0) -> Fresh Start of Quality monitoring
184 | // - Should not lead inacurate reporting
185 | // - In case CODEC change than RTP should reset stats anyway
186 | func (w *RTPPacketWriter) UpdateRTPSession(rtpSess *RTPSession) {
187 | w.mu.Lock()
188 | defer w.mu.Unlock()
189 |
190 | // In case of codec cha
191 | codec := CodecFromSession(rtpSess.Sess)
192 | w.payloadType = codec.PayloadType
193 | w.sampleRate = codec.SampleRate
194 | w.updateClockRate(codec)
195 | w.writer = rtpSess
196 | // rtpSess.writeStats.SSRC = w.SSRC
197 | // rtpSess.writeStats.sampleRate = w.sampleRate
198 | }
199 |
--------------------------------------------------------------------------------
/media/rtp_packet_writer_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "bytes"
8 | "io"
9 | "net"
10 | "testing"
11 | "time"
12 |
13 | "github.com/emiago/sipgo/fakes"
14 | "github.com/stretchr/testify/require"
15 | )
16 |
17 | func fakeMediaSessionWriter(lport int, rport int, rtpWriter io.Writer) *MediaSession {
18 | sess := &MediaSession{
19 | Codecs: []Codec{CodecAudioAlaw, CodecAudioUlaw},
20 | Laddr: net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)},
21 | Raddr: net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1234},
22 | }
23 |
24 | conn := &fakes.UDPConn{
25 | Writers: map[string]io.Writer{
26 | sess.Raddr.String(): bytes.NewBuffer([]byte{}),
27 | },
28 | }
29 | sess.rtpConn = conn
30 | return sess
31 | }
32 |
33 | func TestRTPWriter(t *testing.T) {
34 | rtpConn := bytes.NewBuffer([]byte{})
35 | sess := fakeMediaSessionWriter(0, 1234, rtpConn)
36 | rtpSession := NewRTPSession(sess)
37 | rtpWriter := NewRTPPacketWriterSession(rtpSession)
38 |
39 | payload := []byte("12312313")
40 | N := 10
41 | for i := 0; i < N; i++ {
42 | _, err := rtpWriter.Write(payload)
43 | require.NoError(t, err)
44 |
45 | pkt := rtpWriter.PacketHeader
46 |
47 | require.Equal(t, rtpWriter.payloadType, pkt.PayloadType)
48 | require.Equal(t, rtpWriter.SSRC, pkt.SSRC)
49 | require.Equal(t, rtpWriter.seqWriter.ReadExtendedSeq(), uint64(pkt.SequenceNumber))
50 | require.Equal(t, rtpWriter.nextTimestamp, pkt.Timestamp+160, "%d vs %d", rtpWriter.nextTimestamp, pkt.Timestamp)
51 | require.Equal(t, i == 0, pkt.Marker)
52 | }
53 | }
54 |
55 | func BenchmarkRTPPacketWriter(b *testing.B) {
56 | reader, writer := io.Pipe()
57 | session := fakeMediaSessionWriter(0, 1234, writer)
58 | rtpSess := NewRTPSession(session)
59 | w := NewRTPPacketWriterSession(rtpSess)
60 | w.clockTicker.Reset(1 * time.Nanosecond)
61 |
62 | go func() {
63 | io.ReadAll(reader)
64 | }()
65 |
66 | data := make([]byte, 160)
67 |
68 | for i := 0; i < b.N; i++ {
69 | _, err := w.Write(data)
70 | if err != nil {
71 | b.Error(err)
72 | }
73 | }
74 |
75 | b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "writes/s")
76 | }
77 |
--------------------------------------------------------------------------------
/media/rtp_parse.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "errors"
8 | "fmt"
9 | "io"
10 |
11 | "github.com/pion/rtcp"
12 | "github.com/pion/rtp"
13 | )
14 |
15 | var (
16 | errRTCPFailedToUnmarshal = errors.New("rtcp: failed to unmarshal")
17 | )
18 |
19 | // Experimental
20 | //
21 | // RTPUnmarshal temporarly solution to provide more optimized unmarshal version based on pion/rtp
22 | // it does not preserve any buffer reference which allows reusage
23 | //
24 | // TODO build RTP header unmarshaller for VOIP needs
25 | func RTPUnmarshal(buf []byte, p *rtp.Packet) error {
26 | n, err := p.Header.Unmarshal(buf)
27 | if err != nil {
28 | return err
29 | }
30 | if p.Header.Extension {
31 | // For now eliminate it as it holds reference on buffer
32 | // TODO fix this
33 | p.Header.Extensions = nil
34 | p.Header.Extension = false
35 | }
36 |
37 | end := len(buf)
38 | if p.Header.Padding {
39 | p.PaddingSize = buf[end-1]
40 | end -= int(p.PaddingSize)
41 | }
42 | if end < n {
43 | return io.ErrShortBuffer
44 | }
45 |
46 | // If Payload buffer exists try to fill it and allow buffer reusage
47 | if p.Payload != nil && len(p.Payload) >= len(buf[n:end]) {
48 | copy(p.Payload, buf[n:end])
49 | return nil
50 | }
51 |
52 | // This creates allocations
53 | // Payload should be recreated instead referenced
54 | // This allows buf reusage
55 | p.Payload = make([]byte, len(buf[n:end]))
56 | copy(p.Payload, buf[n:end])
57 | return nil
58 | }
59 |
60 | // RTCPUnmarshal is improved version based on pion/rtcp where we allow caller to define and control
61 | // buffer of rtcp packets. This also reduces one allocation
62 | // NOTE: data is still referenced in packet buffer
63 | func RTCPUnmarshal(data []byte, packets []rtcp.Packet) (n int, err error) {
64 | for i := 0; i < len(packets) && len(data) != 0; i++ {
65 | var h rtcp.Header
66 |
67 | err = h.Unmarshal(data)
68 | if err != nil {
69 | // fmt.Errorf("unmarshal RTCP error: %w", err)
70 | return 0, errors.Join(err, errRTCPFailedToUnmarshal)
71 | }
72 |
73 | pktLen := int(h.Length+1) * 4
74 | if pktLen > len(data) {
75 | return 0, fmt.Errorf("packet too short: %w", errRTCPFailedToUnmarshal)
76 | }
77 | inPacket := data[:pktLen]
78 |
79 | // Check the type and unmarshal
80 | packet := rtcpTypedPacket(h.Type)
81 | err = packet.Unmarshal(inPacket)
82 | if err != nil {
83 | return 0, err
84 | }
85 |
86 | packets[i] = packet
87 |
88 | data = data[pktLen:]
89 | n++
90 | }
91 |
92 | return n, nil
93 | }
94 |
95 | func rtcpMarshal(packets []rtcp.Packet) ([]byte, error) {
96 | return rtcp.Marshal(packets)
97 | }
98 |
99 | // TODO this would be nice that pion exports
100 | func rtcpTypedPacket(htype rtcp.PacketType) rtcp.Packet {
101 | // Currently we are not interested
102 |
103 | switch htype {
104 | case rtcp.TypeSenderReport:
105 | return new(rtcp.SenderReport)
106 |
107 | case rtcp.TypeReceiverReport:
108 | return new(rtcp.ReceiverReport)
109 |
110 | case rtcp.TypeSourceDescription:
111 | return new(rtcp.SourceDescription)
112 |
113 | case rtcp.TypeGoodbye:
114 | return new(rtcp.Goodbye)
115 |
116 | default:
117 | return new(rtcp.RawPacket)
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/media/rtp_parse_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "io"
8 | "testing"
9 |
10 | "github.com/emiago/sipgo/fakes"
11 | "github.com/pion/rtcp"
12 | "github.com/pion/rtp"
13 | )
14 |
15 | func BenchmarkRTCPUnmarshal(b *testing.B) {
16 | reader, writer := io.Pipe()
17 | go func() {
18 | for {
19 | sr := rtcp.SenderReport{}
20 | data, err := sr.Marshal()
21 | if err != nil {
22 | return
23 | }
24 |
25 | writer.Write(data)
26 | }
27 | }()
28 |
29 | b.Run("pionRTCP", func(b *testing.B) {
30 | buf := make([]byte, 1500)
31 | for i := 0; i < b.N; i++ {
32 | n, err := reader.Read(buf)
33 | if err != nil {
34 | b.Fatal(err)
35 | }
36 | pkts, err := rtcp.Unmarshal(buf[:n])
37 | if err != nil {
38 | b.Fatal(err)
39 | }
40 | if len(pkts) == 0 {
41 | b.Fatal("no packet read")
42 | }
43 | }
44 | })
45 |
46 | b.Run("RTCPImproved", func(b *testing.B) {
47 | buf := make([]byte, 1500)
48 | pkts := make([]rtcp.Packet, 5)
49 | for i := 0; i < b.N; i++ {
50 | n, err := reader.Read(buf)
51 | if err != nil {
52 | b.Fatal(err)
53 | }
54 | n, err = RTCPUnmarshal(buf[:n], pkts)
55 | if err != nil {
56 | b.Fatal(err)
57 | }
58 | if n < 0 {
59 | b.Fatal("no read RTCP")
60 | }
61 | }
62 | })
63 | }
64 |
65 | func BenchmarkReadRTP(b *testing.B) {
66 | session := &MediaSession{}
67 | reader, writer := io.Pipe()
68 | session.rtpConn = &fakes.UDPConn{
69 | Reader: reader,
70 | }
71 |
72 | go func() {
73 | for {
74 | pkt := rtp.Packet{
75 | Payload: make([]byte, 160),
76 | }
77 | data, err := pkt.Marshal()
78 | if err != nil {
79 | return
80 | }
81 | writer.Write(data)
82 | }
83 | }()
84 |
85 | b.Run("return", func(b *testing.B) {
86 | b.ResetTimer()
87 | b.ReportAllocs()
88 |
89 | b.RunParallel(func(p *testing.PB) {
90 | for p.Next() {
91 | pkt, err := session.readRTPParsed()
92 | if err != nil {
93 | b.Fatal(err)
94 | }
95 | if len(pkt.Payload) != 160 {
96 | b.Fatal("payload not parsed")
97 | }
98 | }
99 | })
100 |
101 | })
102 |
103 | b.Run("pass", func(b *testing.B) {
104 | b.ResetTimer()
105 | b.ReportAllocs()
106 |
107 | b.RunParallel(func(p *testing.PB) {
108 | buf := make([]byte, RTPBufSize)
109 | for p.Next() {
110 | pkt := rtp.Packet{}
111 | _, err := session.ReadRTP(buf, &pkt)
112 | if err != nil {
113 | b.Fatal(err)
114 | }
115 | if len(pkt.Payload) != 160 {
116 | b.Fatal("payload not parsed")
117 | }
118 | }
119 | })
120 | })
121 |
122 | b.Run("withPayloadBuf", func(b *testing.B) {
123 | b.ResetTimer()
124 | b.ReportAllocs()
125 |
126 | b.RunParallel(func(p *testing.PB) {
127 | buf := make([]byte, RTPBufSize)
128 | for p.Next() {
129 | pkt := rtp.Packet{
130 | Payload: buf,
131 | }
132 | _, err := session.ReadRTP(buf, &pkt)
133 | if err != nil {
134 | b.Fatal(err)
135 | }
136 | if len(pkt.Payload) == 0 {
137 | b.Fatal("payload not parsed")
138 | }
139 | }
140 | })
141 | })
142 | }
143 |
--------------------------------------------------------------------------------
/media/rtp_sequencer.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "errors"
8 | "math/rand"
9 | )
10 |
11 | var (
12 | // RTP spec recomned
13 | maxMisorder uint16 = 100
14 | maxDropout uint16 = 3000
15 | maxSeqNum uint16 = 65535
16 | )
17 |
18 | var (
19 | ErrRTPSequenceOutOfOrder = errors.New("out of order")
20 | ErrRTPSequenceBad = errors.New("bad sequence")
21 | ErrRTPSequnceDuplicate = errors.New("sequence duplicate")
22 | )
23 |
24 | // RTPExtendedSequenceNumber is embedable/ replacable sequnce number generator
25 | // For thread safety you should wrap it
26 | type RTPExtendedSequenceNumber struct {
27 | seqNum uint16 // highest sequence received/created
28 | wrapArroundCount uint16
29 |
30 | badSeq uint16
31 | }
32 |
33 | func NewRTPSequencer() RTPExtendedSequenceNumber {
34 | // There are more safer approaches but best is just SRTP
35 | seq := uint16(rand.Uint32())
36 | sn := RTPExtendedSequenceNumber{}
37 | sn.InitSeq(seq)
38 | return sn
39 | }
40 |
41 | func (sn *RTPExtendedSequenceNumber) InitSeq(seq uint16) {
42 | sn.seqNum = seq
43 | sn.badSeq = maxSeqNum
44 | sn.wrapArroundCount = 0
45 | }
46 |
47 | // Based on https://datatracker.ietf.org/doc/html/rfc1889#appendix-A.2
48 | func (sn *RTPExtendedSequenceNumber) UpdateSeq(seq uint16) error {
49 | maxSeq := sn.seqNum
50 |
51 | // TODO probation
52 |
53 | udelta := seq - maxSeq
54 | if udelta < uint16(maxDropout) {
55 | if seq < maxSeq {
56 | sn.wrapArroundCount++
57 | }
58 | sn.seqNum = seq
59 | return nil
60 | }
61 |
62 | badSeq := sn.badSeq
63 | if udelta <= maxSeqNum-maxMisorder {
64 | // sequence number made a very large jump
65 | if seq == badSeq {
66 | sn.InitSeq(seq)
67 | return nil
68 | }
69 |
70 | sn.badSeq = seq + 1
71 | return ErrRTPSequenceBad
72 | }
73 |
74 | // Duplicate
75 | return ErrRTPSequnceDuplicate
76 | }
77 |
78 | func (sn *RTPExtendedSequenceNumber) ReadExtendedSeq() uint64 {
79 | res := uint64(sn.seqNum) + (uint64(maxSeqNum)+1)*uint64(sn.wrapArroundCount)
80 | return res
81 | }
82 |
83 | func (s *RTPExtendedSequenceNumber) NextSeqNumber() uint16 {
84 | s.seqNum++
85 | if s.seqNum == 0 {
86 | s.wrapArroundCount++
87 | }
88 |
89 | return s.seqNum
90 | }
91 |
--------------------------------------------------------------------------------
/media/rtp_sequencer_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestRTPExtendedSequenceNumberWrapping(t *testing.T) {
13 | var realSeq uint16 = (1<<16 - 1)
14 | seq := RTPExtendedSequenceNumber{
15 | seqNum: realSeq,
16 | }
17 |
18 | realSeq++
19 | seq.UpdateSeq(realSeq)
20 |
21 | assert.Equal(t, seq.wrapArroundCount, uint16(1))
22 | assert.Equal(t, seq.ReadExtendedSeq(), uint64(1<<16))
23 | }
24 |
--------------------------------------------------------------------------------
/media/rtp_stats_reader_writer.go:
--------------------------------------------------------------------------------
1 | package media
2 |
3 | import (
4 | "io"
5 | )
6 |
7 | type OnRTPReadStats func(stats RTPReadStats)
8 | type OnRTPWriteStats func(stats RTPWriteStats)
9 |
10 | type RTPStatsReader struct {
11 | // Reader should be your AudioReade or any other interceptor RTP reader that is reading audio stream
12 | Reader io.Reader
13 | RTPSession *RTPSession
14 | // OnRTPReadStats is fired each time on Read RTP. Must not block
15 | OnRTPReadStats OnRTPReadStats
16 | }
17 |
18 | func (i *RTPStatsReader) Read(b []byte) (int, error) {
19 | n, err := i.Reader.Read(b)
20 | if err != nil {
21 | return n, err
22 | }
23 |
24 | stats := i.RTPSession.ReadStats()
25 | i.OnRTPReadStats(stats)
26 | return n, err
27 | }
28 |
29 | type RTPStatsWriter struct {
30 | // Writer should be your Writer or any other interceptor RTP writer that is reading audio stream
31 | Writer io.Writer
32 | RTPSession *RTPSession
33 | // ONRTPWriteStats is fired each time on Read RTP. Must not block
34 | OnRTPWriteStats OnRTPWriteStats
35 | }
36 |
37 | func (i *RTPStatsWriter) Write(b []byte) (int, error) {
38 | n, err := i.Writer.Write(b)
39 | if err != nil {
40 | return n, err
41 | }
42 |
43 | stats := i.RTPSession.WriteStats()
44 | i.OnRTPWriteStats(stats)
45 | return n, err
46 | }
47 |
--------------------------------------------------------------------------------
/media/rtp_utils.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package media
5 |
6 | import (
7 | "errors"
8 | "io"
9 | "net"
10 | "time"
11 | )
12 |
13 | var ntpEpochOffset int64 = 2208988800
14 |
15 | func GetCurrentNTPTimestamp() uint64 {
16 | now := time.Now()
17 | return NTPTimestamp(now)
18 | }
19 |
20 | func NTPTimestamp(t time.Time) uint64 {
21 | // Number of seconds since NTP epoch
22 | seconds := t.Unix() + ntpEpochOffset
23 |
24 | // Fractional part
25 | nanos := t.Nanosecond()
26 | frac := (float64(nanos) / 1e9) * (1 << 32)
27 |
28 | // NTP timestamp is 32bit second | 32 bit fractional
29 | ntpTimestamp := (uint64(seconds) << 32) | uint64(frac)
30 |
31 | return ntpTimestamp
32 | }
33 |
34 | func NTPToTime(ntpTimestamp uint64) time.Time {
35 | // NTP timestamp is 32bit second | 32 bit fractional
36 | seconds := int64(ntpTimestamp >> 32) // Upper 32 bits
37 | frac := float64(ntpTimestamp&0x00000000FFFFFFFF) / (1 << 32) // Lower 32 bits
38 |
39 | // Convert NTP seconds to Unix seconds
40 | unixSeconds := seconds - ntpEpochOffset
41 | nsec := int64(frac * 1e9)
42 |
43 | // Create a time.Time object
44 | return time.Unix(unixSeconds, nsec)
45 | }
46 |
47 | // Function to generate a silent audio frame
48 | func generateSilentAudioFrame() []byte {
49 | frame := make([]byte, 160) // 160 bytes for a 20ms frame at 8kHz
50 |
51 | // Fill the frame with silence (zero values)
52 | for i := 0; i < len(frame); i++ {
53 | frame[i] = 0
54 | }
55 |
56 | return frame
57 | }
58 |
59 | func ReadAll(reader io.Reader, sampleSize int) ([]byte, error) {
60 | total := []byte{}
61 | buf := make([]byte, sampleSize)
62 | for {
63 | n, err := reader.Read(buf)
64 | if err != nil {
65 | if errors.Is(err, io.EOF) {
66 | break
67 | }
68 | return nil, err
69 | }
70 | total = append(total, buf[:n]...)
71 | }
72 | return total, nil
73 | }
74 |
75 | func WriteAll(w io.Writer, data []byte, sampleSize int) (int64, error) {
76 | var total int64
77 | for i := 0; i < len(data); i += sampleSize {
78 | off := min(len(data), i+sampleSize)
79 | n, err := w.Write(data[i:off])
80 | if err != nil {
81 | return 0, err
82 | }
83 | total += int64(n)
84 | }
85 | return total, nil
86 | }
87 |
88 | // Copy is like io.Copy but it uses buffer size needed for RTP
89 | func Copy(reader io.Reader, writer io.Writer) (int64, error) {
90 | return CopyWithBuf(reader, writer, make([]byte, RTPBufSize))
91 | }
92 |
93 | // CopyWithBuf is simple and strict compared to io.CopyBuffer. ReadFrom and WriteTo is not considered
94 | // and due to RTP buf requirement it can lead to different buffer size passing
95 | func CopyWithBuf(reader io.Reader, writer io.Writer, payloadBuf []byte) (int64, error) {
96 | var totalWritten int64
97 | for {
98 | n, err := reader.Read(payloadBuf)
99 | if err != nil {
100 | return totalWritten, err
101 | }
102 | nn, err := writer.Write(payloadBuf[:n])
103 | if err != nil {
104 | return totalWritten, err
105 | }
106 | totalWritten += int64(nn)
107 | if n < nn {
108 | return totalWritten, io.ErrShortWrite
109 | }
110 | }
111 | }
112 |
113 | func ErrorIsTimeout(err error) bool {
114 | e, ok := err.(net.Error)
115 | return ok && e.Timeout()
116 | }
117 |
--------------------------------------------------------------------------------
/media/sdp/formats.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package sdp
5 |
6 | import (
7 | "strconv"
8 | "strings"
9 | )
10 |
11 | const (
12 | FORMAT_TYPE_ULAW = "0"
13 | FORMAT_TYPE_ALAW = "8"
14 | FORMAT_TYPE_OPUS = "96"
15 | FORMAT_TYPE_TELEPHONE_EVENT = "101"
16 | )
17 |
18 | type Formats []string
19 |
20 | func NewFormats(fmts ...string) Formats {
21 | return Formats(fmts)
22 | }
23 |
24 | // If the sub-field is "RTP/AVP" or "RTP/SAVP" the //
25 | //
26 | // sub-fields contain RTP payload type numbers.
27 | func (fmts Formats) ToNumeric() (nfmts []int, err error) {
28 | nfmt := make([]int, len(fmts))
29 | for i, f := range fmts {
30 | nfmt[i], err = strconv.Atoi(f)
31 | if err != nil {
32 | return
33 | }
34 | }
35 | return nfmt, nil
36 | }
37 |
38 | func (fmts Formats) String() string {
39 | out := make([]string, len(fmts))
40 | for i, v := range fmts {
41 | switch v {
42 | case FORMAT_TYPE_ULAW:
43 | out[i] = "0(ulaw)"
44 | case FORMAT_TYPE_ALAW:
45 | out[i] = "8(alaw)"
46 | case FORMAT_TYPE_OPUS:
47 | out[i] = "96(opus)"
48 | default:
49 | // Unknown then just use as number
50 | out[i] = v
51 | }
52 | }
53 | return strings.Join(out, ",")
54 | }
55 |
56 | func FormatNumeric(f string) (uint8, error) {
57 | num, err := strconv.Atoi(f)
58 | return uint8(num), err
59 | }
60 |
--------------------------------------------------------------------------------
/media/sdp/sdp.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package sdp
5 |
6 | import (
7 | "bytes"
8 | "fmt"
9 | "io"
10 | "net"
11 | "strconv"
12 | "strings"
13 | "sync"
14 | )
15 |
16 | var bufReader = sync.Pool{
17 | New: func() interface{} {
18 | // The Pool's New function should generally only return pointer
19 | // types, since a pointer can be put into the return interface
20 | // value without an allocation:
21 | return new(bytes.Buffer)
22 | },
23 | }
24 |
25 | type SessionDescription map[string][]string
26 |
27 | func (sd SessionDescription) Values(key string) []string {
28 | return sd[key]
29 | }
30 |
31 | func (sd SessionDescription) Value(key string) string {
32 | values := sd[key]
33 | if len(values) == 0 {
34 | return ""
35 | }
36 | return values[0]
37 | }
38 |
39 | // MediaDescription represents a media type.
40 | // m= / ...
41 | // https://tools.ietf.org/html/rfc4566#section-5.14
42 | type MediaDescription struct {
43 | MediaType string
44 |
45 | Port int
46 | PortNumbers int
47 |
48 | Proto string
49 |
50 | Formats []string
51 | }
52 |
53 | func (m *MediaDescription) String() string {
54 | ports := strconv.Itoa(m.Port)
55 | if m.PortNumbers > 0 {
56 | ports += "/" + strconv.Itoa(m.PortNumbers)
57 | }
58 |
59 | return fmt.Sprintf("m=%s %s %s %s", m.MediaType, ports, m.Proto, strings.Join(m.Formats, " "))
60 | }
61 |
62 | func (sd SessionDescription) MediaDescription(mediaType string) (MediaDescription, error) {
63 | values := sd.Values("m")
64 |
65 | md := MediaDescription{}
66 | var v string
67 | for _, val := range values {
68 | ind := strings.Index(val, " ")
69 | if ind < 1 {
70 | continue
71 | }
72 | media := val[:ind]
73 | if media == mediaType {
74 | v = val
75 | break
76 | }
77 | }
78 |
79 | if v == "" {
80 | return md, fmt.Errorf("Media not found for %q", mediaType)
81 | }
82 |
83 | fields := strings.Fields(v)
84 | // TODO: is this really a must
85 | if len(fields) < 4 {
86 | return md, fmt.Errorf("Not enough fields in media description")
87 | }
88 |
89 | md.MediaType = fields[0]
90 |
91 | ports := strings.Split(fields[1], "/")
92 | md.Port, _ = strconv.Atoi(ports[0])
93 | if len(ports) > 1 {
94 | md.PortNumbers, _ = strconv.Atoi(ports[1])
95 | }
96 |
97 | md.Proto = fields[2]
98 |
99 | md.Formats = fields[3:]
100 | return md, nil
101 | }
102 |
103 | // c=
104 | // https://tools.ietf.org/html/rfc4566#section-5.7
105 | type ConnectionInformation struct {
106 | NetworkType string
107 | AddressType string
108 | IP net.IP
109 | TTL int
110 | Range int
111 | }
112 |
113 | func (sd SessionDescription) ConnectionInformation() (ci ConnectionInformation, err error) {
114 | v := sd.Value("c")
115 | if v == "" {
116 | return ci, fmt.Errorf("Connection information does not exists")
117 | }
118 | fields := strings.Fields(v)
119 | ci.NetworkType = fields[0]
120 | ci.AddressType = fields[1]
121 | addr := strings.Split(fields[2], "/")
122 | ci.IP = net.ParseIP(addr[0])
123 |
124 | switch ci.AddressType {
125 | case "IP4":
126 | ci.IP = ci.IP.To4()
127 | if ci.IP == nil {
128 | return ci, fmt.Errorf("failed to convert to IP4")
129 | }
130 | case "IP6":
131 | ci.IP = ci.IP.To16()
132 | if ci.IP == nil {
133 | return ci, fmt.Errorf("failed to convert to IP4")
134 | }
135 | }
136 |
137 | if len(addr) > 1 {
138 | ci.TTL, _ = strconv.Atoi(addr[1])
139 | }
140 |
141 | if len(addr) > 2 {
142 | ci.Range, _ = strconv.Atoi(addr[2])
143 | }
144 | return ci, nil
145 | }
146 |
147 | // Unmarshal is non validate version of sdp parsing
148 | // Validation of values needs to be seperate
149 | // NOT OPTIMIZED
150 | func Unmarshal(data []byte, sdptr *SessionDescription) error {
151 | reader := bufReader.Get().(*bytes.Buffer)
152 | defer bufReader.Put(reader)
153 | reader.Reset()
154 | reader.Write(data)
155 |
156 | sd := *sdptr
157 | for {
158 | line, err := nextLine(reader)
159 | if err != nil {
160 | if err == io.EOF {
161 | return nil
162 | }
163 | return err
164 | }
165 |
166 | if len(line) < 2 {
167 | continue
168 | }
169 |
170 | ind := strings.Index(line, "=")
171 | if ind < 1 {
172 | return fmt.Errorf("Not a type=value line found. line=%q", line)
173 | }
174 | key := line[:ind]
175 | value := line[ind+1:]
176 |
177 | sd[key] = append(sd[key], value)
178 | }
179 |
180 | }
181 |
182 | func nextLine(reader *bytes.Buffer) (line string, err error) {
183 | // Scan full line without buffer
184 | // If we need to continue then try to grow
185 | line, err = reader.ReadString('\n')
186 | if err != nil {
187 | // We may get io.EOF and line till it was read
188 | return line, err
189 | }
190 |
191 | lenline := len(line)
192 |
193 | // Be tolerant for CRLF
194 | if line[lenline-2] == '\r' {
195 | return line[:lenline-2], nil
196 | }
197 |
198 | line = line[:lenline-1]
199 | return line, nil
200 | }
201 |
--------------------------------------------------------------------------------
/media/sdp/sdp_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package sdp
5 |
6 | import (
7 | "net"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestParseSDP(t *testing.T) {
14 | // t.Skip("TODO: fix SDP unmarshal")
15 | body := `v=0
16 | o=- 3905350750 3905350750 IN IP4 192.168.100.11
17 | s=pjmedia
18 | b=AS:84
19 | t=0 0
20 | a=X-nat:0
21 | m=audio 57797 RTP/AVP 96 97 98 99 3 0 8 9 120 121 122
22 | c=IN IP4 192.168.100.11
23 | b=TIAS:64000
24 | a=sendrecv
25 | a=rtpmap:96 speex/16000
26 | a=rtpmap:97 speex/8000
27 | a=rtpmap:98 speex/32000
28 | a=rtpmap:99 iLBC/8000
29 | a=fmtp:99 mode=30
30 | a=rtpmap:120 telephone-event/16000
31 | a=fmtp:120 0-16
32 | a=rtpmap:121 telephone-event/8000
33 | a=fmtp:121 0-16
34 | a=rtpmap:122 telephone-event/32000
35 | a=fmtp:122 0-16
36 | a=ssrc:1204560450 cname:4585300731f880ff
37 | a=rtcp:57798 IN IP4 192.168.100.11
38 | a=rtcp-mux
39 | `
40 |
41 | // Fails due to b=TIAS:64000
42 | sd := SessionDescription{}
43 | err := Unmarshal([]byte(body), &sd)
44 | require.NoError(t, err)
45 |
46 | require.Equal(t, sd.Value("m"), "audio 57797 RTP/AVP 96 97 98 99 3 0 8 9 120 121 122")
47 |
48 | md, err := sd.MediaDescription("audio")
49 | require.NoError(t, err)
50 | require.Equal(t, 57797, md.Port)
51 | require.Equal(t, "RTP/AVP", md.Proto)
52 | require.Equal(t, []string{"96", "97", "98", "99", "3", "0", "8", "9", "120", "121", "122"}, md.Formats)
53 |
54 | ci, err := sd.ConnectionInformation()
55 | require.NoError(t, err)
56 | require.Equal(t, "IN", ci.NetworkType)
57 | require.Equal(t, "IP4", ci.AddressType)
58 | require.Equal(t, net.ParseIP("192.168.100.11").String(), ci.IP.String())
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/media/sdp/utils.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package sdp
5 |
6 | import (
7 | "fmt"
8 | "net"
9 | "strings"
10 | "time"
11 | )
12 |
13 | func GetCurrentNTPTimestamp() uint64 {
14 | var ntpEpochOffset int64 = 2208988800 // Offset from Unix epoch (January 1, 1970) to NTP epoch (January 1, 1900)
15 | currentTime := time.Now().Unix() + int64(ntpEpochOffset)
16 |
17 | return uint64(currentTime)
18 | }
19 |
20 | func NTPTimestamp(now time.Time) uint64 {
21 | var ntpEpochOffset int64 = 2208988800 // Offset from Unix epoch (January 1, 1970) to NTP epoch (January 1, 1900)
22 | currentTime := now.Unix() + ntpEpochOffset
23 |
24 | return uint64(currentTime)
25 | }
26 |
27 | const (
28 | // https://datatracker.ietf.org/doc/html/rfc4566#section-6
29 | ModeRecvonly string = "recvonly"
30 | ModeSendrecv string = "sendrecv"
31 | ModeSendonly string = "sendonly"
32 | )
33 |
34 | // GenerateForAudio is minimal AUDIO SDP setup
35 | // mode -> consts like ModeRecvOnly, ModeSendrecv
36 | func GenerateForAudio(originIP net.IP, connectionIP net.IP, rtpPort int, mode string, fmts Formats) []byte {
37 | ntpTime := GetCurrentNTPTimestamp()
38 |
39 | formatsMap := []string{}
40 | for _, f := range fmts {
41 | switch f {
42 | case FORMAT_TYPE_ULAW:
43 | formatsMap = append(formatsMap, "a=rtpmap:0 PCMU/8000")
44 | case FORMAT_TYPE_ALAW:
45 | formatsMap = append(formatsMap, "a=rtpmap:8 PCMA/8000")
46 | case FORMAT_TYPE_OPUS:
47 | formatsMap = append(formatsMap, "a=rtpmap:96 opus/48000/2")
48 | // Providing 0 when FEC cannot be used on the receiving side is RECOMMENDED.
49 | // https://datatracker.ietf.org/doc/html/rfc7587
50 | formatsMap = append(formatsMap, "a=fmtp:96 useinbandfec=0")
51 | case FORMAT_TYPE_TELEPHONE_EVENT:
52 | formatsMap = append(formatsMap, "a=rtpmap:101 telephone-event/8000")
53 | formatsMap = append(formatsMap, "a=fmtp:101 0-16")
54 | }
55 | }
56 |
57 | // Support only ulaw and alaw
58 | // TODO optimize this with string builder
59 | s := []string{
60 | "v=0",
61 | fmt.Sprintf("o=- %d %d IN IP4 %s", ntpTime, ntpTime, originIP),
62 | "s=Sip Go Media",
63 | // "b=AS:84",
64 | fmt.Sprintf("c=IN IP4 %s", connectionIP),
65 | "t=0 0",
66 | fmt.Sprintf("m=audio %d RTP/AVP %s", rtpPort, strings.Join(fmts, " ")),
67 | }
68 |
69 | s = append(s, formatsMap...)
70 | s = append(s,
71 | "a=ptime:20", // Needed for opus
72 | "a=maxptime:20",
73 | "a="+string(mode))
74 | // s := []string{
75 | // "v=0",
76 | // fmt.Sprintf("o=- %d %d IN IP4 %s", ntpTime, ntpTime, originIP),
77 | // "s=Sip Go Media",
78 | // // "b=AS:84",
79 | // fmt.Sprintf("c=IN IP4 %s", connectionIP),
80 | // "t=0 0",
81 | // fmt.Sprintf("m=audio %d RTP/AVP 96 97 98 99 3 0 8 9 120 121 122", rtpPort),
82 | // "a=" + string(mode),
83 | // "a=rtpmap:96 speex/16000",
84 | // "a=rtpmap:97 speex/8000",
85 | // "a=rtpmap:98 speex/32000",
86 | // "a=rtpmap:99 iLBC/8000",
87 | // "a=fmtp:99 mode=30",
88 | // "a=rtpmap:120 telephone-event/16000",
89 | // "a=fmtp:120 0-16",
90 | // "a=rtpmap:121 telephone-event/8000",
91 | // "a=fmtp:121 0-16",
92 | // "a=rtpmap:122 telephone-event/32000",
93 | // "a=rtcp-mux",
94 | // fmt.Sprintf("a=rtcp:%d IN IP4 %s", rtpPort+1, connectionIP),
95 | // }
96 |
97 | res := strings.Join(s, "\r\n") + "\r\n"
98 | return []byte(res)
99 | }
100 |
--------------------------------------------------------------------------------
/playback.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "errors"
8 | "fmt"
9 | "io"
10 | "os"
11 | "path"
12 | "sync"
13 |
14 | "github.com/emiago/diago/audio"
15 | "github.com/emiago/diago/media"
16 | )
17 |
18 | var (
19 | PlaybackBufferSize = 3840 // For now largest we support. 48000 sample rate with 2 channels
20 | )
21 |
22 | var playBufPool = sync.Pool{
23 | New: func() any {
24 | // Increase this size if there will be support for larger pools
25 | return make([]byte, PlaybackBufferSize)
26 | },
27 | }
28 |
29 | type AudioPlayback struct {
30 | writer io.Writer
31 | codec media.Codec
32 | onPlay func()
33 |
34 | // Read only values
35 | // This will influence playout sampling buffer
36 | BitDepth int
37 | NumChannels int
38 |
39 | totalWritten int64
40 | }
41 |
42 | // NewAudioPlayback creates a playback where writer is encoder/streamer to media codec
43 | // Use dialog.PlaybackCreate() instead creating manually playback
44 | func NewAudioPlayback(writer io.Writer, codec media.Codec) AudioPlayback {
45 | return AudioPlayback{
46 | writer: writer,
47 | codec: codec,
48 | BitDepth: 16,
49 | NumChannels: codec.NumChannels,
50 | }
51 | }
52 |
53 | // Play is generic approach to play supported audio contents
54 | // Empty mimeType will stream reader as buffer. Make sure that bitdepth and numchannels is set correctly
55 | func (p *AudioPlayback) Play(reader io.Reader, mimeType string) (int64, error) {
56 | var written int64
57 | var err error
58 |
59 | if p.onPlay != nil {
60 | // Execute hook on play
61 | p.onPlay()
62 | }
63 |
64 | switch mimeType {
65 | case "":
66 | written, err = p.stream(reader, p.writer)
67 | case "audio/wav", "audio/x-wav", "audio/wav-x", "audio/vnd.wave":
68 | written, err = p.streamWav(reader, p.writer)
69 | default:
70 | return 0, fmt.Errorf("unsuported content type %q", mimeType)
71 | }
72 |
73 | p.totalWritten += written
74 | if errors.Is(err, io.EOF) {
75 | return written, nil
76 | }
77 | return written, err
78 | }
79 |
80 | // PlayFile will play file and close file when finished playing
81 | // If you need to play same file multiple times, that use generic Play function
82 | func (p *AudioPlayback) PlayFile(filename string) (int64, error) {
83 | file, err := os.Open(filename)
84 | if err != nil {
85 | return 0, err
86 | }
87 | defer file.Close()
88 |
89 | if ext := path.Ext(file.Name()); ext != ".wav" {
90 | return 0, fmt.Errorf("only playing wav file is now supported, but detected=%s", ext)
91 | }
92 |
93 | return p.Play(file, "audio/wav")
94 | }
95 |
96 | func (p *AudioPlayback) stream(body io.Reader, playWriter io.Writer) (int64, error) {
97 | payloadSize := p.calcPlayoutSize()
98 | buf := playBufPool.Get()
99 | defer playBufPool.Put(buf)
100 | payloadBuf := buf.([]byte)[:payloadSize] // 20 ms
101 |
102 | written, err := copyWithBuf(body, playWriter, payloadBuf)
103 | return written, err
104 | }
105 |
106 | func (p *AudioPlayback) streamWav(body io.Reader, playWriter io.Writer) (int64, error) {
107 | codec := &p.codec
108 | dec := audio.NewWavReader(body)
109 | if err := dec.ReadHeaders(); err != nil {
110 | return 0, err
111 | }
112 | if dec.BitsPerSample != uint16(p.BitDepth) {
113 | return 0, fmt.Errorf("wav file bitdepth=%d does not match expected=%d", dec.BitsPerSample, p.BitDepth)
114 | }
115 | if dec.SampleRate != codec.SampleRate {
116 | return 0, fmt.Errorf("wav file samplerate=%d does not match expected=%d", dec.SampleRate, codec.SampleRate)
117 | }
118 | if dec.NumChannels != uint16(codec.NumChannels) {
119 | return 0, fmt.Errorf("wav file numchannels=%d does not match expected=%d", dec.NumChannels, codec.NumChannels)
120 | }
121 |
122 | // We need to read and packetize to 20 ms
123 | // sampleDurMS := int(codec.SampleDur.Milliseconds())
124 | // payloadSize := int(dec.BitsPerSample) / 8 * int(dec.NumChannels) * int(dec.SampleRate) / 1000 * sampleDurMS
125 | payloadSize := p.codec.SamplesPCM(int(dec.BitsPerSample))
126 |
127 | buf := playBufPool.Get()
128 | defer playBufPool.Put(buf)
129 | payloadBuf := buf.([]byte)[:payloadSize] // 20 ms
130 |
131 | enc, err := audio.NewPCMEncoderWriter(codec.PayloadType, playWriter)
132 | if err != nil {
133 | return 0, fmt.Errorf("failed to create PCM encoder: %w", err)
134 | }
135 |
136 | written, err := media.CopyWithBuf(dec, enc, payloadBuf)
137 | // written, err := wavCopy(dec, enc, payloadBuf)
138 | return written, err
139 | }
140 |
141 | func (p *AudioPlayback) calcPlayoutSize() int {
142 | codec := &p.codec
143 | sampleDurMS := int(codec.SampleDur.Milliseconds())
144 |
145 | bitsPerSample := p.BitDepth
146 | numChannels := p.NumChannels
147 | sampleRate := codec.SampleRate
148 | return int(bitsPerSample) / 8 * int(numChannels) * int(sampleRate) / 1000 * sampleDurMS
149 | }
150 |
151 | // func wavCopy(dec *audio.WavReader, playWriter io.Writer, payloadBuf []byte) (int64, error) {
152 | // var totalWritten int64
153 | // for {
154 | // ch, err := dec.NextChunk()
155 | // if err != nil {
156 | // return totalWritten, err
157 | // }
158 | // fmt.Println("Chunk wav", ch)
159 | // if ch.ID != riff.DataFormatID && ch.ID != [4]byte{} {
160 | // // Until we reach data chunk we will draining
161 | // ch.Drain()
162 | // continue
163 | // }
164 |
165 | // fmt.Println("copy buf", len(payloadBuf))
166 | // n, err := copyWithBuf(ch, playWriter, payloadBuf)
167 | // totalWritten += n
168 | // if err != nil {
169 | // return totalWritten, err
170 | // }
171 | // }
172 | // }
173 |
--------------------------------------------------------------------------------
/playback_control.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "io"
8 | "sync/atomic"
9 | )
10 |
11 | type AudioPlaybackControl struct {
12 | AudioPlayback
13 |
14 | control *audioControl
15 | }
16 |
17 | func (p *AudioPlaybackControl) Mute(mute bool) {
18 | p.control.Mute(mute)
19 | }
20 |
21 | func (p *AudioPlaybackControl) Stop() {
22 | p.control.Stop()
23 | }
24 |
25 | /*
26 | Playback control should provide functionality like Mute Unmute over audio.
27 | */
28 |
29 | type audioControl struct {
30 | Reader io.Reader // MUST be set if usede as reader
31 | Writer io.Writer // Must be set if used as writer
32 |
33 | muted atomic.Bool
34 | stop atomic.Bool
35 | }
36 |
37 | func (c *audioControl) Read(b []byte) (n int, err error) {
38 | if c.stop.Load() {
39 | return 0, io.EOF
40 | }
41 |
42 | n, err = c.Reader.Read(b)
43 | if err != nil {
44 | return n, err
45 | }
46 |
47 | if c.muted.Load() {
48 | for i := range b[:n] {
49 | b[i] = 0
50 | }
51 | }
52 |
53 | return n, err
54 | }
55 |
56 | func (c *audioControl) Write(b []byte) (n int, err error) {
57 | if c.stop.Load() {
58 | return 0, io.EOF
59 | }
60 |
61 | if c.muted.Load() {
62 | for i := range b {
63 | b[i] = 0
64 | }
65 | }
66 |
67 | return c.Writer.Write(b)
68 | }
69 |
70 | func (c *audioControl) Mute(mute bool) {
71 | c.muted.Store(mute)
72 | }
73 |
74 | // Stop will stop reader/writer and return io.Eof
75 | func (c *audioControl) Stop() {
76 | c.stop.Store(true)
77 | }
78 |
--------------------------------------------------------------------------------
/playback_control_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "bytes"
8 | "testing"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestAudioControl(t *testing.T) {
14 | receiver := bytes.NewBuffer([]byte{})
15 | p := audioControl{
16 | Writer: receiver,
17 | }
18 |
19 | payload := []byte{1, 2, 3}
20 |
21 | p.Write(payload)
22 | res := payload
23 | require.Equal(t, payload, receiver.Bytes())
24 |
25 | p.Write(payload)
26 | res = append(res, payload...)
27 | require.Equal(t, res, receiver.Bytes())
28 |
29 | p.Mute(true)
30 | p.Write(payload)
31 | res = append(res, []byte{0, 0, 0}...)
32 | require.Equal(t, res, receiver.Bytes())
33 |
34 | p.Mute(false)
35 | p.Write(payload)
36 | res = append(res, payload...)
37 | require.Equal(t, res, receiver.Bytes())
38 | }
39 |
--------------------------------------------------------------------------------
/playback_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "bytes"
8 | "io"
9 | "net"
10 | "os"
11 | "testing"
12 |
13 | "github.com/emiago/diago/media"
14 | "github.com/stretchr/testify/assert"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestIntegrationStreamWAV(t *testing.T) {
19 | fh, err := os.Open("testdata/files/demo-echodone.wav")
20 | require.NoError(t, err)
21 | sess, err := media.NewMediaSession(net.IPv4(127, 0, 0, 1), 0)
22 | require.NoError(t, err)
23 | defer sess.Close()
24 |
25 | codec := media.CodecFromSession(sess)
26 | rtpWriter := media.NewRTPPacketWriter(sess, codec)
27 | sess.Raddr = net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9999}
28 |
29 | udpDump, err := net.ListenUDP("udp4", &sess.Raddr)
30 | require.NoError(t, err)
31 | defer udpDump.Close()
32 |
33 | go func() {
34 | io.ReadAll(udpDump)
35 | }()
36 |
37 | p := NewAudioPlayback(rtpWriter, codec)
38 |
39 | written, err := p.Play(fh, "audio/wav")
40 | // written, err := streamWavRTP(fh, rtpWriter, codec)
41 | require.NoError(t, err)
42 | require.Greater(t, written, int64(10000))
43 | }
44 |
45 | func TestIntegrationPlaybackStreamWAV(t *testing.T) {
46 | fh, err := os.Open("testdata/files/demo-echodone.wav")
47 | require.NoError(t, err)
48 | sess, err := media.NewMediaSession(net.IPv4(127, 0, 0, 1), 0)
49 | require.NoError(t, err)
50 | defer sess.Close()
51 |
52 | codec := media.CodecFromSession(sess)
53 | sess.Raddr = net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9999}
54 |
55 | p := NewAudioPlayback(bytes.NewBuffer(make([]byte, 0)), codec)
56 |
57 | udpDump, err := net.ListenUDP("udp4", &sess.Raddr)
58 | require.NoError(t, err)
59 | defer udpDump.Close()
60 |
61 | go func() {
62 | io.ReadAll(udpDump)
63 | }()
64 |
65 | written, err := p.Play(fh, "audio/wav")
66 | require.NoError(t, err)
67 | require.Greater(t, written, int64(10000))
68 | }
69 |
70 | func TestIntegrationPlaybackFile(t *testing.T) {
71 | r, w := io.Pipe()
72 | go func() {
73 | defer t.Log("Reader stopped")
74 | io.ReadAll(r)
75 | }()
76 |
77 | dialog := &DialogServerSession{
78 | DialogMedia: DialogMedia{
79 | mediaSession: &media.MediaSession{Codecs: []media.Codec{media.CodecAudioUlaw}},
80 | // audioReader: bytes.NewBuffer(make([]byte, 9999)),
81 | audioWriter: w,
82 | RTPPacketWriter: media.NewRTPPacketWriter(nil, media.CodecAudioUlaw),
83 | },
84 | }
85 |
86 | t.Run("withControl", func(t *testing.T) {
87 | playback, err := dialog.PlaybackControlCreate()
88 | require.NoError(t, err)
89 |
90 | playback.Stop()
91 | written, err := playback.PlayFile("testdata/files/demo-echodone.wav")
92 | require.NoError(t, err)
93 | assert.EqualValues(t, 0, written)
94 | })
95 |
96 | t.Run("default", func(t *testing.T) {
97 | playback, err := dialog.PlaybackCreate()
98 | require.NoError(t, err)
99 |
100 | written, err := playback.PlayFile("testdata/files/demo-echodone.wav")
101 | require.NoError(t, err)
102 | require.Greater(t, written, int64(10000))
103 | t.Log("Written on RTP stream", written)
104 | })
105 |
106 | }
107 |
--------------------------------------------------------------------------------
/playback_url.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | // Copyright (C) 2024 Emir Aganovic
5 |
6 | package diago
7 |
8 | import (
9 | "bytes"
10 | "context"
11 | "errors"
12 | "fmt"
13 | "io"
14 | "mime"
15 | "net/http"
16 | "strconv"
17 | "strings"
18 | "time"
19 | )
20 |
21 | func (p *AudioPlayback) PlayURL(urlStr string) (int64, error) {
22 | var written int64
23 | err := p.playURL(urlStr, &written)
24 | if errors.Is(err, io.EOF) {
25 | return written, nil
26 | }
27 | return written, err
28 | }
29 |
30 | func (p *AudioPlayback) playURL(urlStr string, written *int64) error {
31 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
32 | defer cancel()
33 | req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
34 | if err != nil {
35 | return err
36 | }
37 | req.Header.Add("Range", "bytes=0-1023") // Try with range request
38 |
39 | res, err := client.Do(req)
40 | if err != nil {
41 | return err
42 | }
43 |
44 | if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusPartialContent {
45 | return fmt.Errorf("non 200 received. code=%d", res.StatusCode)
46 | }
47 |
48 | contType := res.Header.Get("Content-Type")
49 | mimeType, _, err := mime.ParseMediaType(contType)
50 | if err != nil {
51 | return err
52 | }
53 |
54 | switch mimeType {
55 | case "audio/wav", "audio/x-wav", "audio/wav-x", "audio/vnd.wave":
56 | default:
57 | return fmt.Errorf("unsuported content type %q", contType)
58 | }
59 |
60 | // Check can be streamed
61 | if res.StatusCode == http.StatusPartialContent {
62 | // acceptRanges := res.Header.Get("Accept-Ranges")
63 | // if acceptRanges != "bytes" {
64 | // return fmt.Errorf("header Accept-Ranges != bytes. Value=%q", acceptRanges)
65 | // }
66 |
67 | contentRange := res.Header.Get("Content-Range")
68 | ind := strings.LastIndex(contentRange, "/")
69 | if ind < 0 {
70 | return fmt.Errorf("full audio size in Content-Range not present")
71 | }
72 | maxSize, err := strconv.ParseInt(contentRange[ind+1:], 10, 64)
73 | if err != nil {
74 | return err
75 | }
76 |
77 | if maxSize <= 0 {
78 | return fmt.Errorf("parsing audio size failed")
79 | }
80 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
81 |
82 | // WAV header size is 44 bytes so we have more than enough
83 |
84 | reader, writer := io.Pipe()
85 | defer reader.Close()
86 | defer writer.Close()
87 |
88 | // BETTER DESIGN needed
89 | httpPartial := func(res *http.Response, writer io.Writer) error {
90 | chunk, err := io.ReadAll(res.Body)
91 | if err != nil {
92 | return fmt.Errorf("reading chunk stopped: %w", err)
93 | }
94 | res.Body.Close()
95 |
96 | if _, err := writer.Write(chunk); err != nil {
97 | return err
98 | }
99 |
100 | var start int64 = 1024
101 | var offset int64 = 64 * 1024 // 512K
102 | for ; start < maxSize; start += offset {
103 | end := min(start+offset-1, maxSize)
104 | // Range is inclusive
105 | rangeHDR := fmt.Sprintf("bytes=%d-%d", start, end)
106 |
107 | req.Header.Set("Range", rangeHDR) // Try with range request
108 | res, err = client.Do(req)
109 | if err != nil {
110 | return fmt.Errorf("failed to request range: %w", err)
111 | }
112 |
113 | if res.StatusCode == http.StatusRequestedRangeNotSatisfiable && res.ContentLength == 0 {
114 | break
115 | }
116 |
117 | if res.StatusCode != http.StatusPartialContent {
118 | return fmt.Errorf("expected partial content response: code=%d", res.StatusCode)
119 | }
120 |
121 | chunk, err := io.ReadAll(res.Body)
122 | if err != nil {
123 | return fmt.Errorf("reading chunk stopped: %w", err)
124 | }
125 | res.Body.Close()
126 |
127 | if _, err := writer.Write(chunk); err != nil {
128 | return err
129 | }
130 | }
131 | return nil
132 | }
133 |
134 | httpErr := make(chan error)
135 | go func() {
136 | err := httpPartial(res, writer)
137 | writer.Close()
138 | httpErr <- err
139 | }()
140 |
141 | n, err := p.streamWav(reader, p.writer)
142 | *written = n
143 | p.totalWritten += n
144 |
145 | // There is no reason having http goroutine still running
146 | // First make sure http goroutine exited and join errors
147 | err = errors.Join(<-httpErr, err)
148 | return err
149 | }
150 |
151 | // // We need some stream wave implementation
152 | samples, err := io.ReadAll(res.Body)
153 | if err != nil {
154 | return err
155 | }
156 |
157 | defer res.Body.Close()
158 |
159 | wavBuf := bytes.NewReader(samples)
160 | n, err := p.streamWav(wavBuf, p.writer)
161 | *written = n
162 | p.totalWritten += n
163 | return err
164 | }
165 |
--------------------------------------------------------------------------------
/playback_url_test.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "bytes"
8 | "context"
9 | "errors"
10 | "io"
11 | "net"
12 | "net/http"
13 | "os"
14 | "sync"
15 | "testing"
16 | "time"
17 |
18 | "github.com/emiago/diago/media"
19 | "github.com/emiago/sipgo"
20 | "github.com/emiago/sipgo/sip"
21 | "github.com/stretchr/testify/require"
22 | )
23 |
24 | func TestIntegrationPlaybackURL(t *testing.T) {
25 | // Create transaction users, as many as needed.
26 | ua, _ := sipgo.NewUA(
27 | sipgo.WithUserAgent("inbound"),
28 | )
29 | defer ua.Close()
30 | tu := NewDiago(ua, WithTransport(
31 | Transport{
32 | Transport: "udp",
33 | BindHost: "127.0.0.1",
34 | BindPort: 15060,
35 | },
36 | ))
37 |
38 | ctx, cancel := context.WithCancel(context.Background())
39 | defer cancel()
40 | // media.RTPDebug = true
41 |
42 | urlStr := testStartAudioStreamServer(t)
43 |
44 | var errServer error
45 | wg := sync.WaitGroup{}
46 | wg.Add(1)
47 | err := tu.ServeBackground(ctx, func(in *DialogServerSession) {
48 | defer wg.Done()
49 | in.Trying()
50 | in.Ringing()
51 | in.Answer()
52 | t.Log("Playing url ", urlStr)
53 | pb, _ := in.PlaybackCreate()
54 | if _, err := pb.PlayURL(urlStr); err != nil {
55 | errServer = errors.Join(errServer, err)
56 | }
57 |
58 | t.Log("Done playing", urlStr)
59 | in.Hangup(in.Context())
60 | })
61 | require.NoError(t, err)
62 |
63 | {
64 | ua, _ := sipgo.NewUA()
65 | defer ua.Close()
66 | phone := NewDiago(ua, WithTransport(Transport{
67 | Transport: "udp",
68 | BindHost: "127.0.0.100",
69 | BindPort: 15060,
70 | }))
71 | // Just to have handled BYE
72 | err := phone.ServeBackground(context.TODO(), func(d *DialogServerSession) {})
73 | require.NoError(t, err)
74 |
75 | dialog, err := phone.Invite(context.TODO(), sip.Uri{Host: "127.0.0.1", Port: 15060}, InviteOptions{})
76 | require.NoError(t, err)
77 | defer dialog.Close()
78 |
79 | rtpReader := dialog.RTPPacketReader
80 |
81 | go func() {
82 | defer dialog.Close()
83 | time.Sleep(10 * time.Second)
84 | dialog.Hangup(ctx)
85 | }()
86 | b := bytes.NewBuffer([]byte{})
87 | written, err := media.CopyWithBuf(rtpReader, b, make([]byte, media.RTPBufSize))
88 | // bnf, err := io.ReadAll(rtpReader)
89 | require.ErrorIs(t, err, io.EOF)
90 | require.Greater(t, written, int64(10000))
91 | require.Greater(t, b.Len(), 10000)
92 | }
93 |
94 | t.Log("Waiting server goroutine to exit")
95 | wg.Wait()
96 | require.NoError(t, errServer)
97 | }
98 |
99 | func testStartAudioStreamServer(t *testing.T) string {
100 | mux := http.NewServeMux()
101 | mux.HandleFunc("/ping", func(writer http.ResponseWriter, request *http.Request) {
102 | writer.WriteHeader(200)
103 | })
104 | mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
105 | fh, err := os.Open("testdata/files/demo-echodone.wav")
106 | if err != nil {
107 | return
108 | }
109 |
110 | // Get file info
111 | fileInfo, err := fh.Stat()
112 | if err != nil {
113 | http.Error(w, "Internal server error", http.StatusInternalServerError)
114 | return
115 | }
116 |
117 | w.Header().Add("content-type", "audio/wav")
118 | // w.Header().Add("cache-control", "max-age=10")
119 | // w.WriteHeader(http.StatusOK)
120 | t.Logf("Serving file %q", fh.Name())
121 | http.ServeContent(w, req, "audio/wav", fileInfo.ModTime(), fh)
122 |
123 | // _, err = io.Copy(w, fh)
124 | // if err != nil {
125 | // http.Error(w, err.Error(), http.StatusInternalServerError)
126 | // }
127 | })
128 |
129 | srv := http.Server{
130 | Addr: "127.0.0.1:18080",
131 | Handler: mux,
132 | }
133 |
134 | l, err := net.Listen("tcp", srv.Addr)
135 | require.NoError(t, err)
136 | go srv.Serve(l)
137 |
138 | t.Cleanup(func() {
139 | srv.Shutdown(context.TODO())
140 | })
141 | return "http://" + srv.Addr + "/"
142 | }
143 |
--------------------------------------------------------------------------------
/recording.go:
--------------------------------------------------------------------------------
1 | package diago
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/emiago/diago/audio"
7 | )
8 |
9 | type AudioStereoRecordingWav struct {
10 | wawWriter *audio.WavWriter
11 | mon audio.MonitorPCMStereo
12 | }
13 |
14 | func (r *AudioStereoRecordingWav) AudioReader() *audio.MonitorPCMStereo {
15 | return &r.mon
16 | }
17 |
18 | func (r *AudioStereoRecordingWav) AudioWriter() *audio.MonitorPCMStereo {
19 | return &r.mon
20 | }
21 |
22 | func (r *AudioStereoRecordingWav) Close() error {
23 | return errors.Join(
24 | r.mon.Close(),
25 | r.wawWriter.Close(),
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/recording_test.go:
--------------------------------------------------------------------------------
1 | package diago
2 |
3 | import (
4 | "bytes"
5 | "os"
6 | "testing"
7 |
8 | "github.com/emiago/diago/audio"
9 | "github.com/emiago/diago/media"
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestIntegrationRecordingStereoWav(t *testing.T) {
15 | fakePCMFrame := bytes.Repeat([]byte("0123456789"), 32)
16 | alawFrame := make([]byte, 160)
17 | _, err := audio.EncodeAlawTo(alawFrame, fakePCMFrame)
18 | require.NoError(t, err)
19 | encodedAudio := bytes.Repeat(alawFrame, 4)
20 |
21 | dialog := &DialogServerSession{
22 | DialogMedia: DialogMedia{
23 | mediaSession: &media.MediaSession{Codecs: []media.Codec{media.CodecAudioUlaw}},
24 | // audioReader: bytes.NewBuffer(make([]byte, 9999)),
25 | audioReader: bytes.NewBuffer(encodedAudio),
26 | audioWriter: bytes.NewBuffer([]byte{}),
27 | RTPPacketWriter: media.NewRTPPacketWriter(nil, media.CodecAudioUlaw),
28 | },
29 | }
30 |
31 | recordFile, err := os.OpenFile("/tmp/diago_test_record_stereo.wav", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0755)
32 | require.NoError(t, err)
33 | defer recordFile.Close()
34 |
35 | rec, err := dialog.AudioStereoRecordingCreate(recordFile)
36 | require.NoError(t, err)
37 |
38 | media.ReadAll(rec.AudioReader(), 160)
39 | media.WriteAll(rec.AudioWriter(), encodedAudio, 160)
40 | err = rec.Close()
41 |
42 | recordFile.Seek(0, 0)
43 | wav := audio.NewWavReader(recordFile)
44 | wav.ReadHeaders()
45 | // 2 channels, 4 frames Read, 4 frames Write
46 | assert.Equal(t, 2*4*320, wav.DataSize)
47 | }
48 |
--------------------------------------------------------------------------------
/register_transaction.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "context"
8 | "fmt"
9 | "log/slog"
10 | "strconv"
11 | "strings"
12 | "time"
13 |
14 | "github.com/emiago/sipgo"
15 | "github.com/emiago/sipgo/sip"
16 | )
17 |
18 | type RegisterResponseError struct {
19 | RegisterReq *sip.Request
20 | RegisterRes *sip.Response
21 |
22 | Msg string
23 | }
24 |
25 | func (e *RegisterResponseError) StatusCode() int {
26 | return e.RegisterRes.StatusCode
27 | }
28 |
29 | func (e RegisterResponseError) Error() string {
30 | return e.Msg
31 | }
32 |
33 | type RegisterTransaction struct {
34 | opts RegisterOptions
35 | Origin *sip.Request
36 |
37 | client *sipgo.Client
38 | log *slog.Logger
39 |
40 | expiry time.Duration
41 | }
42 |
43 | func newRegisterTransaction(client *sipgo.Client, recipient sip.Uri, contact sip.ContactHeader, opts RegisterOptions) *RegisterTransaction {
44 | expiry, allowHDRS := opts.Expiry, opts.AllowHeaders
45 | // log := p.getLoggerCtx(ctx, "Register")
46 | req := sip.NewRequest(sip.REGISTER, recipient)
47 | req.AppendHeader(&contact)
48 |
49 | if opts.ProxyHost != "" {
50 | req.SetDestination(opts.ProxyHost)
51 | }
52 | if expiry > 0 {
53 | expires := sip.ExpiresHeader(expiry.Seconds())
54 | req.AppendHeader(&expires)
55 | }
56 | if allowHDRS != nil {
57 | req.AppendHeader(sip.NewHeader("Allow", strings.Join(allowHDRS, ", ")))
58 | }
59 |
60 | // if opts.Username == "" {
61 | // opts.Username = opts.UserAgent
62 | // }
63 |
64 | if opts.Username == "" {
65 | opts.Username = client.Name()
66 | }
67 |
68 | t := &RegisterTransaction{
69 | Origin: req, // origin maybe updated after first register
70 | opts: opts,
71 | client: client,
72 | log: slog.Default().With("caller", "Register"),
73 | }
74 |
75 | return t
76 | }
77 |
78 | func (t *RegisterTransaction) Register(ctx context.Context) error {
79 | username, password, expiry := t.opts.Username, t.opts.Password, t.opts.Expiry
80 | client := t.client
81 | log := t.log
82 | req := t.Origin
83 | contact := *req.Contact().Clone()
84 |
85 | // Send request and parse response
86 | // req.SetDestination(*dst)
87 | log.Info("REGISTER", "uri", req.Recipient.String(), "expiry", int(expiry))
88 | tx, err := client.TransactionRequest(ctx, req, sipgo.ClientRequestRegisterBuild)
89 | if err != nil {
90 | return fmt.Errorf("fail to create transaction req=%q: %w", req.StartLine(), err)
91 | }
92 | defer tx.Terminate()
93 |
94 | res, err := getResponse(ctx, tx)
95 | if err != nil {
96 | return fmt.Errorf("fail to get response req=%q : %w", req.StartLine(), err)
97 | }
98 |
99 | via := res.Via()
100 | if via == nil {
101 | return fmt.Errorf("no Via header in response")
102 | }
103 |
104 | // https://datatracker.ietf.org/doc/html/rfc3581#section-9
105 | if rport, _ := via.Params.Get("rport"); rport != "" {
106 | if p, err := strconv.Atoi(rport); err == nil {
107 | contact.Address.Port = p
108 | }
109 |
110 | if received, _ := via.Params.Get("received"); received != "" {
111 | // TODO: consider parsing IP
112 | contact.Address.Host = received
113 | }
114 |
115 | // Update contact address of NAT
116 | req.ReplaceHeader(&contact)
117 | }
118 |
119 | log.Info("Received status", "status", int(res.StatusCode))
120 | if res.StatusCode == sip.StatusUnauthorized || res.StatusCode == sip.StatusProxyAuthRequired {
121 | tx.Terminate() //Terminate previous
122 |
123 | log.Info("Unathorized. Doing digest auth")
124 | res, err = client.DoDigestAuth(ctx, req, res, sipgo.DigestAuth{
125 | Username: username,
126 | Password: password,
127 | })
128 | if err != nil {
129 | return fmt.Errorf("fail to get response req=%q : %w", req.StartLine(), err)
130 | }
131 | log.Info("Received status", "status", int(res.StatusCode))
132 | }
133 |
134 | if res.StatusCode != 200 {
135 | return &RegisterResponseError{
136 | RegisterReq: req,
137 | RegisterRes: res,
138 | Msg: res.StartLine(),
139 | }
140 | }
141 |
142 | // Now update server expiry
143 | t.expiry = t.opts.Expiry
144 | if h := res.GetHeader("Expires"); h != nil {
145 | val, err := strconv.Atoi(h.Value())
146 | if err != nil {
147 | return fmt.Errorf("Failed to parse server Expires value: %w", err)
148 | }
149 | t.expiry = time.Duration(val) * time.Second
150 | }
151 |
152 | return nil
153 | }
154 |
155 | func (t *RegisterTransaction) QualifyLoop(ctx context.Context) error {
156 | // TODO: based on server response Expires header this must be adjusted
157 | // Allows caller to adjust
158 |
159 | calcRetry := func(expiry time.Duration) time.Duration {
160 | // Allow caller to use own interval
161 | if t.opts.RetryInterval != 0 {
162 | return t.opts.RetryInterval
163 | }
164 |
165 | calc := expiry.Seconds() * 0.75
166 | retry := time.Duration(calc) * time.Second
167 |
168 | // Set to 30 in case retry is not set
169 | if retry == 0 {
170 | retry = 30 * time.Second
171 | }
172 |
173 | return retry
174 | }
175 |
176 | expiry := t.expiry
177 | retry := calcRetry(expiry)
178 |
179 | ticker := time.NewTicker(retry)
180 | for {
181 | select {
182 | case <-ctx.Done():
183 | return ctx.Err()
184 | case <-ticker.C: // TODO make configurable
185 | }
186 | err := t.Qualify(ctx)
187 | if err != nil {
188 | return err
189 | }
190 |
191 | if t.expiry != expiry {
192 | // expiry got updated
193 | expiry = t.expiry
194 | retry = calcRetry(expiry)
195 |
196 | t.log.Info("Register expiry changed", "expiry_old", expiry, "expiry_new", t.expiry, "retry", retry)
197 | ticker.Reset(retry)
198 | }
199 |
200 | }
201 | }
202 |
203 | func (t *RegisterTransaction) Unregister(ctx context.Context) error {
204 | req := t.Origin
205 |
206 | req.RemoveHeader("Expires")
207 | req.RemoveHeader("Contact")
208 | req.AppendHeader(sip.NewHeader("Contact", "*"))
209 | expires := sip.ExpiresHeader(0)
210 | req.AppendHeader(&expires)
211 | return t.doRequest(ctx, req)
212 | }
213 |
214 | func (t *RegisterTransaction) Qualify(ctx context.Context) error {
215 | return t.doRequest(ctx, t.Origin)
216 | }
217 |
218 | func (t *RegisterTransaction) doRequest(ctx context.Context, req *sip.Request) error {
219 | // log := p.getLoggerCtx(ctx, "Register")
220 | log := t.log
221 | client := t.client
222 | username, password := t.opts.Username, t.opts.Password
223 | // Send request and parse response
224 | // req.SetDestination(*dst)
225 | req.RemoveHeader("Via")
226 | res, err := client.Do(ctx, req, sipgo.ClientRequestRegisterBuild)
227 | if err != nil {
228 | return fmt.Errorf("fail to get response req=%q : %w", req.StartLine(), err)
229 | }
230 |
231 | log.Info("Received status", "uri", req.Recipient.String())
232 | if res.StatusCode == sip.StatusUnauthorized || res.StatusCode == sip.StatusProxyAuthRequired {
233 | log.Info("Unathorized. Doing digest auth")
234 | res, err = client.DoDigestAuth(ctx, req, res, sipgo.DigestAuth{
235 | Username: username,
236 | Password: password,
237 | })
238 | if err != nil {
239 | return fmt.Errorf("fail to get response req=%q : %w", req.StartLine(), err)
240 | }
241 | log.Info("Received status", "uri", req.Recipient.String())
242 | }
243 |
244 | if res.StatusCode != 200 {
245 | return &RegisterResponseError{
246 | RegisterReq: req,
247 | RegisterRes: res,
248 | Msg: res.StartLine(),
249 | }
250 | }
251 |
252 | // Check is expirese changed
253 | if h := res.GetHeader("Expires"); h != nil {
254 | val, err := strconv.Atoi(h.Value())
255 | if err != nil {
256 | return fmt.Errorf("Failed to parse server Expires value: %w", err)
257 | }
258 | t.expiry = time.Duration(val) * time.Second
259 | }
260 |
261 | return nil
262 | }
263 |
264 | func getResponse(ctx context.Context, tx sip.ClientTransaction) (*sip.Response, error) {
265 | select {
266 | case <-tx.Done():
267 | return nil, fmt.Errorf("transaction died")
268 | case res := <-tx.Responses():
269 | return res, nil
270 | case <-ctx.Done():
271 | return nil, ctx.Err()
272 | }
273 | }
274 |
--------------------------------------------------------------------------------
/rsync_public.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | PWD=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
4 | DEST=$(realpath $PWD/../diago-public)
5 |
6 | echo $PWD
7 |
8 | rsync -avR --progress --inplace --delete --dry-run \
9 | --exclude='.git' \
10 | --exclude=$PWD/.git \
11 | --exclude=$PWD/playback_url.go --exclude=$PWD/playback_url_test.go \
12 | --exclude=$PWD/dialog_session_server_webrtc.go \
13 | --exclude=$PWD/recording.go --exclude=$PWD/recording_test.go \
14 | --exclude=$PWD/examples/webrtc \
15 | --exclude=$PWD/diagomod \
16 | --exclude='*.md' \
17 | --exclude='*.sh' \
18 | --exclude=$PWD/cmd \
19 | --exclude='go.work*' \
20 | $PWD \
21 | $DEST
22 |
--------------------------------------------------------------------------------
/testdata/embed.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package testdata
5 |
6 | import (
7 | "embed"
8 | "io/fs"
9 | "path"
10 | )
11 |
12 | //go:embed files/*.wav
13 | var filesDir embed.FS
14 |
15 | func OpenFile(filename string) (fs.File, error) {
16 | return filesDir.Open(path.Join("files", filename))
17 | }
18 |
19 | func ReadFile(filename string) ([]byte, error) {
20 | return filesDir.ReadFile(path.Join("files", filename))
21 | }
22 |
--------------------------------------------------------------------------------
/testdata/files/demo-echodone.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emiago/diago/8fe90424be3335f25a544659fb2bc2531cc23efc/testdata/files/demo-echodone.wav
--------------------------------------------------------------------------------
/testdata/files/demo-echotest.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emiago/diago/8fe90424be3335f25a544659fb2bc2531cc23efc/testdata/files/demo-echotest.wav
--------------------------------------------------------------------------------
/utils.go:
--------------------------------------------------------------------------------
1 | // SPDX-License-Identifier: MPL-2.0
2 | // SPDX-FileCopyrightText: Copyright (c) 2024, Emir Aganovic
3 |
4 | package diago
5 |
6 | import (
7 | "io"
8 | "log/slog"
9 | "sync"
10 |
11 | "github.com/emiago/diago/media"
12 | "github.com/pion/rtp"
13 | )
14 |
15 | var rtpBufPool = sync.Pool{
16 | New: func() any {
17 | return make([]byte, media.RTPBufSize)
18 | },
19 | }
20 |
21 | func copyWithBuf(reader io.Reader, writer io.Writer, payloadBuf []byte) (int64, error) {
22 | return media.CopyWithBuf(reader, writer, payloadBuf)
23 | }
24 |
25 | func closeAndLog(closer io.Closer, msg string) {
26 | if err := closer.Close(); err != nil {
27 | slog.Error(msg, "error", err)
28 | }
29 | }
30 |
31 | type rtpWriterBuffer struct {
32 | buf []*rtp.Packet
33 | }
34 |
35 | func newRTPWriterBuffer() *rtpWriterBuffer {
36 | return &rtpWriterBuffer{
37 | buf: make([]*rtp.Packet, 0, 1000),
38 | }
39 | }
40 |
41 | func (w *rtpWriterBuffer) WriteRTP(p *rtp.Packet) error {
42 | w.buf = append(w.buf, p)
43 | return nil
44 | }
45 |
--------------------------------------------------------------------------------