@telegenta/webclient 3.1.1 → 3.2.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/README.md CHANGED
@@ -39,63 +39,49 @@ and you are ready to go. The client has no external requirements (like jQuery an
39
39
 
40
40
  Before starting, you will need an account on telegenta.com, to create sip accounts and api tokens.
41
41
 
42
- #### Example 1: Simple phone with static account
42
+ Runnable examples live in `demos/`. They load the browser bundle from `../dist/telegenta.min.js`, so
43
+ run `npm run build` first, then serve the directory over https. WebRTC requires a secure context;
44
+ `localhost` counts as secure, but opening a demo as `file://...` does not - media permissions are not
45
+ remembered there, so you will be prompted for the microphone every time.
43
46
 
44
- In this example we will use a normal SIP User account, as it does not require you to use our API service.
47
+ #### Example: phone with a provisioning token
45
48
 
46
- Log into the administration frontend (https://admin.telegenta.com) and create a "SIP User account", this will provide
47
- you with credentials (username and password). Once you have these open the embedded html client provided with this
48
- library /embedded_client/index.html and modify the credentials:
49
+ `demos/phone_client.html` is a complete softphone - start/stop, outbound calls, transfer, listen-in,
50
+ incoming calls and the event client.
49
51
 
50
- ```
51
- // SIP account username and password
52
- var USERNAME='your username';
53
- var PASSWORD='your password';
54
-
55
- window.onload = function () {
56
- ...
57
- ```
52
+ First create a static API token in the admin interface
53
+ (https://admin.telegenta.com/integration/access-tokens). Open the demo and paste the token in; it then
58
54
 
59
- Now open the file (index.html) in your browser. After you have been prompted for permission to use the audio devices,
60
- you should have a working phone.
61
- Note: If you are viewing a file using "file://index.html", it is not considered a secure connection by the browser
62
- and media permissions are not remembered, so you will be prompted every time you open the file.
63
- However, once you move the file to a secure webserver (https), this will no longer be an issue.
55
+ 1. requests a short-lived provisioning token with `GET /api/get-call-session/?id=<account id>`, and
56
+ 2. starts the phone with that token:
64
57
 
65
- #### Example 2: Using SIP Flex accounts
66
-
67
- When using SIP flex accounts, you need to use our API service to retrieve account information first.
68
- This approach solves the authentication problem and provides you with short-lived sip accounts used in client sessions.
69
- For this example we need a backend service and we provide a simple example in PHP using the built in Webserver.
58
+ ```
59
+ const opts = {
60
+ // Enable event client
61
+ connectEventService: true,
70
62
 
71
- First go to the admin interface and create following 2 items:
63
+ // Subscribe to detailed information about call after processing
64
+ useCallDataInfoEvent: true,
72
65
 
73
- 1. SIP Flex account with "flex account identification" set to 'test'
74
- 2. A static api token (https://admin.telegenta.com/integration/access-tokens).
66
+ // Enable incoming calls (you must set a phone number to call the account also)
67
+ register: true,
75
68
 
76
- Now open the file /embedded_client/server.html and insert the API token at the top of the file:
69
+ // Configure client with provision token
70
+ provisionToken: data.provision_token,
71
+ };
77
72
 
73
+ phoneClient.start(opts);
78
74
  ```
79
- // Your API Token
80
- $api_auth_token = '<your api token>';
81
75
 
82
- // The flex account identification (this is set when configuring your flex accounts)
83
- // It possible to have multiple sip flex accounts with different configurations
84
- $flex_identification = 'test';
85
- ...
86
- ```
76
+ Issuing the provisioning token from your own backend is the recommended integration: the short-lived
77
+ token is all that reaches the browser, and your API token stays on the server.
87
78
 
88
- Once you have added the token, you can start the php Webserver (requires php v5.4+) like this:
79
+ The remaining demos cover narrower cases:
89
80
 
90
- ```
91
- cd embedded_client
92
- php -S localhost:9000
93
- ```
94
-
95
- Now open the URL http://localhost:9000/server.php and you should see the phone and be able to perform calls.
96
- Note: If you use any other host than localhost, you won't be able to call because the built in php webserver does not
97
- support secure (https) connections and this is required by the browser to use WebRTC. If needed, you can tunnel to
98
- localhost using your ssh client for testing.
81
+ - `demos/event_client.html` - the event client on its own, without a phone
82
+ - `demos/calldata_available.html` - subscribing to call data after a call completes
83
+ - `demos/add_phone_number.html` - adding a phone number through the API
84
+ - `demos/embedded_config.html` - embedding the admin config pages with a config token
99
85
 
100
86
  ## Approaching the integration
101
87
 
@@ -106,15 +92,10 @@ Depending on how much flexibility you need, you can choose between the following
106
92
  We believe in using standard protocols as much as possible and it will work nicely, but you need to address most
107
93
  of the issues we listed above in the previous section yourself.
108
94
 
109
- 2. Use our free library (lib/telegenta.min.js) and provide your own frontend
110
- This way you just need to handle events and use javascript methods like call, hangup, startRecord, etc..
111
-
112
- 3. Use our complete embedded phone client with frontend.
113
- We have made a simple phone with frontend, which can be easily included in your own project. Just grab the
114
- index.html, style.css and embedded_phone.js and insert it into your own project.
115
- We named the JS and CSS to try and avoid any potential conflicts.
116
- You only need to provide it with credentials or provisioning token (as in the example above) from you backend and
117
- you are set to start calling.
95
+ 2. Use our free library and provide your own frontend.
96
+ Load `dist/telegenta.min.js` in the browser (global `Telegenta`) or import the package in your
97
+ bundler, then handle events and use javascript methods like call, hangup, startRecord, etc..
98
+ `demos/phone_client.html` shows the whole flow end to end.
118
99
 
119
100
  ## Migrating from 2.x
120
101
 
@@ -161,6 +142,33 @@ If you are migrating from 1.7.x release into 2.x, some code breaking changes nee
161
142
 
162
143
  This library uses the JsSIP library (www.jssip.net) created by José Luis Milàn, Iñaki Baz Castillo and Saúl Ibarra Corretgé
163
144
 
145
+ ## Changelog
146
+
147
+ 3.2.0
148
+ - Phone.setCallToken(token): replace the call token without restarting (tokens expire after 30 min).
149
+ With useCallDataInfoEvent, the callDataInfo subscription keeps the token from start(); call
150
+ start() again to move it to the new token.
151
+ - Phone.prepareMedia({ deviceId }) and Phone.setInputDevice(deviceId): microphone selection
152
+ - Phone.prepareMedia() without a deviceId resets a microphone previously chosen with setInputDevice()
153
+ - Phone.setOutputDevice(deviceId) and Phone.outputDeviceSelectionSupported: speaker selection
154
+ - prepareMedia() releases the microphone after the permission probe
155
+ - Phone.resumeAudio(): resumes the signal-tone AudioContext; call it from a user-gesture handler,
156
+ since a phone started on page load gets a "suspended" context and plays no tones. Tones also
157
+ try to resume it themselves. One AudioContext per Phone is now reused across start()/stop()
158
+ (previously every start() created a new one that was never closed)
159
+ - prepareMedia() now rejects instead of throwing synchronously, and a failed request no longer
160
+ changes the selected microphone or media-permission state unless the browser actually denied
161
+ permission (NotAllowedError/SecurityError)
162
+ - start() no longer mutates the shared default options between Phone instances
163
+ - stop() now always marks the phone stopped
164
+ - Phone.register(): re-sends REGISTER without restarting the phone or dropping active calls
165
+ (throws NotReady if not started) - for recovering a REGISTER refresh that failed for a reason
166
+ other than bad credentials, which JsSIP does not retry on its own
167
+ - connectionStateChange now carries `cause` and `statusCode` alongside `state` when
168
+ state is REGISTRATION_FAILED (from JsSIP's registrationFailed event), so callers can tell a
169
+ transient network failure (JsSIP retries on its own) apart from an authentication/authorization
170
+ failure (401/403) that needs a fresh call session
171
+
164
172
  #### License
165
173
 
166
174
  This software is provided for free together with our service
package/dist/telegenta.js CHANGED
@@ -25,23 +25,23 @@ class LogHandler {
25
25
  }
26
26
 
27
27
  debug(...args) {
28
- this._doLog(LOGLVL$1.DEBUG, args);
28
+ this._doLog(LOGLVL.DEBUG, args);
29
29
  }
30
30
 
31
31
  info(...args) {
32
- this._doLog(LOGLVL$1.INFO, args);
32
+ this._doLog(LOGLVL.INFO, args);
33
33
  }
34
34
 
35
35
  warn(...args) {
36
- this._doLog(LOGLVL$1.WARN, args);
36
+ this._doLog(LOGLVL.WARN, args);
37
37
  }
38
38
 
39
39
  warning(...args) {
40
- this._doLog(LOGLVL$1.WARN, args);
40
+ this._doLog(LOGLVL.WARN, args);
41
41
  }
42
42
 
43
43
  error(...args) {
44
- this._doLog(LOGLVL$1.ERROR, args);
44
+ this._doLog(LOGLVL.ERROR, args);
45
45
  }
46
46
 
47
47
  _doLog(level, args) {
@@ -60,19 +60,19 @@ class LogHandler {
60
60
  this.extHandler(LOGLVLSTR[level], args);
61
61
  } else {
62
62
  switch (level) {
63
- case LOGLVL$1.DEBUG:
63
+ case LOGLVL.DEBUG:
64
64
  console.debug(args);
65
65
  break;
66
66
 
67
- case LOGLVL$1.INFO:
67
+ case LOGLVL.INFO:
68
68
  console.info(args);
69
69
  break;
70
70
 
71
- case LOGLVL$1.WARN:
71
+ case LOGLVL.WARN:
72
72
  console.warn(args);
73
73
  break;
74
74
 
75
- case LOGLVL$1.ERROR:
75
+ case LOGLVL.ERROR:
76
76
  console.error(args);
77
77
  break;
78
78
 
@@ -91,7 +91,7 @@ class LogHandler {
91
91
  * @property {number} INFO 2 Informational messages (default)
92
92
  * @property {number} DEBUG 3 Debug messages
93
93
  */
94
- const LOGLVL$1 = {
94
+ const LOGLVL = {
95
95
  DEBUG: 3,
96
96
  INFO: 2,
97
97
  WARN: 1,
@@ -386,7 +386,7 @@ class Request {
386
386
  class Requestor extends EventEmitter {
387
387
  constructor(logger = null) {
388
388
  super();
389
- this.log = logger ? logger : new LogHandler(LOGLVL$1.INFO);
389
+ this.log = logger ? logger : new LogHandler(LOGLVL.INFO);
390
390
  this._pendingRequests = [];
391
391
  }
392
392
 
@@ -401,13 +401,13 @@ class Requestor extends EventEmitter {
401
401
  let msg;
402
402
  try {
403
403
  msg = JSON.parse(message);
404
- } catch (err) {
404
+ } catch {
405
405
  this.log.warning("Unable to parse message: " + message);
406
406
  return;
407
407
  }
408
408
 
409
409
  // Validate msg fields
410
- if (!msg.hasOwnProperty("type") || !msg.hasOwnProperty("id")) {
410
+ if (!Object.hasOwn(msg, "type") || !Object.hasOwn(msg, "id")) {
411
411
  this.log.warning(`Invalid message received: ${msg} - missing type or id`);
412
412
  return;
413
413
  }
@@ -658,7 +658,7 @@ class EventClient {
658
658
  throw new InvalidParameter('You must specify both event spec and callback function');
659
659
  }
660
660
 
661
- if (!eventSpecification.hasOwnProperty('event_name')) {
661
+ if (!Object.hasOwn(eventSpecification, 'event_name')) {
662
662
  throw new InvalidParameter('Your event specification must contain event name');
663
663
  }
664
664
 
@@ -1643,6 +1643,9 @@ class Phone extends EventEmitter {
1643
1643
  this._eventClient = null;
1644
1644
  this._options = null;
1645
1645
 
1646
+ // getUserMedia audio constraint: true (default device) or { deviceId: { exact } }
1647
+ this._audioConstraints = true;
1648
+
1646
1649
  // List of active sessions
1647
1650
  this._sessionCounter = 0;
1648
1651
  this._activeSessions = {};
@@ -1658,15 +1661,16 @@ class Phone extends EventEmitter {
1658
1661
  this._repeatToneActive = false;
1659
1662
  this._repeatToneSequence = [];
1660
1663
  this._repeatToneSequencePos = 0;
1664
+ this._toneContext = null;
1661
1665
  this._toneGainProcessor = null;
1662
1666
  this._oscillator1 = null;
1663
1667
  this._oscillator2 = null;
1664
1668
 
1665
1669
  // Set default logger
1666
- this.log = logger ? logger : new LogHandler(LOGLVL$1.INFO);
1670
+ this.log = logger ? logger : new LogHandler(LOGLVL.INFO);
1667
1671
 
1668
1672
  // Handle jssip debug logs
1669
- if (this.log.level === LOGLVL$1.DEBUG) {
1673
+ if (this.log.level === LOGLVL.DEBUG) {
1670
1674
  JsSIP.debug.disable('JsSIP:*');
1671
1675
  } else {
1672
1676
  JsSIP.debug.disable('JsSIP:*');
@@ -1677,29 +1681,83 @@ class Phone extends EventEmitter {
1677
1681
  * Prepare media and get media permission from browser
1678
1682
  * This must be done prior to using the phone or getting device lists.
1679
1683
  *
1680
- * @return A promise which will be resolved when user accepts access to media devices
1684
+ * On failure the previously selected input device (see {@link setInputDevice}) and the
1685
+ * current media-permission state are left untouched, unless the browser reports the
1686
+ * request was actually denied (NotAllowedError / SecurityError), in which case
1687
+ * hasMediaPermission/hasMediaPermissionError are updated to reflect that.
1681
1688
  *
1682
- */
1683
- prepareMedia() {
1689
+ * @param {object} [mediaOptions]
1690
+ * @param {string} [mediaOptions.deviceId] Microphone deviceId (from enumerateDevices); omit or leave
1691
+ * out to select the browser default device, which also resets a device previously chosen with
1692
+ * {@link setInputDevice}
1693
+ * @return {Promise} Resolved when user accepts access to media devices; rejected (never thrown
1694
+ * synchronously) otherwise, e.g. when the browser denies permission or has no mediaDevices API
1695
+ */
1696
+ async prepareMedia(mediaOptions = {}) {
1684
1697
  this.log.info('Get media permission');
1685
- const self = this;
1686
- const constraints = { audio: true, video: false };
1687
- return new Promise(function (resolve, reject) {
1688
- navigator.mediaDevices.getUserMedia(constraints).then(
1689
- () => {
1690
- self._mediaPermission = true;
1691
- self._mediaPermissionError = false;
1692
- self.log.info('Media permission granted');
1693
- resolve();
1694
- },
1695
- (err) => {
1696
- self._mediaPermission = false;
1697
- self._mediaPermissionError = true;
1698
- self.log.error('Denied media permission: ' + err);
1699
- reject(err);
1700
- },
1701
- );
1702
- });
1698
+ const deviceId = (mediaOptions || {}).deviceId || null;
1699
+ const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true;
1700
+ const constraints = { audio: audioConstraints, video: false };
1701
+
1702
+ try {
1703
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
1704
+ // Only the permission is needed here; release the device again.
1705
+ stream.getTracks().forEach((track) => track.stop());
1706
+ this.setInputDevice(deviceId);
1707
+ this._mediaPermission = true;
1708
+ this._mediaPermissionError = false;
1709
+ this.log.info('Media permission granted');
1710
+ } catch (err) {
1711
+ if (err && (err.name === 'NotAllowedError' || err.name === 'SecurityError')) {
1712
+ this._mediaPermission = false;
1713
+ this._mediaPermissionError = true;
1714
+ this.log.error('Denied media permission: ' + err);
1715
+ } else {
1716
+ this.log.error('Unable to get requested media device: ' + err);
1717
+ }
1718
+ throw err;
1719
+ }
1720
+ }
1721
+
1722
+ /**
1723
+ * Select the microphone used for subsequent calls and answers.
1724
+ * @param {string|null} deviceId Microphone deviceId, or null for the browser default
1725
+ */
1726
+ setInputDevice(deviceId) {
1727
+ this._audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true;
1728
+ }
1729
+
1730
+ /**
1731
+ * Current getUserMedia audio constraint (true = default device)
1732
+ */
1733
+ get audioConstraints() {
1734
+ return this._audioConstraints;
1735
+ }
1736
+
1737
+ /**
1738
+ * True when the browser can route audio to a chosen output device (HTMLMediaElement.setSinkId)
1739
+ */
1740
+ get outputDeviceSelectionSupported() {
1741
+ return typeof this._remoteAudioElement.setSinkId === 'function';
1742
+ }
1743
+
1744
+ /**
1745
+ * Route call audio and signal tones to an output device.
1746
+ * @param {string|null} deviceId Output deviceId (from enumerateDevices), or null for the default
1747
+ * @return {Promise<boolean>} false when the browser does not support output selection
1748
+ * @throws Rejects if the browser's HTMLMediaElement.setSinkId() call rejects, e.g. for an
1749
+ * unknown deviceId or one the browser is not allowed to use (NotAllowedError)
1750
+ */
1751
+ async setOutputDevice(deviceId) {
1752
+ if (!this.outputDeviceSelectionSupported) {
1753
+ return false;
1754
+ }
1755
+ const sinkId = deviceId || '';
1756
+ await Promise.all([
1757
+ this._remoteAudioElement.setSinkId(sinkId),
1758
+ this._toneAudioElement.setSinkId(sinkId),
1759
+ ]);
1760
+ return true;
1703
1761
  }
1704
1762
 
1705
1763
  /**
@@ -1735,16 +1793,16 @@ class Phone extends EventEmitter {
1735
1793
  }
1736
1794
 
1737
1795
  // Apply options
1738
- this._options = Object.assign(options, phoneOptions);
1796
+ this._options = Object.assign({}, options, phoneOptions);
1739
1797
 
1740
1798
  this._initToneGenerator();
1741
1799
 
1742
1800
  // Unpack provisioning token if specified
1743
1801
  if (this._options.provisionToken) {
1744
- let pt = null;
1802
+ let pt;
1745
1803
  try {
1746
1804
  pt = JSON.parse(atob(this._options.provisionToken));
1747
- } catch (err) {
1805
+ } catch {
1748
1806
  throw new InvalidOptions('provisionToken', 'Invalid token');
1749
1807
  }
1750
1808
 
@@ -1820,7 +1878,7 @@ class Phone extends EventEmitter {
1820
1878
  );
1821
1879
  }
1822
1880
 
1823
- let wsSocket = null;
1881
+ let wsSocket;
1824
1882
  try {
1825
1883
  wsSocket = new JsSIP.WebSocketInterface(this._options.wssServer);
1826
1884
  } catch (err) {
@@ -1898,8 +1956,11 @@ class Phone extends EventEmitter {
1898
1956
  self._setConnectionState('UNREGISTERED');
1899
1957
  });
1900
1958
 
1901
- this._UA.on('registrationFailed', function (/* data */) {
1902
- self._setConnectionState('REGISTRATION_FAILED');
1959
+ this._UA.on('registrationFailed', function (data) {
1960
+ self._setConnectionState('REGISTRATION_FAILED', {
1961
+ cause: data?.cause,
1962
+ statusCode: data?.response?.status_code,
1963
+ });
1903
1964
  });
1904
1965
 
1905
1966
  this._UA.start();
@@ -1924,6 +1985,11 @@ class Phone extends EventEmitter {
1924
1985
  this._UA.stop();
1925
1986
  delete this._UA;
1926
1987
 
1988
+ // Always mark the phone stopped from here on, even if there is no event client to
1989
+ // disconnect below - otherwise isStarted stays true and a later start() would try to
1990
+ // stop() this same (already torn down) instance again.
1991
+ this._isStarted = false;
1992
+
1927
1993
  // Disconnect event client if needed
1928
1994
  if (!this._eventClient) {
1929
1995
  this.log.debug('Event client not running, not stopping');
@@ -1932,14 +1998,26 @@ class Phone extends EventEmitter {
1932
1998
  try {
1933
1999
  this._eventClient.disconnect();
1934
2000
  delete this._eventClient;
1935
- } catch (e) {
2001
+ } catch {
1936
2002
  this.log.warning('Unable to disconnect event client');
1937
2003
  }
1938
-
1939
- this._isStarted = false;
1940
2004
  this.log.info('Phone client successfully stopped');
1941
2005
  }
1942
2006
 
2007
+ /**
2008
+ * Re-send REGISTER without tearing down the websocket connection or any active calls.
2009
+ * Use this to recover from a REGISTER refresh that failed for a reason other than bad
2010
+ * credentials (e.g. a timeout or a 5xx from the registrar) while the transport itself stayed
2011
+ * up - JsSIP does not retry those on its own. Safe to call during an active call.
2012
+ * @throws NotReady - If phone is not started
2013
+ */
2014
+ register() {
2015
+ if (!this._isStarted) {
2016
+ throw new NotReady('Phone is not started');
2017
+ }
2018
+ this._UA.register();
2019
+ }
2020
+
1943
2021
  /**
1944
2022
  * Start a new call
1945
2023
  * If you have other active calls they will be set on hold
@@ -2023,7 +2101,7 @@ class Phone extends EventEmitter {
2023
2101
  */
2024
2102
  getSessionByUUID(uuid) {
2025
2103
  for (let key in this._activeSessions) {
2026
- if (this._activeSessions.hasOwnProperty(key)) {
2104
+ if (Object.hasOwn(this._activeSessions, key)) {
2027
2105
  if (this._activeSessions[key].uuid === uuid) {
2028
2106
  return this._activeSessions[key];
2029
2107
  }
@@ -2041,6 +2119,27 @@ class Phone extends EventEmitter {
2041
2119
  return this._options;
2042
2120
  }
2043
2121
 
2122
+ /**
2123
+ * Replace the call token used by subsequent calls and answers.
2124
+ * Call tokens expire (30 min); renew via the API and pass the new token here.
2125
+ *
2126
+ * With useCallDataInfoEvent, the callDataInfo subscription keeps the token from start();
2127
+ * call start() again to move it to the new token.
2128
+ *
2129
+ * @param {string} token New call token
2130
+ * @throws NotReady - If phone is not started
2131
+ * @throws InvalidOptions - If token is empty
2132
+ */
2133
+ setCallToken(token) {
2134
+ if (!this._isStarted) {
2135
+ throw new NotReady('Phone is not started');
2136
+ }
2137
+ if (!token) {
2138
+ throw new InvalidOptions('callToken', 'Missing call token');
2139
+ }
2140
+ this._options.callToken = token;
2141
+ }
2142
+
2044
2143
  /**
2045
2144
  * Return list of current sessions
2046
2145
  * @returns [Session]
@@ -2048,7 +2147,7 @@ class Phone extends EventEmitter {
2048
2147
  get sessions() {
2049
2148
  let s = [];
2050
2149
  for (let key in this._activeSessions) {
2051
- if (this._activeSessions.hasOwnProperty(key)) {
2150
+ if (Object.hasOwn(this._activeSessions, key)) {
2052
2151
  s.push(this._activeSessions[key]);
2053
2152
  }
2054
2153
  }
@@ -2106,6 +2205,7 @@ class Phone extends EventEmitter {
2106
2205
  */
2107
2206
  playTone(duration, freq1, freq2 = 0, callback = null) {
2108
2207
  this.log.debug(`Playing tone ${freq1} / ${freq2} for ${duration} ms`);
2208
+ this._resumeToneContext();
2109
2209
 
2110
2210
  this._oscillator1.frequency.value = freq1;
2111
2211
  this._oscillator2.frequency.value = freq2;
@@ -2172,8 +2272,35 @@ class Phone extends EventEmitter {
2172
2272
  }
2173
2273
  }
2174
2274
 
2175
- // Initialize tone generator
2275
+ /**
2276
+ * Resume the Web Audio context behind signal tones (ringing, ringback, busy, DTMF feedback).
2277
+ * Browsers create an AudioContext "suspended" when it is not created during a user gesture
2278
+ * (e.g. a phone started on page load); call this from a user-gesture handler (click, keydown)
2279
+ * so tones are audible. Safe to call before {@link start} and any number of times.
2280
+ * @return {Promise<boolean>} false when the browser has no Web Audio API or resuming failed
2281
+ */
2282
+ async resumeAudio() {
2283
+ try {
2284
+ this._initToneGenerator();
2285
+ } catch {
2286
+ return false;
2287
+ }
2288
+ try {
2289
+ await this._toneContext.resume();
2290
+ return this._toneContext.state === 'running';
2291
+ } catch (err) {
2292
+ this.log.warning('Unable to resume audio context: ' + err);
2293
+ return false;
2294
+ }
2295
+ }
2296
+
2297
+ // Initialize tone generator. One AudioContext per Phone, reused across start()/stop() -
2298
+ // browsers cap the number of live contexts and a new one per start() would leak.
2176
2299
  _initToneGenerator() {
2300
+ if (this._toneContext && this._toneContext.state !== 'closed') {
2301
+ return;
2302
+ }
2303
+
2177
2304
  // Create audio context
2178
2305
  const AudioContext = window.AudioContext || window.webkitAudioContext || false;
2179
2306
  if (!AudioContext) {
@@ -2181,6 +2308,7 @@ class Phone extends EventEmitter {
2181
2308
  }
2182
2309
 
2183
2310
  const ctx = new AudioContext();
2311
+ this._toneContext = ctx;
2184
2312
  this._toneStreamNode = ctx.createMediaStreamDestination();
2185
2313
  this._toneAudioElement.srcObject = this._toneStreamNode.stream;
2186
2314
 
@@ -2200,6 +2328,14 @@ class Phone extends EventEmitter {
2200
2328
  this._oscillator2.start();
2201
2329
  }
2202
2330
 
2331
+ // Best effort: a context created outside a user gesture is suspended until resumed.
2332
+ _resumeToneContext() {
2333
+ const ctx = this._toneContext;
2334
+ if (ctx && ctx.state === 'suspended' && typeof ctx.resume === 'function') {
2335
+ ctx.resume().catch((err) => this.log.debug('Audio context resume refused: ' + err));
2336
+ }
2337
+ }
2338
+
2203
2339
  /**
2204
2340
  * Hold all active calls
2205
2341
  * @throws NotAllowed If there are connection calls
@@ -2213,7 +2349,7 @@ class Phone extends EventEmitter {
2213
2349
 
2214
2350
  // Iterate through all active calls and hold them
2215
2351
  for (let key in this._activeSessions) {
2216
- if (this._activeSessions.hasOwnProperty(key)) {
2352
+ if (Object.hasOwn(this._activeSessions, key)) {
2217
2353
  let session = this._activeSessions[key];
2218
2354
  if (session.state === 'ACTIVE') {
2219
2355
  session.hold();
@@ -2287,7 +2423,7 @@ class Phone extends EventEmitter {
2287
2423
  // Return true if there are calls currently connecting
2288
2424
  _checkForConnectingCalls() {
2289
2425
  for (let key in this._activeSessions) {
2290
- if (this._activeSessions.hasOwnProperty(key)) {
2426
+ if (Object.hasOwn(this._activeSessions, key)) {
2291
2427
  let session = this._activeSessions[key];
2292
2428
  if (
2293
2429
  session.state === 'INITIALIZING' ||
@@ -2312,17 +2448,23 @@ class Phone extends EventEmitter {
2312
2448
  * @property {string} REGISTRATION_FAILED Registration failed
2313
2449
  */
2314
2450
  // Set connection state and emit event
2315
- _setConnectionState(state) {
2451
+ _setConnectionState(state, extra = {}) {
2316
2452
  /**
2317
2453
  * @event connectionStateChange
2318
2454
  * @type {object}
2319
2455
  * @param {CONNECTION_STATE} state - The current connection state
2456
+ * @param {string} [cause] - For REGISTRATION_FAILED only: JsSIP's registrationFailed
2457
+ * cause, e.g. 'Connection Error', 'Request Timeout' (network/transport issues, JsSIP
2458
+ * will keep retrying on its own), 'Authentication Error' (401), or 'Rejected' (403 -
2459
+ * account disabled or credentials rejected by the registrar)
2460
+ * @param {number} [statusCode] - For REGISTRATION_FAILED only: the SIP status code of
2461
+ * the failed REGISTER response, when JsSIP received one (e.g. 401, 403)
2320
2462
  */
2321
2463
 
2322
2464
  this.log.info('Connection state: ' + state);
2323
2465
  this._connectionState = state;
2324
2466
  try {
2325
- this.emit('connectionStateChange', { state: state });
2467
+ this.emit('connectionStateChange', { state: state, ...extra });
2326
2468
  } catch (e) {
2327
2469
  this.log.error('Exception in connectionStateChange event: ' + e);
2328
2470
  }
@@ -2370,18 +2512,15 @@ class Phone extends EventEmitter {
2370
2512
  * @param {string} effectiveCidNameStr - Phone number name used as a-number for call
2371
2513
  * @param {boolean} callPrivacy - Call privacy used for call (number hidden)
2372
2514
  * @param {number} hangupCauseQ850 - Q.850 hangup cause code
2373
- * @param {Object[]} recordings - List of recordings done for this call
2374
- * @param {number} recordings[].startTime - Time recording was started
2375
- * @param {number} recordings[].length - Recording length in milliseconds
2376
- * @param {number} recordings[].size - Recording size (bytes)
2377
- * @param {number} recordings[].urlPlayer - URL for recording with player
2378
- * @param {number} recordings[].url - URL for recording without player
2379
- * @param {number} recordings[].retentionDate - Date recording will be automatically deleted
2380
2515
  */
2381
2516
 
2382
2517
  let e;
2383
2518
  e = camelcaseKeys(eventData);
2384
- e.recordings = camelcaseKeys(eventData.recordings);
2519
+ // The call data event no longer carries recordings. Uploads are
2520
+ // asynchronous, so the array was almost always empty by the time the CDR
2521
+ // was processed, and the event cannot be re-fired once a recording lands
2522
+ // because call_token is never persisted on the CDR. A dedicated
2523
+ // "recording ready" event will replace it.
2385
2524
 
2386
2525
  // Event all duration fields are in seconds, convert to millisecs
2387
2526
  e.duration = e.duration * 1000;
@@ -2474,7 +2613,7 @@ class Session extends EventEmitter {
2474
2613
  this.destinationNumber = number;
2475
2614
 
2476
2615
  let options = {
2477
- mediaConstraints: { audio: true, video: false },
2616
+ mediaConstraints: { audio: this._phone.audioConstraints, video: false },
2478
2617
  pcConfig: {
2479
2618
  iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
2480
2619
  },
@@ -2671,7 +2810,7 @@ class Session extends EventEmitter {
2671
2810
  self.log.info(`Answer call timeout after ${self._answerCallTimeout} seconds`);
2672
2811
  try {
2673
2812
  self.hangup();
2674
- } catch (e) {
2813
+ } catch {
2675
2814
  self.log.info('No active session, ignoring answer call timeout');
2676
2815
  }
2677
2816
  }, self._answerCallTimeout * 1000);
@@ -2828,7 +2967,7 @@ class Session extends EventEmitter {
2828
2967
  */
2829
2968
  answer(metaInfo = null) {
2830
2969
  let options = {
2831
- mediaConstraints: { audio: true, video: false },
2970
+ mediaConstraints: { audio: this._phone.audioConstraints, video: false },
2832
2971
  pcConfig: {
2833
2972
  iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }],
2834
2973
  },
@@ -3742,4 +3881,4 @@ class Session extends EventEmitter {
3742
3881
  }
3743
3882
  }
3744
3883
 
3745
- export { EventClient, EventServiceNotConnected, InvalidMediaDevice, InvalidOptions, InvalidParameter, LOGLVL$1 as LOGLVL, LogHandler, MediaNotPrepared, NoActiveSession, NotAllowed, NotReady, Phone, Request, Requestor, Session, UnableToConnect, browserIsChrome, browserIsEdge, browserIsFirefox, browserIsOpera, browserIsSafari, buildCustomHeaders, compareObjects, createDurationString, createUUID };
3884
+ export { EventClient, EventServiceNotConnected, InvalidMediaDevice, InvalidOptions, InvalidParameter, LOGLVL, LogHandler, MediaNotPrepared, NoActiveSession, NotAllowed, NotReady, Phone, Request, Requestor, Session, UnableToConnect, browserIsChrome, browserIsEdge, browserIsFirefox, browserIsOpera, browserIsSafari, buildCustomHeaders, compareObjects, createDurationString, createUUID };