@cemscale-voip/voip-sdk 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/webrtc.js ADDED
@@ -0,0 +1,758 @@
1
+ // ============================================================
2
+ // @cemscale/voip-sdk — WebRTC SIP Client (SIP.js 0.21 wrapper)
3
+ // ============================================================
4
+ import { UserAgent, Registerer, RegistererState, Invitation, Inviter, SessionState } from 'sip.js';
5
+ // -------------------------------------------------------------------
6
+ // SDP modifier: restrict audio to G.711 (PCMU/PCMA) + telephone-event
7
+ // -------------------------------------------------------------------
8
+ /**
9
+ * Returns an SDP modifier that strips all audio codecs except PCMU (G.711µ),
10
+ * PCMA (G.711A), and telephone-event (RFC 4733 DTMF).
11
+ *
12
+ * WHY: Twilio's PSTN gateway natively uses G.711. When the webphone offers
13
+ * OPUS, FreeSWITCH must activate a write resampler to transcode OPUS↔PCMU
14
+ * on every bridge. This transcoding introduces latency and timing instability
15
+ * that causes the call to terminate immediately after the 200 OK (observed as
16
+ * "call drops after one ring"). By restricting the webphone to G.711 only,
17
+ * FreeSWITCH performs zero-copy media passthrough — no resampler, no codec
18
+ * conversion, no timing skew.
19
+ *
20
+ * BROWSER SUPPORT: G.711 (PCMU/PCMA) is mandatory in the WebRTC spec (RFC 7874).
21
+ * All browsers (Chrome, Firefox, Safari, Edge) support it.
22
+ */
23
+ function makeG711SdpModifier() {
24
+ return async (desc) => {
25
+ if (!desc.sdp)
26
+ return desc;
27
+ const lines = desc.sdp.split(/\r\n|\r|\n/);
28
+ // Static G.711 payload types (per RFC 3551, always 0 and 8)
29
+ const kept = new Set(['0', '8']);
30
+ let inAudio = false;
31
+ // Pass 1 — collect dynamic telephone-event payload numbers in the audio section
32
+ for (const line of lines) {
33
+ if (line.startsWith('m=audio')) {
34
+ inAudio = true;
35
+ continue;
36
+ }
37
+ if (line.startsWith('m=') && !line.startsWith('m=audio')) {
38
+ inAudio = false;
39
+ continue;
40
+ }
41
+ if (!inAudio)
42
+ continue;
43
+ const m = line.match(/^a=rtpmap:(\d+)\s+telephone-event\//i);
44
+ if (m)
45
+ kept.add(m[1]);
46
+ }
47
+ // Pass 2 — rebuild SDP keeping only G.711 + telephone-event lines
48
+ inAudio = false;
49
+ const out = [];
50
+ for (const line of lines) {
51
+ if (line.startsWith('m=audio')) {
52
+ inAudio = true;
53
+ // Rewrite "m=audio <port> <proto> <pt1> <pt2> ..." keeping only our payloads
54
+ const parts = line.split(' ');
55
+ const prefix = parts.slice(0, 3).join(' ');
56
+ const filtered = parts.slice(3).filter((pt) => kept.has(pt));
57
+ // Safety: if nothing matched, keep original (should never happen)
58
+ out.push(filtered.length ? `${prefix} ${filtered.join(' ')}` : line);
59
+ continue;
60
+ }
61
+ if (line.startsWith('m=') && !line.startsWith('m=audio')) {
62
+ inAudio = false;
63
+ }
64
+ if (inAudio) {
65
+ // Drop rtpmap / fmtp / rtcp-fb lines for non-kept payload types
66
+ const rtpmap = line.match(/^a=rtpmap:(\d+)\s/);
67
+ const fmtp = line.match(/^a=fmtp:(\d+)\s/);
68
+ const rtcpfb = line.match(/^a=rtcp-fb:(\d+)\s/);
69
+ if (rtpmap && !kept.has(rtpmap[1]))
70
+ continue;
71
+ if (fmtp && !kept.has(fmtp[1]))
72
+ continue;
73
+ if (rtcpfb && !kept.has(rtcpfb[1]))
74
+ continue;
75
+ }
76
+ out.push(line);
77
+ }
78
+ return { ...desc, sdp: out.join('\r\n') };
79
+ };
80
+ }
81
+ /**
82
+ * WebRTCPhone wraps SIP.js 0.21 to provide a high-level phone interface.
83
+ *
84
+ * Hold uses the native SIP.js `sessionDescriptionHandlerOptionsReInvite.hold`
85
+ * API, which internally calls `SessionDescriptionHandler.updateDirection()` to
86
+ * set transceiver directions correctly before generating the SDP offer.
87
+ *
88
+ * Transfer uses SIP REFER (blind) or REFER with Replaces (attended).
89
+ *
90
+ * Usage:
91
+ * const phone = new WebRTCPhone({
92
+ * sipDomain: 'demo.sip.cemscale.com',
93
+ * wsUri: 'wss://sip.cemscale.com/ws',
94
+ * extension: '1001',
95
+ * password: 'secret',
96
+ * });
97
+ * await phone.start();
98
+ * phone.on('incomingCall', (call) => { ... });
99
+ * phone.call('1002');
100
+ */
101
+ export class WebRTCPhone {
102
+ config;
103
+ ua = null;
104
+ registerer = null;
105
+ currentSession = null;
106
+ currentCallInfo = null;
107
+ heldState = false;
108
+ mutedState = false;
109
+ holdPending = false;
110
+ listeners = new Map();
111
+ audioElement;
112
+ registered = false;
113
+ constructor(config) {
114
+ this.config = config;
115
+ this.audioElement = config.audioElement || document.createElement('audio');
116
+ this.audioElement.autoplay = true;
117
+ }
118
+ // -------------------------------------------------------------------
119
+ // Event emitter
120
+ // -------------------------------------------------------------------
121
+ on(event, callback) {
122
+ if (!this.listeners.has(event)) {
123
+ this.listeners.set(event, new Set());
124
+ }
125
+ this.listeners.get(event).add(callback);
126
+ return () => {
127
+ this.listeners.get(event)?.delete(callback);
128
+ };
129
+ }
130
+ emit(event, ...args) {
131
+ const handlers = this.listeners.get(event);
132
+ if (handlers) {
133
+ for (const handler of handlers) {
134
+ try {
135
+ handler(...args);
136
+ }
137
+ catch (err) {
138
+ console.error(`[WebRTCPhone] Event handler error for "${event}":`, err);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ // -------------------------------------------------------------------
144
+ // Lifecycle
145
+ // -------------------------------------------------------------------
146
+ /** Start the SIP User Agent and register */
147
+ async start() {
148
+ const uri = UserAgent.makeURI(`sip:${this.config.extension}@${this.config.sipDomain}`);
149
+ if (!uri) {
150
+ throw new Error(`Invalid SIP URI: sip:${this.config.extension}@${this.config.sipDomain}`);
151
+ }
152
+ const iceServers = this.config.turnServers || [
153
+ { urls: 'stun:stun.l.google.com:19302' },
154
+ ];
155
+ // Chrome 97+ requires the offerer to use a=setup:actpass (not active/passive).
156
+ // SIP.js or browser edge-cases can change it to 'active'. This modifier
157
+ // ensures the offer always has 'actpass' so setRemoteDescription(answer) succeeds.
158
+ const forceActpass = async (desc) => {
159
+ if (!desc.sdp)
160
+ return desc;
161
+ return {
162
+ ...desc,
163
+ sdp: desc.sdp.replace(/a=setup:active\r?\n/g, 'a=setup:actpass\r\n'),
164
+ };
165
+ };
166
+ const sdpModifiers = [forceActpass];
167
+ this.ua = new UserAgent({
168
+ uri,
169
+ transportOptions: {
170
+ server: this.config.wsUri,
171
+ connectionTimeout: 10,
172
+ keepAliveInterval: 25,
173
+ reconnectionAttempts: 3,
174
+ reconnectionDelay: 4,
175
+ },
176
+ authorizationUsername: this.config.extension,
177
+ authorizationPassword: this.config.password,
178
+ displayName: this.config.displayName || this.config.extension,
179
+ sessionDescriptionHandlerFactoryOptions: {
180
+ peerConnectionConfiguration: {
181
+ iceServers,
182
+ },
183
+ // Codec filter applied to every SDP offer and answer (outbound calls,
184
+ // inbound answers, re-INVITEs for hold/unhold). Defaults to G.711-only.
185
+ modifiers: sdpModifiers,
186
+ },
187
+ delegate: {
188
+ onInvite: (invitation) => {
189
+ this.handleIncomingCall(invitation);
190
+ },
191
+ },
192
+ logLevel: 'warn',
193
+ });
194
+ await this.ua.start();
195
+ // Monitor transport connect/disconnect for auto-re-registration
196
+ if (this.ua.transport) {
197
+ const transport = this.ua.transport;
198
+ const origOnDisconnect = transport.onDisconnect;
199
+ transport.onDisconnect = (error) => {
200
+ this.registered = false;
201
+ this.emit('unregistered');
202
+ console.warn('[WebRTCPhone] Transport disconnected', error?.message || '');
203
+ if (origOnDisconnect)
204
+ origOnDisconnect.call(transport, error);
205
+ };
206
+ const origOnConnect = transport.onConnect;
207
+ transport.onConnect = () => {
208
+ console.log('[WebRTCPhone] Transport reconnected, re-registering...');
209
+ if (origOnConnect)
210
+ origOnConnect.call(transport);
211
+ // Re-register after transport reconnects
212
+ if (this.registerer) {
213
+ this.registerer.register().catch((err) => {
214
+ console.error('[WebRTCPhone] Re-registration failed:', err);
215
+ this.emit('registrationFailed', err instanceof Error ? err : new Error(String(err)));
216
+ });
217
+ }
218
+ };
219
+ }
220
+ this.registerer = new Registerer(this.ua, { expires: 300 });
221
+ this.registerer.stateChange.addListener((state) => {
222
+ switch (state) {
223
+ case RegistererState.Registered:
224
+ this.registered = true;
225
+ this.emit('registered');
226
+ break;
227
+ case RegistererState.Unregistered:
228
+ case RegistererState.Terminated:
229
+ this.registered = false;
230
+ this.emit('unregistered');
231
+ break;
232
+ }
233
+ });
234
+ try {
235
+ await this.registerer.register();
236
+ }
237
+ catch (err) {
238
+ this.registered = false;
239
+ this.emit('registrationFailed', err instanceof Error ? err : new Error(String(err)));
240
+ throw err;
241
+ }
242
+ }
243
+ /** Stop the SIP User Agent */
244
+ async stop() {
245
+ if (this.currentSession) {
246
+ try {
247
+ await this.hangup();
248
+ }
249
+ catch { /* ignore */ }
250
+ }
251
+ if (this.registerer) {
252
+ try {
253
+ await this.registerer.unregister();
254
+ }
255
+ catch { /* ignore */ }
256
+ this.registerer = null;
257
+ }
258
+ if (this.ua) {
259
+ try {
260
+ await this.ua.stop();
261
+ }
262
+ catch { /* ignore */ }
263
+ this.ua = null;
264
+ }
265
+ this.registered = false;
266
+ this.currentSession = null;
267
+ this.currentCallInfo = null;
268
+ this.heldState = false;
269
+ this.mutedState = false;
270
+ }
271
+ isRegistered() {
272
+ return this.registered;
273
+ }
274
+ /** Get the SIP domain for this phone instance */
275
+ getSipDomain() {
276
+ return this.config.sipDomain;
277
+ }
278
+ /** Get the underlying SIP.js Session (for advanced use) */
279
+ getSession() {
280
+ return this.currentSession;
281
+ }
282
+ /** Get current call info (snapshot) */
283
+ getCurrentCall() {
284
+ return this.currentCallInfo ? { ...this.currentCallInfo } : null;
285
+ }
286
+ // -------------------------------------------------------------------
287
+ // Outbound calls
288
+ // -------------------------------------------------------------------
289
+ async call(target) {
290
+ if (!this.ua)
291
+ throw new Error('UserAgent not started. Call start() first.');
292
+ if (this.currentSession)
293
+ throw new Error('A call is already in progress.');
294
+ // Request microphone access before sending the INVITE.
295
+ // This surfaces permission errors immediately with a clear message,
296
+ // rather than letting SIP.js fail silently during media negotiation.
297
+ try {
298
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
299
+ stream.getTracks().forEach((t) => t.stop()); // Release — SIP.js re-acquires it
300
+ }
301
+ catch (err) {
302
+ const msg = err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError'
303
+ ? 'Microphone access denied. Allow microphone in browser settings and try again.'
304
+ : `Microphone unavailable: ${err.message}`;
305
+ this.emit('error', new Error(msg));
306
+ throw new Error(msg);
307
+ }
308
+ const targetUri = UserAgent.makeURI(`sip:${target}@${this.config.sipDomain}`);
309
+ if (!targetUri)
310
+ throw new Error(`Invalid target: ${target}`);
311
+ const inviter = new Inviter(this.ua, targetUri, {
312
+ sessionDescriptionHandlerOptions: {
313
+ constraints: { audio: true, video: false },
314
+ },
315
+ });
316
+ this.currentSession = inviter;
317
+ this.heldState = false;
318
+ this.mutedState = false;
319
+ this.currentCallInfo = {
320
+ id: inviter.id,
321
+ direction: 'outbound',
322
+ remoteIdentity: target,
323
+ remoteDisplayName: target,
324
+ state: 'connecting',
325
+ startTime: new Date(),
326
+ answerTime: null,
327
+ held: false,
328
+ muted: false,
329
+ dtmfSent: '',
330
+ };
331
+ this.setupSessionHandlers(inviter);
332
+ await inviter.invite();
333
+ this.emitCallState();
334
+ return { ...this.currentCallInfo };
335
+ }
336
+ // -------------------------------------------------------------------
337
+ // Inbound calls
338
+ // -------------------------------------------------------------------
339
+ handleIncomingCall(invitation) {
340
+ if (this.currentSession) {
341
+ // Reject with 486 Busy Here and notify consumer of missed call
342
+ invitation.reject({ statusCode: 486 });
343
+ const remoteUri = invitation.remoteIdentity.uri;
344
+ this.emit('callStateChanged', {
345
+ id: invitation.id,
346
+ direction: 'inbound',
347
+ remoteIdentity: remoteUri.user || 'unknown',
348
+ remoteDisplayName: invitation.remoteIdentity.displayName || remoteUri.user || 'unknown',
349
+ state: 'terminated',
350
+ startTime: new Date(),
351
+ answerTime: null,
352
+ held: false,
353
+ muted: false,
354
+ dtmfSent: '',
355
+ });
356
+ return;
357
+ }
358
+ this.currentSession = invitation;
359
+ this.heldState = false;
360
+ this.mutedState = false;
361
+ const remoteUri = invitation.remoteIdentity.uri;
362
+ this.currentCallInfo = {
363
+ id: invitation.id,
364
+ direction: 'inbound',
365
+ remoteIdentity: remoteUri.user || 'unknown',
366
+ remoteDisplayName: invitation.remoteIdentity.displayName || remoteUri.user || 'unknown',
367
+ state: 'ringing',
368
+ startTime: new Date(),
369
+ answerTime: null,
370
+ held: false,
371
+ muted: false,
372
+ dtmfSent: '',
373
+ };
374
+ this.setupSessionHandlers(invitation);
375
+ this.emit('incomingCall', { ...this.currentCallInfo });
376
+ if (this.config.autoAnswer) {
377
+ this.answer().catch((err) => {
378
+ this.emit('error', new Error(`Auto-answer failed: ${err.message}`));
379
+ });
380
+ }
381
+ }
382
+ async answer() {
383
+ if (!this.currentSession || !(this.currentSession instanceof Invitation)) {
384
+ throw new Error('No incoming call to answer');
385
+ }
386
+ // Request microphone access before accepting — same rationale as call()
387
+ try {
388
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
389
+ stream.getTracks().forEach((t) => t.stop());
390
+ }
391
+ catch (err) {
392
+ const msg = err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError'
393
+ ? 'Microphone access denied. Allow microphone in browser settings to answer calls.'
394
+ : `Microphone unavailable: ${err.message}`;
395
+ this.emit('error', new Error(msg));
396
+ throw new Error(msg);
397
+ }
398
+ await this.currentSession.accept({
399
+ sessionDescriptionHandlerOptions: {
400
+ constraints: { audio: true, video: false },
401
+ },
402
+ });
403
+ }
404
+ async reject() {
405
+ if (!this.currentSession || !(this.currentSession instanceof Invitation)) {
406
+ throw new Error('No incoming call to reject');
407
+ }
408
+ await this.currentSession.reject();
409
+ this.clearSession();
410
+ }
411
+ // -------------------------------------------------------------------
412
+ // Session controls
413
+ // -------------------------------------------------------------------
414
+ async hangup() {
415
+ if (!this.currentSession)
416
+ throw new Error('No active call');
417
+ const session = this.currentSession;
418
+ try {
419
+ switch (session.state) {
420
+ case SessionState.Initial:
421
+ case SessionState.Establishing:
422
+ if (session instanceof Inviter)
423
+ await session.cancel();
424
+ else if (session instanceof Invitation)
425
+ await session.reject();
426
+ break;
427
+ case SessionState.Established:
428
+ await session.bye();
429
+ break;
430
+ case SessionState.Terminated:
431
+ break;
432
+ default:
433
+ try {
434
+ await session.bye();
435
+ }
436
+ catch { /* ignore */ }
437
+ break;
438
+ }
439
+ }
440
+ catch (err) {
441
+ console.error('[WebRTCPhone] Hangup error:', err);
442
+ }
443
+ this.clearSession();
444
+ }
445
+ /**
446
+ * Hold/unhold the current call.
447
+ *
448
+ * Uses the SIP.js 0.21 native hold API:
449
+ * `session.sessionDescriptionHandlerOptionsReInvite.hold = true/false`
450
+ *
451
+ * SIP.js internally calls `SessionDescriptionHandler.updateDirection()` which
452
+ * correctly sets transceiver directions (sendonly for hold, sendrecv for resume)
453
+ * before generating the SDP offer. This is the same pattern used by SIP.js's
454
+ * own SessionManager.setHold().
455
+ *
456
+ * The re-INVITE is sent to FreeSWITCH which activates/deactivates music-on-hold.
457
+ */
458
+ async toggleHold() {
459
+ if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
460
+ throw new Error('No established call to hold');
461
+ }
462
+ if (!this.currentCallInfo)
463
+ throw new Error('No call info');
464
+ if (this.holdPending)
465
+ throw new Error('Hold operation already in progress');
466
+ this.holdPending = true;
467
+ const newHeld = !this.heldState;
468
+ const session = this.currentSession;
469
+ // Preemptively update UI state for immediate feedback
470
+ this.heldState = newHeld;
471
+ this.currentCallInfo.held = newHeld;
472
+ this.currentCallInfo.state = newHeld ? 'held' : 'established';
473
+ this.emitCallState();
474
+ // Send re-INVITE with hold flag passed directly to the SessionDescriptionHandler.
475
+ // SIP.js SDH reads `sessionDescriptionHandlerOptions.hold` and sets the correct
476
+ // SDP direction: hold=true → sendonly, hold=false → sendrecv.
477
+ // This tells FreeSWITCH to activate/deactivate Music-On-Hold.
478
+ // Cast to `any` — `hold` is defined in the Web SDH implementation but not in
479
+ // the base `SessionDescriptionHandlerOptions` TypeScript interface.
480
+ try {
481
+ await session.invite({
482
+ sessionDescriptionHandlerOptions: { hold: newHeld },
483
+ requestDelegate: {
484
+ onAccept: () => {
485
+ console.log(`[WebRTCPhone] Hold ${newHeld ? 'ON' : 'OFF'}: accepted`);
486
+ this.holdPending = false;
487
+ this.emit('holdSucceeded', newHeld);
488
+ },
489
+ onReject: () => {
490
+ console.warn(`[WebRTCPhone] Hold re-INVITE rejected`);
491
+ this.holdPending = false;
492
+ this.revertHold(!newHeld);
493
+ this.emit('holdFailed', new Error('Hold re-INVITE rejected by server'));
494
+ },
495
+ },
496
+ });
497
+ }
498
+ catch (err) {
499
+ console.error('[WebRTCPhone] Hold re-INVITE failed:', err);
500
+ this.holdPending = false;
501
+ this.revertHold(!newHeld);
502
+ this.emit('holdFailed', new Error(`Hold failed: ${err.message}`));
503
+ throw err;
504
+ }
505
+ return newHeld;
506
+ }
507
+ /** Revert local hold state after re-INVITE failure (no SDP change sent) */
508
+ revertHold(revertTo) {
509
+ if (!this.currentCallInfo)
510
+ return;
511
+ this.heldState = revertTo;
512
+ this.currentCallInfo.held = revertTo;
513
+ this.currentCallInfo.state = revertTo ? 'held' : 'established';
514
+ this.emitCallState();
515
+ }
516
+ /** Mute/unmute local audio */
517
+ toggleMute() {
518
+ if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
519
+ throw new Error('No established call to mute');
520
+ }
521
+ if (!this.currentCallInfo)
522
+ throw new Error('No call info');
523
+ this.mutedState = !this.mutedState;
524
+ this.enableSenderTracks(!this.mutedState && !this.heldState);
525
+ this.currentCallInfo.muted = this.mutedState;
526
+ this.emitCallState();
527
+ return this.mutedState;
528
+ }
529
+ /** Send a DTMF tone */
530
+ sendDtmf(tone) {
531
+ if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
532
+ throw new Error('No established call for DTMF');
533
+ }
534
+ const pc = this.getPeerConnection();
535
+ if (pc) {
536
+ for (const sender of pc.getSenders()) {
537
+ if (sender.track?.kind === 'audio' && sender.dtmf) {
538
+ sender.dtmf.insertDTMF(tone, 100, 70);
539
+ if (this.currentCallInfo) {
540
+ this.currentCallInfo.dtmfSent += tone;
541
+ this.emitCallState();
542
+ }
543
+ return;
544
+ }
545
+ }
546
+ }
547
+ // Fallback: SIP INFO
548
+ this.currentSession.info({
549
+ requestOptions: {
550
+ body: {
551
+ contentDisposition: 'render',
552
+ contentType: 'application/dtmf-relay',
553
+ content: `Signal=${tone}\r\nDuration=100`,
554
+ },
555
+ },
556
+ });
557
+ if (this.currentCallInfo) {
558
+ this.currentCallInfo.dtmfSent += tone;
559
+ this.emitCallState();
560
+ }
561
+ }
562
+ /**
563
+ * Blind transfer: send SIP REFER to transfer the call to target.
564
+ * FreeSWITCH handles the actual transfer in its `transfer` dialplan context.
565
+ */
566
+ async blindTransfer(target) {
567
+ if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
568
+ throw new Error('No established call to transfer');
569
+ }
570
+ const targetUri = UserAgent.makeURI(`sip:${target}@${this.config.sipDomain}`);
571
+ if (!targetUri)
572
+ throw new Error(`Invalid transfer target: ${target}`);
573
+ console.log(`[WebRTCPhone] Blind transfer to ${target}@${this.config.sipDomain}`);
574
+ await this.currentSession.refer(targetUri, {
575
+ requestDelegate: {
576
+ onAccept: () => {
577
+ console.log('[WebRTCPhone] REFER accepted');
578
+ this.emit('transferProgress', { state: 'accepted', message: 'Transfer accepted' });
579
+ },
580
+ onReject: () => {
581
+ console.warn('[WebRTCPhone] REFER rejected');
582
+ this.emit('transferProgress', { state: 'rejected', message: 'Transfer rejected' });
583
+ },
584
+ },
585
+ });
586
+ this.emit('transferProgress', { state: 'trying', message: `Transferring to ${target}...` });
587
+ }
588
+ /**
589
+ * Attended transfer: REFER with Replaces.
590
+ * Requires a second Session (the consultation call) that is already established.
591
+ */
592
+ async attendedTransfer(targetSession) {
593
+ if (!this.currentSession || this.currentSession.state !== SessionState.Established) {
594
+ throw new Error('No established call to transfer');
595
+ }
596
+ console.log('[WebRTCPhone] Attended transfer (REFER with Replaces)');
597
+ await this.currentSession.refer(targetSession, {
598
+ requestDelegate: {
599
+ onAccept: () => {
600
+ console.log('[WebRTCPhone] Attended REFER accepted');
601
+ this.emit('transferProgress', { state: 'accepted', message: 'Transfer completed' });
602
+ },
603
+ onReject: () => {
604
+ console.warn('[WebRTCPhone] Attended REFER rejected');
605
+ this.emit('transferProgress', { state: 'rejected', message: 'Transfer rejected' });
606
+ },
607
+ },
608
+ });
609
+ this.emit('transferProgress', { state: 'trying', message: 'Completing attended transfer...' });
610
+ }
611
+ // -------------------------------------------------------------------
612
+ // Internal helpers
613
+ // -------------------------------------------------------------------
614
+ setupSessionHandlers(session) {
615
+ // ------------------------------------------------------------------
616
+ // Explicit BYE delegate — robust backup for remote hangup detection.
617
+ // SIP.js stateChange should also fire Terminated, but this ensures
618
+ // the call ends even if stateChange is delayed or missed.
619
+ // ------------------------------------------------------------------
620
+ session.delegate = {
621
+ ...(session.delegate || {}),
622
+ onBye: (bye) => {
623
+ console.log('[WebRTCPhone] BYE received from remote party');
624
+ // SIP.js already sent 200 OK automatically.
625
+ // Force immediate cleanup if stateChange hasn't fired yet.
626
+ if (this.currentCallInfo && this.currentCallInfo.state !== 'terminated') {
627
+ this.currentCallInfo.state = 'terminated';
628
+ this.emitCallState();
629
+ this.emit('callEnded', { ...this.currentCallInfo });
630
+ this.clearSession();
631
+ }
632
+ },
633
+ };
634
+ session.stateChange.addListener((state) => {
635
+ console.log(`[WebRTCPhone] Session stateChange: ${state}`);
636
+ if (!this.currentCallInfo)
637
+ return;
638
+ switch (state) {
639
+ case SessionState.Establishing:
640
+ // Outbound: far-end is ringing (1xx received). Inbound: we accepted, negotiating.
641
+ this.currentCallInfo.state =
642
+ this.currentCallInfo.direction === 'outbound' ? 'ringing' : 'connecting';
643
+ break;
644
+ case SessionState.Established:
645
+ this.currentCallInfo.state = 'established';
646
+ this.currentCallInfo.answerTime = new Date();
647
+ this.attachRemoteAudio(session);
648
+ break;
649
+ case SessionState.Terminating:
650
+ this.currentCallInfo.state = 'terminating';
651
+ break;
652
+ case SessionState.Terminated:
653
+ if (this.currentCallInfo.state === 'terminated')
654
+ return; // Already handled by onBye
655
+ this.currentCallInfo.state = 'terminated';
656
+ this.emitCallState();
657
+ this.emit('callEnded', { ...this.currentCallInfo });
658
+ this.clearSession();
659
+ return;
660
+ }
661
+ this.emitCallState();
662
+ });
663
+ }
664
+ attachRemoteAudio(session) {
665
+ const pc = this.getPeerConnection(session);
666
+ if (!pc)
667
+ return;
668
+ const remoteStream = new MediaStream();
669
+ // Assign srcObject FIRST so the ontrack handler below can safely add tracks to it.
670
+ // Previously this was set AFTER ontrack, causing a race condition where ontrack
671
+ // could fire before srcObject was set, silently dropping the remote audio track.
672
+ this.audioElement.srcObject = remoteStream;
673
+ // Add tracks that already exist at the moment Established fires
674
+ for (const receiver of pc.getReceivers()) {
675
+ if (receiver.track)
676
+ remoteStream.addTrack(receiver.track);
677
+ }
678
+ // Add tracks that arrive later (hold/unhold re-INVITE renegotiation)
679
+ pc.ontrack = (event) => {
680
+ if (event.track && !remoteStream.getTrackById(event.track.id)) {
681
+ remoteStream.addTrack(event.track);
682
+ }
683
+ };
684
+ // Monitor ICE connection state for media path failures
685
+ pc.oniceconnectionstatechange = () => {
686
+ if (pc.iceConnectionState === 'failed') {
687
+ this.emit('error', new Error('ICE connection failed — media path lost'));
688
+ }
689
+ else if (pc.iceConnectionState === 'disconnected') {
690
+ console.warn('[WebRTCPhone] ICE disconnected — may recover');
691
+ }
692
+ };
693
+ this.audioElement.play().catch((err) => {
694
+ console.warn('[WebRTCPhone] Autoplay blocked — user interaction required:', err?.message);
695
+ this.emit('error', new Error('Audio playback blocked by browser. Click the page to enable audio.'));
696
+ });
697
+ }
698
+ getPeerConnection(session) {
699
+ const s = session || this.currentSession;
700
+ if (!s)
701
+ return null;
702
+ const sdh = s.sessionDescriptionHandler;
703
+ if (!sdh)
704
+ return null;
705
+ return sdh.peerConnection || null;
706
+ }
707
+ enableSenderTracks(enabled) {
708
+ const pc = this.getPeerConnection();
709
+ if (!pc)
710
+ return;
711
+ for (const sender of pc.getSenders()) {
712
+ if (sender.track?.kind === 'audio') {
713
+ sender.track.enabled = enabled;
714
+ }
715
+ }
716
+ }
717
+ enableReceiverTracks(enabled) {
718
+ const pc = this.getPeerConnection();
719
+ if (!pc)
720
+ return;
721
+ for (const receiver of pc.getReceivers()) {
722
+ if (receiver.track?.kind === 'audio') {
723
+ receiver.track.enabled = enabled;
724
+ }
725
+ }
726
+ }
727
+ emitCallState() {
728
+ if (this.currentCallInfo) {
729
+ this.emit('callStateChanged', { ...this.currentCallInfo });
730
+ }
731
+ }
732
+ clearSession() {
733
+ this.currentSession = null;
734
+ this.currentCallInfo = null;
735
+ this.heldState = false;
736
+ this.mutedState = false;
737
+ this.holdPending = false;
738
+ }
739
+ /**
740
+ * Check if the browser has microphone permission.
741
+ * Call this before start() to provide a better UX when mic is denied.
742
+ */
743
+ static async checkMicrophoneAccess() {
744
+ try {
745
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
746
+ stream.getTracks().forEach((t) => t.stop());
747
+ return true;
748
+ }
749
+ catch {
750
+ return false;
751
+ }
752
+ }
753
+ async destroy() {
754
+ await this.stop();
755
+ this.listeners.clear();
756
+ }
757
+ }
758
+ //# sourceMappingURL=webrtc.js.map