@sentdm/voice-client 0.0.0-stage → 0.1.1
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/CHANGELOG.md +38 -0
- package/LICENSE +201 -0
- package/README.md +283 -2
- package/adapter/loader.d.mts +3 -0
- package/adapter/loader.d.mts.map +1 -0
- package/adapter/loader.d.ts +3 -0
- package/adapter/loader.d.ts.map +1 -0
- package/adapter/loader.js +6 -0
- package/adapter/loader.js.map +1 -0
- package/adapter/loader.mjs +3 -0
- package/adapter/loader.mjs.map +1 -0
- package/adapter/sinch.d.mts +3 -0
- package/adapter/sinch.d.mts.map +1 -0
- package/adapter/sinch.d.ts +3 -0
- package/adapter/sinch.d.ts.map +1 -0
- package/adapter/sinch.js +280 -0
- package/adapter/sinch.js.map +1 -0
- package/adapter/sinch.mjs +276 -0
- package/adapter/sinch.mjs.map +1 -0
- package/adapter/types.d.mts +78 -0
- package/adapter/types.d.mts.map +1 -0
- package/adapter/types.d.ts +78 -0
- package/adapter/types.d.ts.map +1 -0
- package/adapter/types.js +3 -0
- package/adapter/types.js.map +1 -0
- package/adapter/types.mjs +2 -0
- package/adapter/types.mjs.map +1 -0
- package/audio.d.mts +42 -0
- package/audio.d.mts.map +1 -0
- package/audio.d.ts +42 -0
- package/audio.d.ts.map +1 -0
- package/audio.js +125 -0
- package/audio.js.map +1 -0
- package/audio.mjs +121 -0
- package/audio.mjs.map +1 -0
- package/call.d.mts +60 -0
- package/call.d.mts.map +1 -0
- package/call.d.ts +60 -0
- package/call.d.ts.map +1 -0
- package/call.js +84 -0
- package/call.js.map +1 -0
- package/call.mjs +80 -0
- package/call.mjs.map +1 -0
- package/client.d.mts +111 -0
- package/client.d.mts.map +1 -0
- package/client.d.ts +111 -0
- package/client.d.ts.map +1 -0
- package/client.js +365 -0
- package/client.js.map +1 -0
- package/client.mjs +361 -0
- package/client.mjs.map +1 -0
- package/errors.d.mts +2 -0
- package/errors.d.mts.map +1 -0
- package/errors.d.ts +2 -0
- package/errors.d.ts.map +1 -0
- package/errors.js +16 -0
- package/errors.js.map +1 -0
- package/errors.mjs +4 -0
- package/errors.mjs.map +1 -0
- package/index.d.mts +4 -0
- package/index.d.mts.map +1 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -0
- package/index.js +18 -0
- package/index.js.map +1 -0
- package/index.mjs +4 -0
- package/index.mjs.map +1 -0
- package/internal/tslib.js +75 -0
- package/internal/tslib.mjs +17 -0
- package/invite.d.mts +28 -0
- package/invite.d.mts.map +1 -0
- package/invite.d.ts +28 -0
- package/invite.d.ts.map +1 -0
- package/invite.js +64 -0
- package/invite.js.map +1 -0
- package/invite.mjs +60 -0
- package/invite.mjs.map +1 -0
- package/package.json +41 -3
- package/src/adapter/loader.ts +4 -0
- package/src/adapter/sinch.ts +333 -0
- package/src/adapter/types.ts +63 -0
- package/src/audio.ts +174 -0
- package/src/call.ts +145 -0
- package/src/client.ts +582 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +14 -0
- package/src/invite.ts +75 -0
- package/src/sw.js +14 -0
- package/src/tsconfig.json +11 -0
- package/src/version.ts +1 -0
- package/sw.js +14 -0
- package/version.d.mts +2 -0
- package/version.d.mts.map +1 -0
- package/version.d.ts +2 -0
- package/version.d.ts.map +1 -0
- package/version.js +5 -0
- package/version.js.map +1 -0
- package/version.mjs +2 -0
- package/version.mjs.map +1 -0
package/src/audio.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
|
|
2
|
+
import { CapabilityUnsupportedError, TypedEmitter, type Log } from '@sentdm/voice-client-core';
|
|
3
|
+
|
|
4
|
+
export interface AudioControllerEvents {
|
|
5
|
+
deviceChanged: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface AudioAdapter {
|
|
9
|
+
setInputDevice(deviceId: string): Promise<void>;
|
|
10
|
+
setOutputDevice(deviceId: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AudioLifecycle {
|
|
14
|
+
loaded(adapter: AudioAdapter): void;
|
|
15
|
+
callStarted(): void;
|
|
16
|
+
destroyed(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AudioProvider {
|
|
20
|
+
check(): void;
|
|
21
|
+
onLifecycle(lifecycle: AudioLifecycle): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AudioController extends TypedEmitter<AudioControllerEvents> {
|
|
25
|
+
#log: Log;
|
|
26
|
+
#provider: AudioProvider;
|
|
27
|
+
#adapter: AudioAdapter | undefined;
|
|
28
|
+
#pendingInput: string | undefined;
|
|
29
|
+
#pendingOutput: string | undefined;
|
|
30
|
+
#listening = false;
|
|
31
|
+
#destroyed = false;
|
|
32
|
+
#deviceChanged = () => this.emit('deviceChanged');
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
log: Log,
|
|
36
|
+
inputDeviceId: string | undefined,
|
|
37
|
+
outputDeviceId: string | undefined,
|
|
38
|
+
provider: AudioProvider,
|
|
39
|
+
) {
|
|
40
|
+
super();
|
|
41
|
+
this.#log = log;
|
|
42
|
+
this.#pendingInput = inputDeviceId;
|
|
43
|
+
this.#pendingOutput = outputDeviceId;
|
|
44
|
+
this.#provider = provider;
|
|
45
|
+
provider.onLifecycle({
|
|
46
|
+
loaded: (adapter) => this.#load(adapter),
|
|
47
|
+
callStarted: () => this.#retryOutput(),
|
|
48
|
+
destroyed: () => this.#destroy(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lists the microphones. Browsers leave labels and ids empty, and may list only one, until the
|
|
54
|
+
* user has allowed microphone access in the page, which the first call asks for.
|
|
55
|
+
*/
|
|
56
|
+
inputDevices(): Promise<MediaDeviceInfo[]> {
|
|
57
|
+
return this.#list('audioinput');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Lists the speakers, which browsers also hide until the user has allowed microphone access. */
|
|
61
|
+
outputDevices(): Promise<MediaDeviceInfo[]> {
|
|
62
|
+
return this.#list('audiooutput');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Switches the microphone of the call in progress and of later calls, falling back to the
|
|
67
|
+
* default one while the device is missing. Called before `register()`, it takes effect from `register()`.
|
|
68
|
+
*/
|
|
69
|
+
async setInputDevice(deviceId: string): Promise<void> {
|
|
70
|
+
this.#provider.check();
|
|
71
|
+
if (!this.#adapter) {
|
|
72
|
+
this.#pendingInput = deviceId;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await this.#adapter.setInputDevice(deviceId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Plays calls through this speaker. Throws `CapabilityUnsupportedError` in browsers that cannot
|
|
80
|
+
* choose the audio output. Called before `register()`, it takes effect from `register()`.
|
|
81
|
+
*/
|
|
82
|
+
async setOutputDevice(deviceId: string): Promise<void> {
|
|
83
|
+
this.#provider.check();
|
|
84
|
+
if (!('setSinkId' in HTMLMediaElement.prototype)) {
|
|
85
|
+
throw new CapabilityUnsupportedError({
|
|
86
|
+
message: 'Choosing the audio output device is not supported by this browser.',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (!this.#adapter) {
|
|
90
|
+
this.#pendingOutput = deviceId;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.#pendingOutput = undefined;
|
|
94
|
+
await this.#adapter.setOutputDevice(deviceId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
override on<E extends keyof AudioControllerEvents>(event: E, listener: AudioControllerEvents[E]): this {
|
|
98
|
+
this.#listen();
|
|
99
|
+
return super.on(event, listener);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override once<E extends keyof AudioControllerEvents>(event: E, listener: AudioControllerEvents[E]): this {
|
|
103
|
+
this.#listen();
|
|
104
|
+
return super.once(event, listener);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async #list(kind: MediaDeviceKind): Promise<MediaDeviceInfo[]> {
|
|
108
|
+
if (!navigator.mediaDevices) {
|
|
109
|
+
throw new CapabilityUnsupportedError({
|
|
110
|
+
message: 'Audio devices can only be listed in a secure context (HTTPS or localhost).',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
114
|
+
return devices.filter((device) => device.kind === kind);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#load(adapter: AudioAdapter): void {
|
|
118
|
+
this.#adapter = adapter;
|
|
119
|
+
const input = this.#pendingInput;
|
|
120
|
+
const output = this.#pendingOutput;
|
|
121
|
+
|
|
122
|
+
if (input !== undefined) {
|
|
123
|
+
adapter
|
|
124
|
+
.setInputDevice(input)
|
|
125
|
+
.catch((error: unknown) =>
|
|
126
|
+
this.#log.warn(
|
|
127
|
+
`The audio input device ${input} could not be used, using the default device: ${error}`,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
void this.#checkInput(input);
|
|
131
|
+
}
|
|
132
|
+
if (output !== undefined) {
|
|
133
|
+
adapter.setOutputDevice(output).then(
|
|
134
|
+
() => (this.#pendingOutput = undefined),
|
|
135
|
+
(error: unknown) =>
|
|
136
|
+
this.#log.info(
|
|
137
|
+
`The audio output device ${output} could not be used yet, retrying on the first call: ${error}`,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async #checkInput(deviceId: string): Promise<void> {
|
|
144
|
+
const ids = (await this.inputDevices().catch(() => [])).map((device) => device.deviceId);
|
|
145
|
+
if (ids.some(Boolean) && !ids.includes(deviceId)) {
|
|
146
|
+
this.#log.warn(`The audio input device ${deviceId} was not found, using the default device.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#retryOutput(): void {
|
|
151
|
+
const output = this.#pendingOutput;
|
|
152
|
+
if (output === undefined || !this.#adapter) return;
|
|
153
|
+
|
|
154
|
+
this.#pendingOutput = undefined;
|
|
155
|
+
this.#adapter
|
|
156
|
+
.setOutputDevice(output)
|
|
157
|
+
.catch((error: unknown) =>
|
|
158
|
+
this.#log.warn(
|
|
159
|
+
`The audio output device ${output} could not be used, using the default device: ${error}`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#listen(): void {
|
|
165
|
+
if (this.#listening || this.#destroyed || !navigator.mediaDevices) return;
|
|
166
|
+
navigator.mediaDevices.addEventListener('devicechange', this.#deviceChanged);
|
|
167
|
+
this.#listening = true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#destroy(): void {
|
|
171
|
+
this.#destroyed = true;
|
|
172
|
+
if (this.#listening) navigator.mediaDevices.removeEventListener('devicechange', this.#deviceChanged);
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/call.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type CallStats, type SentVoiceError, TypedEmitter } from '@sentdm/voice-client-core';
|
|
2
|
+
|
|
3
|
+
export type { CallStats };
|
|
4
|
+
|
|
5
|
+
export type CallState =
|
|
6
|
+
| 'initiated'
|
|
7
|
+
| 'ringing'
|
|
8
|
+
| 'answered'
|
|
9
|
+
| 'connected'
|
|
10
|
+
| 'reconnecting'
|
|
11
|
+
| 'completed'
|
|
12
|
+
| 'failed'
|
|
13
|
+
| 'busy'
|
|
14
|
+
| 'noAnswer';
|
|
15
|
+
|
|
16
|
+
export type Address =
|
|
17
|
+
| { kind: 'user'; identity: string }
|
|
18
|
+
| { kind: 'number'; number: string }
|
|
19
|
+
| { kind: 'conference'; name: string };
|
|
20
|
+
|
|
21
|
+
export interface DisconnectInfo {
|
|
22
|
+
state: 'completed' | 'failed' | 'busy' | 'noAnswer';
|
|
23
|
+
error?: SentVoiceError;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface QualityWarning {
|
|
27
|
+
metric: 'jitter' | 'packetLoss' | 'rtt';
|
|
28
|
+
cleared: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CallEvents {
|
|
32
|
+
ringing: () => void;
|
|
33
|
+
answered: () => void;
|
|
34
|
+
connected: () => void;
|
|
35
|
+
reconnecting: () => void;
|
|
36
|
+
reconnected: () => void;
|
|
37
|
+
disconnected: (info: DisconnectInfo) => void;
|
|
38
|
+
muteChanged: (isMuted: boolean) => void;
|
|
39
|
+
qualityWarning: (warning: QualityWarning) => void;
|
|
40
|
+
error: (error: SentVoiceError) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CallProvider {
|
|
44
|
+
hangup(): Promise<void>;
|
|
45
|
+
mute(muted: boolean): void;
|
|
46
|
+
sendDigits(digits: string): void;
|
|
47
|
+
getStats(): Promise<CallStats>;
|
|
48
|
+
onUpdate(listener: (state: Exclude<CallState, 'initiated'>, error?: SentVoiceError) => void): void;
|
|
49
|
+
onQualityWarning(listener: (warning: QualityWarning) => void): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isEnded = (state: CallState): state is DisconnectInfo['state'] =>
|
|
53
|
+
state === 'completed' || state === 'failed' || state === 'busy' || state === 'noAnswer';
|
|
54
|
+
|
|
55
|
+
export class Call extends TypedEmitter<CallEvents> {
|
|
56
|
+
readonly id: string;
|
|
57
|
+
readonly direction: 'inbound' | 'outbound';
|
|
58
|
+
readonly from: Address;
|
|
59
|
+
readonly to: Address;
|
|
60
|
+
#provider: CallProvider;
|
|
61
|
+
#state: CallState;
|
|
62
|
+
#isMuted = false;
|
|
63
|
+
#startedAt: number | undefined;
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
id: string,
|
|
67
|
+
direction: 'inbound' | 'outbound',
|
|
68
|
+
from: Address,
|
|
69
|
+
to: Address,
|
|
70
|
+
provider: CallProvider,
|
|
71
|
+
) {
|
|
72
|
+
super();
|
|
73
|
+
this.id = id;
|
|
74
|
+
this.direction = direction;
|
|
75
|
+
this.from = from;
|
|
76
|
+
this.to = to;
|
|
77
|
+
this.#provider = provider;
|
|
78
|
+
this.#state = direction === 'inbound' ? 'ringing' : 'initiated';
|
|
79
|
+
provider.onUpdate((state, error) => this.#update(state, error));
|
|
80
|
+
provider.onQualityWarning((warning) => this.emit('qualityWarning', warning));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get state(): CallState {
|
|
84
|
+
return this.#state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get isMuted(): boolean {
|
|
88
|
+
return this.#isMuted;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** When the call connected, in epoch milliseconds. */
|
|
92
|
+
get startedAt(): number | undefined {
|
|
93
|
+
return this.#startedAt;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Hangs up; does nothing once the call has ended. */
|
|
97
|
+
async disconnect(): Promise<void> {
|
|
98
|
+
if (isEnded(this.#state)) return;
|
|
99
|
+
await this.#provider.hangup();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
hangup(): Promise<void> {
|
|
103
|
+
return this.disconnect();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Mutes or unmutes the microphone, toggling when called without an argument. */
|
|
107
|
+
mute(muted = !this.#isMuted): void {
|
|
108
|
+
if (muted === this.#isMuted) return;
|
|
109
|
+
this.#provider.mute(muted);
|
|
110
|
+
this.#isMuted = muted;
|
|
111
|
+
this.emit('muteChanged', muted);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
sendDigits(digits: string): void {
|
|
115
|
+
this.#provider.sendDigits(digits);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getStats(): Promise<CallStats> {
|
|
119
|
+
return this.#provider.getStats();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#update(state: Exclude<CallState, 'initiated'>, error?: SentVoiceError): void {
|
|
123
|
+
const previous = this.#state;
|
|
124
|
+
if (
|
|
125
|
+
isEnded(previous) ||
|
|
126
|
+
(state === 'ringing' && previous !== 'initiated') ||
|
|
127
|
+
(state === 'answered' && previous !== 'initiated' && previous !== 'ringing') ||
|
|
128
|
+
(state === 'connected' && previous === 'connected') ||
|
|
129
|
+
(state === 'reconnecting' && previous !== 'connected')
|
|
130
|
+
) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.#state = state;
|
|
135
|
+
if (isEnded(state)) {
|
|
136
|
+
if (error) this.emit('error', error);
|
|
137
|
+
this.emit('disconnected', error ? { state, error } : { state });
|
|
138
|
+
} else if (state === 'connected' && previous === 'reconnecting') {
|
|
139
|
+
this.emit('reconnected');
|
|
140
|
+
} else {
|
|
141
|
+
if (state === 'connected') this.#startedAt = Date.now();
|
|
142
|
+
this.emit(state);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|