├── .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 | DIAGO 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/emiago/diago)](https://goreportcard.com/report/github.com/emiago/diago) 4 | ![Coverage](https://img.shields.io/badge/coverage-61.1%25-blue) 5 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/emiago/diago) 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 | --------------------------------------------------------------------------------