1+ //-----------------------------------------------------------------------------
2+ // Filename: Program.cs
3+ //
4+ // Description: An example showing how to use SIPSorcery with OpenAI's WebRTC endpoint.
5+ // This demo shows the concept of how you could bridge SIP calls to OpenAI, though
6+ // a complete implementation would require additional SIP handling logic.
7+ //
8+ // Usage:
9+ // set OPENAI_API_KEY=your_openai_key
10+ // dotnet run
11+ //
12+ // Author(s):
13+ // Aaron Clauson (aaron@sipsorcery.com)
14+ //
15+ // History:
16+ // 09 Aug 2025 Aaron Clauson Created, Dublin, Ireland.
17+ //
18+ // License:
19+ // BSD 3-Clause "New" or "Revised" License and the additional
20+ // BDS BY-NC-SA restriction, see included LICENSE.md file.
21+ //-----------------------------------------------------------------------------
22+
23+ using Microsoft . Extensions . Logging ;
24+ using Serilog ;
25+ using Serilog . Extensions . Logging ;
26+ using SIPSorcery . Net ;
27+ using SIPSorcery . OpenAIWebRTC ;
28+ using SIPSorcery . OpenAIWebRTC . Models ;
29+ using SIPSorcery . SIP ;
30+ using SIPSorcery . SIP . App ;
31+ using SIPSorceryMedia . Abstractions ;
32+ using System ;
33+ using System . Collections . Concurrent ;
34+ using System . Net ;
35+ using System . Threading . Tasks ;
36+
37+ namespace demo ;
38+
39+ record SIPToOpenAiCall ( SIPUserAgent ua , RTPSession voip , WebRTCEndPoint ? webrtc ) ;
40+
41+ class Program
42+ {
43+ private static int SIP_LISTEN_PORT = 5060 ;
44+
45+ /// <summary>
46+ /// Keeps track of the current active calls. It includes both received and placed calls.
47+ /// </summary>
48+ private static ConcurrentDictionary < string , SIPToOpenAiCall > _calls = new ConcurrentDictionary < string , SIPToOpenAiCall > ( ) ;
49+
50+ static async Task Main ( )
51+ {
52+ Log . Logger = new LoggerConfiguration ( )
53+ . MinimumLevel . Debug ( )
54+ //.MinimumLevel.Verbose()
55+ . Enrich . FromLogContext ( )
56+ . WriteTo . Console ( )
57+ . CreateLogger ( ) ;
58+
59+ var loggerFactory = new SerilogLoggerFactory ( Log . Logger ) ;
60+ SIPSorcery . LogFactory . Set ( loggerFactory ) ;
61+
62+ Log . Logger . Information ( "SIP-to-WebRTC OpenAI Demo Program" ) ;
63+
64+ var openAiKey = Environment . GetEnvironmentVariable ( "OPENAI_API_KEY" ) ;
65+
66+ if ( string . IsNullOrWhiteSpace ( openAiKey ) )
67+ {
68+ Log . Logger . Error ( "Please provide your OpenAI key as an environment variable. For example: set OPENAI_API_KEY=<your openai api key>" ) ;
69+ return ;
70+ }
71+
72+ var logger = loggerFactory . CreateLogger < Program > ( ) ;
73+
74+ SIPSorcery . LogFactory . Set ( loggerFactory ) ;
75+ var sipTransport = new SIPTransport ( ) ;
76+ sipTransport . EnableTraceLogs ( ) ;
77+ sipTransport . AddSIPChannel ( new SIPUDPChannel ( new IPEndPoint ( IPAddress . Any , SIP_LISTEN_PORT ) ) ) ;
78+ sipTransport . SIPTransportRequestReceived += ( lep , rep , req ) => OnRequest ( lep , rep , req , sipTransport , openAiKey ) ;
79+
80+ Console . WriteLine ( "Wait for ctrl-c to indicate user exit." ) ;
81+
82+ var exitTcs = new TaskCompletionSource < object ? > ( ) ;
83+ Console . CancelKeyPress += ( s , e ) =>
84+ {
85+ e . Cancel = true ;
86+ exitTcs . TrySetResult ( null ) ;
87+ } ;
88+
89+ await exitTcs . Task ;
90+ }
91+
92+ /// <summary>
93+ /// Because this is a server user agent the SIP transport must start listening for client user agents.
94+ /// </summary>
95+ private static async Task OnRequest ( SIPEndPoint localSIPEndPoint , SIPEndPoint remoteEndPoint , SIPRequest sipRequest , SIPTransport sipTransport , string openAiKey )
96+ {
97+ try
98+ {
99+ if ( sipRequest . Header . From != null &&
100+ sipRequest . Header . From . FromTag != null &&
101+ sipRequest . Header . To != null &&
102+ sipRequest . Header . To . ToTag != null )
103+ {
104+ // This is an in-dialog request that will be handled directly by a user agent instance.
105+ }
106+ else if ( sipRequest . Method == SIPMethodsEnum . INVITE )
107+ {
108+ Log . Information ( $ "Incoming call request: { localSIPEndPoint } <-{ remoteEndPoint } { sipRequest . URI } .") ;
109+
110+ SIPUserAgent ua = new SIPUserAgent ( sipTransport , null ) ;
111+ ua . OnCallHungup += OnHangup ;
112+ ua . ServerCallCancelled += ( uas , cancelReq ) => Log . Debug ( "Incoming call cancelled by remote party." ) ;
113+ ua . OnDtmfTone += ( key , duration ) => OnDtmfTone ( ua , key , duration ) ;
114+ ua . OnRtpEvent += ( evt , hdr ) => Log . Debug ( $ "rtp event { evt . EventID } , duration { evt . Duration } , end of event { evt . EndOfEvent } , timestamp { hdr . Timestamp } , marker { hdr . MarkerBit } .") ;
115+ //ua.OnTransactionTraceMessage += (tx, msg) => Log.LogDebug($"uas tx {tx.TransactionId}: {msg}");
116+ ua . ServerCallRingTimeout += ( uas ) =>
117+ {
118+ Log . Warning ( $ "Incoming call timed out in { uas . ClientTransaction . TransactionState } state waiting for client ACK, terminating.") ;
119+ ua . Hangup ( ) ;
120+ } ;
121+
122+ //bool wasMangled = false;
123+ //sipRequest.Body = SIPPacketMangler.MangleSDP(sipRequest.Body, remoteEndPoint.Address.ToString(), out wasMangled);
124+ //Log.LogDebug("INVITE was mangled=" + wasMangled + " remote=" + remoteEndPoint.Address.ToString() + ".");
125+ //sipRequest.Header.ContentLength = sipRequest.Body.Length;
126+
127+ var uas = ua . AcceptCall ( sipRequest ) ;
128+ var rtpSession = CreateRtpSession ( ua ) ;
129+
130+ // Insert a brief delay to allow testing of the "Ringing" progress response.
131+ // Without the delay the call gets answered before it can be sent.
132+ //await Task.Delay(500);
133+
134+ //if (!string.IsNullOrWhiteSpace(_publicIPAddress))
135+ //{
136+ // await ua.Answer(uas, rtpSession, IPAddress.Parse(_publicIPAddress));
137+ //}
138+ //else
139+ //{
140+ await ua . Answer ( uas , rtpSession ) ;
141+ //}
142+
143+ if ( ua . IsCallActive )
144+ {
145+ await rtpSession . Start ( ) ;
146+ _calls . TryAdd ( ua . Dialogue . CallId , new SIPToOpenAiCall ( ua , rtpSession , null ) ) ;
147+
148+ Log . Information ( $ "Call answered, call ID { ua . Dialogue . CallId } .") ;
149+
150+ // Create a WebRTC session to OpenAI.
151+ await CreateOpenAIWebRTCSession ( new SerilogLoggerFactory ( Log . Logger ) , openAiKey , ua . Dialogue . CallId , rtpSession ) ;
152+ }
153+ }
154+ else if ( sipRequest . Method == SIPMethodsEnum . BYE )
155+ {
156+ SIPResponse byeResponse = SIPResponse . GetResponse ( sipRequest , SIPResponseStatusCodesEnum . CallLegTransactionDoesNotExist , null ) ;
157+ await sipTransport . SendResponseAsync ( byeResponse ) ;
158+ }
159+ else if ( sipRequest . Method == SIPMethodsEnum . SUBSCRIBE )
160+ {
161+ SIPResponse notAllowededResponse = SIPResponse . GetResponse ( sipRequest , SIPResponseStatusCodesEnum . MethodNotAllowed , null ) ;
162+ await sipTransport . SendResponseAsync ( notAllowededResponse ) ;
163+ }
164+ else if ( sipRequest . Method == SIPMethodsEnum . OPTIONS || sipRequest . Method == SIPMethodsEnum . REGISTER )
165+ {
166+ SIPResponse optionsResponse = SIPResponse . GetResponse ( sipRequest , SIPResponseStatusCodesEnum . Ok , null ) ;
167+ await sipTransport . SendResponseAsync ( optionsResponse ) ;
168+ }
169+ }
170+ catch ( Exception reqExcp )
171+ {
172+ Log . Warning ( $ "Exception handling { sipRequest . Method } . { reqExcp . Message } ") ;
173+ }
174+ }
175+
176+ /// <summary>
177+ /// Example of how to create a basic RTP session object and hook up the event handlers.
178+ /// </summary>
179+ /// <param name="ua">The user agent the RTP session is being created for.</param>
180+ /// <returns>A new RTP session object.</returns>
181+ private static RTPSession CreateRtpSession ( SIPUserAgent ua )
182+ {
183+ var rtpSession = new RTPSession ( false , false , false ) ;
184+ rtpSession . addTrack ( new MediaStreamTrack ( AudioCommonlyUsedFormats . OpusWebRTC ) ) ;
185+ rtpSession . AcceptRtpFromAny = true ;
186+
187+ // Wire up the event handler for RTP packets received from the remote party.
188+ //rtpSession.OnRtpPacketReceived += (ep, type, rtp) => OnRtpPacketReceived(ua, ep, type, rtp);
189+ rtpSession . OnTimeout += ( mediaType ) =>
190+ {
191+ if ( ua ? . Dialogue != null )
192+ {
193+ Log . Warning ( $ "RTP timeout on call with { ua . Dialogue . RemoteTarget } , hanging up.") ;
194+ }
195+ else
196+ {
197+ Log . Warning ( $ "RTP timeout on incomplete call, closing RTP session.") ;
198+ }
199+
200+ ua ? . Hangup ( ) ;
201+ } ;
202+
203+ return rtpSession ;
204+ }
205+
206+ private static async Task CreateOpenAIWebRTCSession ( ILoggerFactory loggerFactory , string openAiKey , string sipCallID , RTPSession rtpSession )
207+ {
208+ var logger = loggerFactory . CreateLogger < WebRTCEndPoint > ( ) ;
209+ var webrtcEndPoint = new WebRTCEndPoint ( openAiKey , logger ) ;
210+
211+ if ( _calls . TryGetValue ( sipCallID , out var existing ) )
212+ {
213+ var updated = existing with { webrtc = webrtcEndPoint } ;
214+ _calls . TryUpdate ( sipCallID , updated , existing ) ;
215+ }
216+
217+ var negotiateConnectResult = await webrtcEndPoint . StartConnect ( ) ;
218+
219+ if ( negotiateConnectResult . IsLeft )
220+ {
221+ Log . Logger . Error ( $ "Failed to negotiation connection to OpenAI Realtime WebRTC endpoint: { negotiateConnectResult . LeftAsEnumerable ( ) . First ( ) } ") ;
222+ return ;
223+ }
224+
225+ webrtcEndPoint . OnPeerConnectionConnected += ( ) =>
226+ {
227+ Log . Logger . Information ( "WebRTC peer connection established." ) ;
228+
229+ webrtcEndPoint . ConnectRTPSession ( rtpSession , AudioCommonlyUsedFormats . OpusWebRTC ) ;
230+
231+ var voice = RealtimeVoicesEnum . shimmer ;
232+
233+ // Optionally send a session update message to adjust the session parameters.
234+ var sessionUpdateResult = webrtcEndPoint . DataChannelMessenger . SendSessionUpdate (
235+ voice ,
236+ "Keep it short." ,
237+ transcriptionModel : TranscriptionModelEnum . Whisper1 ) ;
238+
239+ if ( sessionUpdateResult . IsLeft )
240+ {
241+ Log . Logger . Error ( $ "Failed to send session update message: { sessionUpdateResult . LeftAsEnumerable ( ) . First ( ) } ") ;
242+ }
243+
244+ // Trigger the conversation by sending a response create message.
245+ var result = webrtcEndPoint . DataChannelMessenger . SendResponseCreate ( voice , "Say Hi!" ) ;
246+ if ( result . IsLeft )
247+ {
248+ Log . Logger . Error ( $ "Failed to send response create message: { result . LeftAsEnumerable ( ) . First ( ) } ") ;
249+ }
250+ } ;
251+
252+ webrtcEndPoint . OnDataChannelMessage += ( dc , message ) =>
253+ {
254+ var log = message switch
255+ {
256+ RealtimeServerEventSessionUpdated sessionUpdated => $ "Session updated: { sessionUpdated . ToJson ( ) } ",
257+ //RealtimeServerEventConversationItemInputAudioTranscriptionDelta inputDelta => $"ME ⌛: {inputDelta.Delta?.Trim()}",
258+ RealtimeServerEventConversationItemInputAudioTranscriptionCompleted inputTranscript => $ "ME ✅: { inputTranscript . Transcript ? . Trim ( ) } ",
259+ //RealtimeServerEventResponseAudioTranscriptDelta responseDelta => $"AI ⌛: {responseDelta.Delta?.Trim()}",
260+ RealtimeServerEventResponseAudioTranscriptDone responseTranscript => $ "AI ✅: { responseTranscript . Transcript ? . Trim ( ) } ",
261+ //_ => $"Received {message.Type} -> {message.GetType().Name}"
262+ _ => string . Empty
263+ } ;
264+
265+ if ( log != string . Empty )
266+ {
267+ Log . Information ( log ) ;
268+ }
269+ } ;
270+ }
271+
272+ /// <summary>
273+ /// Event handler for receiving RTP packets.
274+ /// </summary>
275+ /// <param name="ua">The SIP user agent associated with the RTP session.</param>
276+ /// <param name="type">The media type of the RTP packet (audio or video).</param>
277+ /// <param name="rtpPacket">The RTP packet received from the remote party.</param>
278+ private static void OnRtpPacketReceived ( SIPUserAgent ua , IPEndPoint remoteEp , SDPMediaTypesEnum type , RTPPacket rtpPacket )
279+ {
280+ // The raw audio data is available in rtpPacket.Payload.
281+ Log . Verbose ( $ "OnRtpPacketReceived from { remoteEp } .") ;
282+ }
283+
284+ /// <summary>
285+ /// Event handler for receiving a DTMF tone.
286+ /// </summary>
287+ /// <param name="ua">The user agent that received the DTMF tone.</param>
288+ /// <param name="key">The DTMF tone.</param>
289+ /// <param name="duration">The duration in milliseconds of the tone.</param>
290+ private static void OnDtmfTone ( SIPUserAgent ua , byte key , int duration )
291+ {
292+ string callID = ua . Dialogue . CallId ;
293+ Log . Information ( $ "Call { callID } received DTMF tone { key } , duration { duration } ms.") ;
294+ }
295+
296+ /// <summary>
297+ /// Remove call from the active calls list.
298+ /// </summary>
299+ /// <param name="dialogue">The dialogue that was hungup.</param>
300+ private static void OnHangup ( SIPDialogue dialogue )
301+ {
302+ if ( dialogue != null )
303+ {
304+ string callID = dialogue . CallId ;
305+ if ( _calls . ContainsKey ( callID ) )
306+ {
307+ if ( _calls . TryRemove ( callID , out var call ) )
308+ {
309+ Log . Information ( $ "Call { callID } removed.") ;
310+
311+ // This app only uses each SIP user agent once so here the agent is
312+ // explicitly closed to prevent is responding to any new SIP requests.
313+ call . ua . Close ( ) ;
314+ call . webrtc ? . Close ( ) ;
315+ }
316+ }
317+ }
318+ }
319+ }
0 commit comments