whepts 1.0.1 → 1.0.2
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/QWEN.md +118 -0
- package/dist/errors.d.ts +21 -0
- package/dist/index.d.ts +4 -196
- package/dist/index.js +1 -1
- package/dist/utils/flow-check.d.ts +29 -0
- package/dist/utils/sdp.d.ts +43 -0
- package/dist/utils/webrtc.d.ts +21 -0
- package/dist/whep.d.ts +117 -0
- package/package.json +6 -2
- package/src/errors.ts +27 -0
- package/src/index.ts +4 -824
- package/src/utils/flow-check.ts +73 -0
- package/src/utils/sdp.ts +253 -0
- package/src/utils/webrtc.ts +123 -0
- package/src/whep.ts +404 -0
package/src/index.ts
CHANGED
|
@@ -1,826 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ErrorTypes, WebRTCError } from './errors'
|
|
2
|
+
import WebRTCWhep, { Conf } from './whep'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
* Configuration interface for WebRTCWhep.
|
|
5
|
-
*/
|
|
6
|
-
export interface Conf {
|
|
7
|
-
/** Absolute URL of the WHEP endpoint */
|
|
8
|
-
url: string
|
|
9
|
-
/** Media player container */
|
|
10
|
-
container: HTMLMediaElement
|
|
11
|
-
/** Username for authentication */
|
|
12
|
-
user?: string
|
|
13
|
-
/** Password for authentication */
|
|
14
|
-
pass?: string
|
|
15
|
-
/** Token for authentication */
|
|
16
|
-
token?: string
|
|
17
|
-
/** ice server list */
|
|
18
|
-
iceServers?: RTCIceServer[]
|
|
19
|
-
/** Called when there's an error */
|
|
20
|
-
onError?: (err: WebRTCError) => void
|
|
21
|
-
}
|
|
4
|
+
export { Conf, ErrorTypes, WebRTCError }
|
|
22
5
|
|
|
23
|
-
|
|
24
|
-
declare global {
|
|
25
|
-
interface RTCConfiguration {
|
|
26
|
-
sdpSemantics?: 'plan-b' | 'unified-plan'
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface RTCIceServer {
|
|
30
|
-
credentialType?: 'password'
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Type for parsed offer data */
|
|
35
|
-
interface ParsedOffer {
|
|
36
|
-
iceUfrag: string
|
|
37
|
-
icePwd: string
|
|
38
|
-
medias: string[]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 错误类型
|
|
43
|
-
*/
|
|
44
|
-
export type ErrorType = string
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 错误类型常量
|
|
48
|
-
*/
|
|
49
|
-
export const ErrorTypes = {
|
|
50
|
-
SIGNAL_ERROR: 'SignalError', // 信令异常
|
|
51
|
-
STATE_ERROR: 'StateError', // 状态异常
|
|
52
|
-
NETWORK_ERROR: 'NetworkError',
|
|
53
|
-
MEDIA_ERROR: 'MediaError',
|
|
54
|
-
OTHER_ERROR: 'OtherError',
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* 错误
|
|
59
|
-
*/
|
|
60
|
-
export class WebRTCError extends Error {
|
|
61
|
-
type: ErrorType
|
|
62
|
-
|
|
63
|
-
constructor(type: ErrorType, message: string, options?: ErrorOptions) {
|
|
64
|
-
super(message, options)
|
|
65
|
-
this.type = type
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** WebRTC/WHEP reader. */
|
|
70
|
-
export default class WebRTCWhep {
|
|
71
|
-
private retryPause: number = 2000
|
|
72
|
-
private conf: Conf
|
|
73
|
-
private state: 'getting_codecs' | 'running' | 'restarting' | 'closed' | 'failed'
|
|
74
|
-
private restartTimeout?: NodeJS.Timeout
|
|
75
|
-
private pc?: RTCPeerConnection
|
|
76
|
-
private offerData?: ParsedOffer
|
|
77
|
-
private sessionUrl?: string
|
|
78
|
-
private queuedCandidates: RTCIceCandidate[] = []
|
|
79
|
-
private nonAdvertisedCodecs: string[] = []
|
|
80
|
-
private container: HTMLMediaElement
|
|
81
|
-
private observer: VisibilityObserver
|
|
82
|
-
private stream?: MediaStream
|
|
83
|
-
/**
|
|
84
|
-
* 断连重试参数
|
|
85
|
-
*/
|
|
86
|
-
private checkInterval: number = 5000
|
|
87
|
-
private lastBytesReceived: number = 0
|
|
88
|
-
private checkTimer?: NodeJS.Timeout
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Create a WebRTCWhep.
|
|
92
|
-
* @param {Conf} conf - Configuration.
|
|
93
|
-
*/
|
|
94
|
-
constructor(conf: Conf) {
|
|
95
|
-
this.conf = conf
|
|
96
|
-
this.state = 'getting_codecs'
|
|
97
|
-
this.container = conf.container
|
|
98
|
-
this.observer = new VisibilityObserver()
|
|
99
|
-
this.getNonAdvertisedCodecs()
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 媒体是否正常
|
|
104
|
-
*/
|
|
105
|
-
get isRunning(): boolean {
|
|
106
|
-
return this.state === 'running'
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Close the reader and all its resources.
|
|
111
|
-
*/
|
|
112
|
-
close(): void {
|
|
113
|
-
this.state = 'closed'
|
|
114
|
-
this.pc?.close()
|
|
115
|
-
|
|
116
|
-
this.observer.stop()
|
|
117
|
-
this.stopFlowCheck()
|
|
118
|
-
if (this.restartTimeout) {
|
|
119
|
-
clearTimeout(this.restartTimeout)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Check if the browser supports a non-advertised codec.
|
|
125
|
-
*/
|
|
126
|
-
private static async supportsNonAdvertisedCodec(codec: string, fmtp?: string): Promise<boolean> {
|
|
127
|
-
return new Promise((resolve) => {
|
|
128
|
-
const pc = new RTCPeerConnection({ iceServers: [] })
|
|
129
|
-
const mediaType = 'audio'
|
|
130
|
-
let payloadType = ''
|
|
131
|
-
|
|
132
|
-
pc.addTransceiver(mediaType, { direction: 'recvonly' })
|
|
133
|
-
pc.createOffer()
|
|
134
|
-
.then((offer) => {
|
|
135
|
-
if (!offer.sdp) {
|
|
136
|
-
throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'SDP not present')
|
|
137
|
-
}
|
|
138
|
-
if (offer.sdp.includes(` ${codec}`)) {
|
|
139
|
-
// codec is advertised, there's no need to add it manually
|
|
140
|
-
throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'already present')
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const sections = offer.sdp.split(`m=${mediaType}`)
|
|
144
|
-
|
|
145
|
-
const payloadTypes = sections
|
|
146
|
-
.slice(1)
|
|
147
|
-
.map(s => s.split('\r\n')[0].split(' ').slice(3))
|
|
148
|
-
.reduce((prev, cur) => [...prev, ...cur], [])
|
|
149
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
150
|
-
|
|
151
|
-
const lines = sections[1].split('\r\n')
|
|
152
|
-
lines[0] += ` ${payloadType}`
|
|
153
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} ${codec}`)
|
|
154
|
-
if (fmtp !== undefined) {
|
|
155
|
-
lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} ${fmtp}`)
|
|
156
|
-
}
|
|
157
|
-
sections[1] = lines.join('\r\n')
|
|
158
|
-
offer.sdp = sections.join(`m=${mediaType}`)
|
|
159
|
-
return pc.setLocalDescription(offer)
|
|
160
|
-
})
|
|
161
|
-
.then(() =>
|
|
162
|
-
pc.setRemoteDescription(
|
|
163
|
-
new RTCSessionDescription({
|
|
164
|
-
type: 'answer',
|
|
165
|
-
sdp:
|
|
166
|
-
`v=0\r\n`
|
|
167
|
-
+ `o=- 6539324223450680508 0 IN IP4 0.0.0.0\r\n`
|
|
168
|
-
+ `s=-\r\n`
|
|
169
|
-
+ `t=0 0\r\n`
|
|
170
|
-
+ `a=fingerprint:sha-256 0D:9F:78:15:42:B5:4B:E6:E2:94:3E:5B:37:78:E1:4B:54:59:A3:36:3A:E5:05:EB:27:EE:8F:D2:2D:41:29:25\r\n`
|
|
171
|
-
+ `m=${mediaType} 9 UDP/TLS/RTP/SAVPF ${payloadType}\r\n`
|
|
172
|
-
+ `c=IN IP4 0.0.0.0\r\n`
|
|
173
|
-
+ `a=ice-pwd:7c3bf4770007e7432ee4ea4d697db675\r\n`
|
|
174
|
-
+ `a=ice-ufrag:29e036dc\r\n`
|
|
175
|
-
+ `a=sendonly\r\n`
|
|
176
|
-
+ `a=rtcp-mux\r\n`
|
|
177
|
-
+ `a=rtpmap:${payloadType} ${codec}\r\n${fmtp !== undefined ? `a=fmtp:${payloadType} ${fmtp}\r\n` : ''}`,
|
|
178
|
-
}),
|
|
179
|
-
),
|
|
180
|
-
)
|
|
181
|
-
.then(() => resolve(true))
|
|
182
|
-
.catch(() => resolve(false))
|
|
183
|
-
.finally(() => pc.close())
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Unquote a credential string.
|
|
189
|
-
*/
|
|
190
|
-
private static unquoteCredential(v: string): string {
|
|
191
|
-
return JSON.parse(`"${v}"`)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Convert Link header to iceServers array.
|
|
196
|
-
*/
|
|
197
|
-
private static linkToIceServers(links: string | null): RTCIceServer[] {
|
|
198
|
-
if (links) {
|
|
199
|
-
return links.split(', ').map((link) => {
|
|
200
|
-
const m = link.match(
|
|
201
|
-
/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i,
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
if (!m) {
|
|
205
|
-
throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Invalid ICE server link format')
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const ret: RTCIceServer = {
|
|
209
|
-
urls: [m[1]],
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (m[3]) {
|
|
213
|
-
ret.username = WebRTCWhep.unquoteCredential(m[3])
|
|
214
|
-
ret.credential = WebRTCWhep.unquoteCredential(m[4])
|
|
215
|
-
ret.credentialType = 'password'
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return ret
|
|
219
|
-
})
|
|
220
|
-
}
|
|
221
|
-
return []
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Parse an offer SDP into iceUfrag, icePwd, and medias.
|
|
226
|
-
*/
|
|
227
|
-
private static parseOffer(sdp: string): ParsedOffer {
|
|
228
|
-
const ret: ParsedOffer = {
|
|
229
|
-
iceUfrag: '',
|
|
230
|
-
icePwd: '',
|
|
231
|
-
medias: [],
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
for (const line of sdp.split('\r\n')) {
|
|
235
|
-
if (line.startsWith('m=')) {
|
|
236
|
-
ret.medias.push(line.slice('m='.length))
|
|
237
|
-
}
|
|
238
|
-
else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) {
|
|
239
|
-
ret.iceUfrag = line.slice('a=ice-ufrag:'.length)
|
|
240
|
-
}
|
|
241
|
-
else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) {
|
|
242
|
-
ret.icePwd = line.slice('a=ice-pwd:'.length)
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return ret
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Reserve a payload type.
|
|
251
|
-
*/
|
|
252
|
-
private static reservePayloadType(payloadTypes: string[]): string {
|
|
253
|
-
// everything is valid between 30 and 127, except for interval between 64 and 95
|
|
254
|
-
// https://chromium.googlesource.com/external/webrtc/+/refs/heads/master/call/payload_type.h#29
|
|
255
|
-
for (let i = 30; i <= 127; i++) {
|
|
256
|
-
if ((i <= 63 || i >= 96) && !payloadTypes.includes(i.toString())) {
|
|
257
|
-
const pl = i.toString()
|
|
258
|
-
payloadTypes.push(pl)
|
|
259
|
-
return pl
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'unable to find a free payload type')
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Enable stereo PCMA/PCMU codecs.
|
|
267
|
-
*/
|
|
268
|
-
private static enableStereoPcmau(payloadTypes: string[], section: string): string {
|
|
269
|
-
const lines = section.split('\r\n')
|
|
270
|
-
|
|
271
|
-
let payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
272
|
-
lines[0] += ` ${payloadType}`
|
|
273
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} PCMU/8000/2`)
|
|
274
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
275
|
-
|
|
276
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
277
|
-
lines[0] += ` ${payloadType}`
|
|
278
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} PCMA/8000/2`)
|
|
279
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
280
|
-
|
|
281
|
-
return lines.join('\r\n')
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Enable multichannel Opus codec.
|
|
286
|
-
*/
|
|
287
|
-
private static enableMultichannelOpus(payloadTypes: string[], section: string): string {
|
|
288
|
-
const lines = section.split('\r\n')
|
|
289
|
-
|
|
290
|
-
let payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
291
|
-
lines[0] += ` ${payloadType}`
|
|
292
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/3`)
|
|
293
|
-
lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} channel_mapping=0,2,1;num_streams=2;coupled_streams=1`)
|
|
294
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
295
|
-
|
|
296
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
297
|
-
lines[0] += ` ${payloadType}`
|
|
298
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/4`)
|
|
299
|
-
lines.splice(lines.length - 1, 0, `a=fmtp:${payloadType} channel_mapping=0,1,2,3;num_streams=2;coupled_streams=2`)
|
|
300
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
301
|
-
|
|
302
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
303
|
-
lines[0] += ` ${payloadType}`
|
|
304
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/5`)
|
|
305
|
-
lines.splice(
|
|
306
|
-
lines.length - 1,
|
|
307
|
-
0,
|
|
308
|
-
`a=fmtp:${payloadType} channel_mapping=0,4,1,2,3;num_streams=3;coupled_streams=2`,
|
|
309
|
-
)
|
|
310
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
311
|
-
|
|
312
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
313
|
-
lines[0] += ` ${payloadType}`
|
|
314
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/6`)
|
|
315
|
-
lines.splice(
|
|
316
|
-
lines.length - 1,
|
|
317
|
-
0,
|
|
318
|
-
`a=fmtp:${payloadType} channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2`,
|
|
319
|
-
)
|
|
320
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
321
|
-
|
|
322
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
323
|
-
lines[0] += ` ${payloadType}`
|
|
324
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/7`)
|
|
325
|
-
lines.splice(
|
|
326
|
-
lines.length - 1,
|
|
327
|
-
0,
|
|
328
|
-
`a=fmtp:${payloadType} channel_mapping=0,4,1,2,3,5,6;num_streams=4;coupled_streams=4`,
|
|
329
|
-
)
|
|
330
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
331
|
-
|
|
332
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
333
|
-
lines[0] += ` ${payloadType}`
|
|
334
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} multiopus/48000/8`)
|
|
335
|
-
lines.splice(
|
|
336
|
-
lines.length - 1,
|
|
337
|
-
0,
|
|
338
|
-
`a=fmtp:${payloadType} channel_mapping=0,6,1,4,5,2,3,7;num_streams=5;coupled_streams=4`,
|
|
339
|
-
)
|
|
340
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
341
|
-
|
|
342
|
-
return lines.join('\r\n')
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Enable L16 codec.
|
|
347
|
-
*/
|
|
348
|
-
private static enableL16(payloadTypes: string[], section: string): string {
|
|
349
|
-
const lines = section.split('\r\n')
|
|
350
|
-
|
|
351
|
-
let payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
352
|
-
lines[0] += ` ${payloadType}`
|
|
353
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/8000/2`)
|
|
354
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
355
|
-
|
|
356
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
357
|
-
lines[0] += ` ${payloadType}`
|
|
358
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/16000/2`)
|
|
359
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
360
|
-
|
|
361
|
-
payloadType = WebRTCWhep.reservePayloadType(payloadTypes)
|
|
362
|
-
lines[0] += ` ${payloadType}`
|
|
363
|
-
lines.splice(lines.length - 1, 0, `a=rtpmap:${payloadType} L16/48000/2`)
|
|
364
|
-
lines.splice(lines.length - 1, 0, `a=rtcp-fb:${payloadType} transport-cc`)
|
|
365
|
-
|
|
366
|
-
return lines.join('\r\n')
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Enable stereo Opus codec.
|
|
371
|
-
*/
|
|
372
|
-
private static enableStereoOpus(section: string): string {
|
|
373
|
-
let opusPayloadFormat = ''
|
|
374
|
-
const lines = section.split('\r\n')
|
|
375
|
-
|
|
376
|
-
for (let i = 0; i < lines.length; i++) {
|
|
377
|
-
if (lines[i].startsWith('a=rtpmap:') && lines[i].toLowerCase().includes('opus/')) {
|
|
378
|
-
opusPayloadFormat = lines[i].slice('a=rtpmap:'.length).split(' ')[0]
|
|
379
|
-
break
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (opusPayloadFormat === '') {
|
|
384
|
-
return section
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
for (let i = 0; i < lines.length; i++) {
|
|
388
|
-
if (lines[i].startsWith(`a=fmtp:${opusPayloadFormat} `)) {
|
|
389
|
-
if (!lines[i].includes('stereo')) {
|
|
390
|
-
lines[i] += ';stereo=1'
|
|
391
|
-
}
|
|
392
|
-
if (!lines[i].includes('sprop-stereo')) {
|
|
393
|
-
lines[i] += ';sprop-stereo=1'
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
return lines.join('\r\n')
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Edit an offer SDP to enable non-advertised codecs.
|
|
403
|
-
*/
|
|
404
|
-
private static editOffer(sdp: string, nonAdvertisedCodecs: string[]): string {
|
|
405
|
-
const sections = sdp.split('m=')
|
|
406
|
-
|
|
407
|
-
const payloadTypes = sections
|
|
408
|
-
.slice(1)
|
|
409
|
-
.map(s => s.split('\r\n')[0].split(' ').slice(3))
|
|
410
|
-
.reduce((prev, cur) => [...prev, ...cur], [])
|
|
411
|
-
|
|
412
|
-
for (let i = 1; i < sections.length; i++) {
|
|
413
|
-
if (sections[i].startsWith('audio')) {
|
|
414
|
-
sections[i] = WebRTCWhep.enableStereoOpus(sections[i])
|
|
415
|
-
|
|
416
|
-
if (nonAdvertisedCodecs.includes('pcma/8000/2')) {
|
|
417
|
-
sections[i] = WebRTCWhep.enableStereoPcmau(payloadTypes, sections[i])
|
|
418
|
-
}
|
|
419
|
-
if (nonAdvertisedCodecs.includes('multiopus/48000/6')) {
|
|
420
|
-
sections[i] = WebRTCWhep.enableMultichannelOpus(payloadTypes, sections[i])
|
|
421
|
-
}
|
|
422
|
-
if (nonAdvertisedCodecs.includes('L16/48000/2')) {
|
|
423
|
-
sections[i] = WebRTCWhep.enableL16(payloadTypes, sections[i])
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
break
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
return sections.join('m=')
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Generate an SDP fragment.
|
|
435
|
-
*/
|
|
436
|
-
private static generateSdpFragment(od: ParsedOffer, candidates: RTCIceCandidate[]): string {
|
|
437
|
-
const candidatesByMedia: Record<number, RTCIceCandidate[]> = {}
|
|
438
|
-
for (const candidate of candidates) {
|
|
439
|
-
const mid = candidate.sdpMLineIndex
|
|
440
|
-
if (mid) {
|
|
441
|
-
if (candidatesByMedia[mid] === undefined) {
|
|
442
|
-
candidatesByMedia[mid] = []
|
|
443
|
-
}
|
|
444
|
-
candidatesByMedia[mid].push(candidate)
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
let frag = `a=ice-ufrag:${od.iceUfrag}\r\n` + `a=ice-pwd:${od.icePwd}\r\n`
|
|
449
|
-
|
|
450
|
-
let mid = 0
|
|
451
|
-
|
|
452
|
-
for (const media of od.medias) {
|
|
453
|
-
if (candidatesByMedia[mid] !== undefined) {
|
|
454
|
-
frag += `m=${media}\r\n` + `a=mid:${mid}\r\n`
|
|
455
|
-
|
|
456
|
-
for (const candidate of candidatesByMedia[mid]) {
|
|
457
|
-
frag += `a=${candidate.candidate}\r\n`
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
mid++
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
return frag
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Handle errors.
|
|
468
|
-
*/
|
|
469
|
-
private handleError(err: Error | WebRTCError): void {
|
|
470
|
-
this.stopFlowCheck()
|
|
471
|
-
|
|
472
|
-
if (this.state === 'getting_codecs') {
|
|
473
|
-
this.state = 'failed'
|
|
474
|
-
}
|
|
475
|
-
else if (err instanceof WebRTCError && err.type === ErrorTypes.SIGNAL_ERROR) {
|
|
476
|
-
this.state = 'failed'
|
|
477
|
-
}
|
|
478
|
-
else if (this.state === 'running') {
|
|
479
|
-
this.pc?.close()
|
|
480
|
-
this.pc = undefined
|
|
481
|
-
|
|
482
|
-
this.offerData = undefined
|
|
483
|
-
this.queuedCandidates = []
|
|
484
|
-
|
|
485
|
-
if (this.sessionUrl) {
|
|
486
|
-
fetch(this.sessionUrl, {
|
|
487
|
-
method: 'DELETE',
|
|
488
|
-
})
|
|
489
|
-
this.sessionUrl = undefined
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
this.state = 'restarting'
|
|
493
|
-
|
|
494
|
-
this.restartTimeout = setTimeout(() => {
|
|
495
|
-
this.restartTimeout = undefined
|
|
496
|
-
this.state = 'running'
|
|
497
|
-
this.start()
|
|
498
|
-
}, this.retryPause)
|
|
499
|
-
|
|
500
|
-
err.message = `${err.message}, retrying in some seconds`
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
if (this.conf.onError) {
|
|
504
|
-
if (err instanceof WebRTCError) {
|
|
505
|
-
this.conf.onError(err)
|
|
506
|
-
}
|
|
507
|
-
else {
|
|
508
|
-
this.conf.onError(new WebRTCError(ErrorTypes.OTHER_ERROR, err.message))
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Get non-advertised codecs.
|
|
515
|
-
*/
|
|
516
|
-
private getNonAdvertisedCodecs(): void {
|
|
517
|
-
Promise.all(
|
|
518
|
-
[
|
|
519
|
-
['pcma/8000/2'],
|
|
520
|
-
['multiopus/48000/6', 'channel_mapping=0,4,1,2,3,5;num_streams=4;coupled_streams=2'],
|
|
521
|
-
['L16/48000/2'],
|
|
522
|
-
].map(c => WebRTCWhep.supportsNonAdvertisedCodec(c[0], c[1]).then(r => (r ? c[0] : false))),
|
|
523
|
-
)
|
|
524
|
-
.then(c => c.filter(e => e !== false))
|
|
525
|
-
.then((codecs) => {
|
|
526
|
-
if (this.state !== 'getting_codecs') {
|
|
527
|
-
throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
this.nonAdvertisedCodecs = codecs
|
|
531
|
-
this.state = 'running'
|
|
532
|
-
this.start()
|
|
533
|
-
})
|
|
534
|
-
.catch(err => this.handleError(err))
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Start the WebRTC session.
|
|
539
|
-
*/
|
|
540
|
-
private start(): void {
|
|
541
|
-
this.requestICEServers()
|
|
542
|
-
.then(iceServers => this.setupPeerConnection(iceServers))
|
|
543
|
-
.then(offer => this.sendOffer(offer))
|
|
544
|
-
.then(answer => this.setAnswer(answer))
|
|
545
|
-
.catch(err => this.handleError(err))
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* Generate an authorization header.
|
|
550
|
-
*/
|
|
551
|
-
private authHeader(): Record<string, string> {
|
|
552
|
-
if (this.conf.user && this.conf.user !== '') {
|
|
553
|
-
const credentials = btoa(`${this.conf.user}:${this.conf.pass}`)
|
|
554
|
-
return { Authorization: `Basic ${credentials}` }
|
|
555
|
-
}
|
|
556
|
-
if (this.conf.token && this.conf.token !== '') {
|
|
557
|
-
return { Authorization: `Bearer ${this.conf.token}` }
|
|
558
|
-
}
|
|
559
|
-
return {}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Request ICE servers from the endpoint.
|
|
564
|
-
*/
|
|
565
|
-
private async requestICEServers(): Promise<RTCIceServer[]> {
|
|
566
|
-
if (this.conf.iceServers && this.conf.iceServers.length > 0) {
|
|
567
|
-
return this.conf.iceServers
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
return fetch(this.conf.url, {
|
|
571
|
-
method: 'OPTIONS',
|
|
572
|
-
headers: {
|
|
573
|
-
...this.authHeader(),
|
|
574
|
-
},
|
|
575
|
-
}).then(res => WebRTCWhep.linkToIceServers(res.headers.get('Link')))
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* Setup a peer connection.
|
|
580
|
-
*/
|
|
581
|
-
private async setupPeerConnection(iceServers: RTCIceServer[]): Promise<string> {
|
|
582
|
-
if (this.state !== 'running') {
|
|
583
|
-
throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const pc = new RTCPeerConnection({
|
|
587
|
-
iceServers,
|
|
588
|
-
// https://webrtc.org/getting-started/unified-plan-transition-guide
|
|
589
|
-
sdpSemantics: 'unified-plan',
|
|
590
|
-
})
|
|
591
|
-
this.pc = pc
|
|
592
|
-
|
|
593
|
-
const direction: RTCRtpTransceiverDirection = 'recvonly'
|
|
594
|
-
pc.addTransceiver('video', { direction })
|
|
595
|
-
pc.addTransceiver('audio', { direction })
|
|
596
|
-
|
|
597
|
-
pc.onicecandidate = (evt: RTCPeerConnectionIceEvent) => this.onLocalCandidate(evt)
|
|
598
|
-
pc.onconnectionstatechange = () => this.onConnectionState()
|
|
599
|
-
pc.ontrack = (evt: RTCTrackEvent) => this.onTrack(evt)
|
|
600
|
-
|
|
601
|
-
return pc.createOffer().then((offer) => {
|
|
602
|
-
if (!offer.sdp) {
|
|
603
|
-
throw new WebRTCError(ErrorTypes.SIGNAL_ERROR, 'Failed to create offer SDP')
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
offer.sdp = WebRTCWhep.editOffer(offer.sdp, this.nonAdvertisedCodecs)
|
|
607
|
-
this.offerData = WebRTCWhep.parseOffer(offer.sdp)
|
|
608
|
-
|
|
609
|
-
return pc.setLocalDescription(offer).then(() => offer.sdp!)
|
|
610
|
-
})
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Send an offer to the endpoint.
|
|
615
|
-
*/
|
|
616
|
-
private sendOffer(offer: string): Promise<string> {
|
|
617
|
-
if (this.state !== 'running') {
|
|
618
|
-
throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
return fetch(this.conf.url, {
|
|
622
|
-
method: 'POST',
|
|
623
|
-
headers: {
|
|
624
|
-
...this.authHeader(),
|
|
625
|
-
'Content-Type': 'application/sdp',
|
|
626
|
-
},
|
|
627
|
-
body: offer,
|
|
628
|
-
}).then((res) => {
|
|
629
|
-
switch (res.status) {
|
|
630
|
-
case 201:
|
|
631
|
-
break
|
|
632
|
-
case 404:
|
|
633
|
-
throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
|
|
634
|
-
case 406:
|
|
635
|
-
throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not supported')
|
|
636
|
-
case 400:
|
|
637
|
-
return res.json().then((e: { error: string }) => {
|
|
638
|
-
throw new WebRTCError(ErrorTypes.NETWORK_ERROR, e.error)
|
|
639
|
-
})
|
|
640
|
-
default:
|
|
641
|
-
throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
const location = res.headers.get('Location')
|
|
645
|
-
if (location) {
|
|
646
|
-
this.sessionUrl = new URL(location, this.conf.url).toString()
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
return res.text()
|
|
650
|
-
})
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Set a remote answer.
|
|
655
|
-
*/
|
|
656
|
-
private setAnswer(answer: string): Promise<void> {
|
|
657
|
-
if (this.state !== 'running') {
|
|
658
|
-
throw new WebRTCError(ErrorTypes.STATE_ERROR, 'closed')
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
return this.pc!.setRemoteDescription(
|
|
662
|
-
new RTCSessionDescription({
|
|
663
|
-
type: 'answer',
|
|
664
|
-
sdp: answer,
|
|
665
|
-
}),
|
|
666
|
-
).then(() => {
|
|
667
|
-
if (this.state !== 'running') {
|
|
668
|
-
return
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (this.queuedCandidates.length !== 0) {
|
|
672
|
-
this.sendLocalCandidates(this.queuedCandidates)
|
|
673
|
-
this.queuedCandidates = []
|
|
674
|
-
}
|
|
675
|
-
})
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Handle local ICE candidates.
|
|
680
|
-
*/
|
|
681
|
-
private onLocalCandidate(evt: RTCPeerConnectionIceEvent): void {
|
|
682
|
-
if (this.state !== 'running') {
|
|
683
|
-
return
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
if (evt.candidate) {
|
|
687
|
-
if (this.sessionUrl) {
|
|
688
|
-
this.sendLocalCandidates([evt.candidate])
|
|
689
|
-
}
|
|
690
|
-
else {
|
|
691
|
-
this.queuedCandidates.push(evt.candidate)
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Send local ICE candidates to the endpoint.
|
|
698
|
-
*/
|
|
699
|
-
private sendLocalCandidates(candidates: RTCIceCandidate[]): void {
|
|
700
|
-
if (!this.sessionUrl || !this.offerData) {
|
|
701
|
-
return
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
fetch(this.sessionUrl, {
|
|
705
|
-
method: 'PATCH',
|
|
706
|
-
headers: {
|
|
707
|
-
'Content-Type': 'application/trickle-ice-sdpfrag',
|
|
708
|
-
'If-Match': '*',
|
|
709
|
-
},
|
|
710
|
-
body: WebRTCWhep.generateSdpFragment(this.offerData, candidates),
|
|
711
|
-
})
|
|
712
|
-
.then((res) => {
|
|
713
|
-
switch (res.status) {
|
|
714
|
-
case 204:
|
|
715
|
-
break
|
|
716
|
-
case 404:
|
|
717
|
-
throw new WebRTCError(ErrorTypes.NETWORK_ERROR, 'stream not found')
|
|
718
|
-
default:
|
|
719
|
-
throw new WebRTCError(ErrorTypes.NETWORK_ERROR, `bad status code ${res.status}`)
|
|
720
|
-
}
|
|
721
|
-
})
|
|
722
|
-
.catch(err => this.handleError(err))
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Handle peer connection state changes.
|
|
727
|
-
*/
|
|
728
|
-
private onConnectionState(): void {
|
|
729
|
-
if (this.state !== 'running' || !this.pc) {
|
|
730
|
-
return
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// "closed" can arrive before "failed" and without
|
|
734
|
-
// the close() method being called at all.
|
|
735
|
-
// It happens when the other peer sends a termination
|
|
736
|
-
// message like a DTLS CloseNotify.
|
|
737
|
-
if (this.pc.connectionState === 'failed' || this.pc.connectionState === 'closed') {
|
|
738
|
-
this.handleError(new WebRTCError(ErrorTypes.OTHER_ERROR, 'peer connection closed'))
|
|
739
|
-
}
|
|
740
|
-
else if (this.pc.connectionState === 'connected') {
|
|
741
|
-
this.startFlowCheck()
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* 启动断流检测
|
|
747
|
-
*/
|
|
748
|
-
private startFlowCheck(): void {
|
|
749
|
-
this.stopFlowCheck()
|
|
750
|
-
this.checkTimer = setInterval(() => this.checkFlowState(), this.checkInterval)
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
/**
|
|
754
|
-
* 停止断流检测
|
|
755
|
-
*/
|
|
756
|
-
private stopFlowCheck(): void {
|
|
757
|
-
if (this.checkTimer) {
|
|
758
|
-
clearInterval(this.checkTimer)
|
|
759
|
-
this.checkTimer = undefined
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/*
|
|
764
|
-
* 检测流状态(通过接收字节数判断是否断流)
|
|
765
|
-
*/
|
|
766
|
-
private async checkFlowState(): Promise<void> {
|
|
767
|
-
if (!this.pc) {
|
|
768
|
-
return
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const stats = await this.pc.getStats()
|
|
772
|
-
let currentBytes = 0
|
|
773
|
-
|
|
774
|
-
// 遍历统计信息,获取视频接收字节数
|
|
775
|
-
stats.forEach((stat: RTCStats) => {
|
|
776
|
-
const inboundRtpStat = stat as RTCInboundRtpStreamStats
|
|
777
|
-
if (stat.type === 'inbound-rtp' && inboundRtpStat.kind === 'video') {
|
|
778
|
-
currentBytes = inboundRtpStat.bytesReceived || 0
|
|
779
|
-
}
|
|
780
|
-
})
|
|
781
|
-
|
|
782
|
-
// 断流判定:连接正常但字节数无变化
|
|
783
|
-
if (currentBytes === this.lastBytesReceived && this.pc.connectionState === 'connected') {
|
|
784
|
-
this.handleError(new WebRTCError(ErrorTypes.NETWORK_ERROR, 'data stream interruption'))
|
|
785
|
-
return
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// 更新上一次字节数
|
|
789
|
-
this.lastBytesReceived = currentBytes
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Handle incoming tracks.
|
|
794
|
-
*/
|
|
795
|
-
private onTrack(evt: RTCTrackEvent): void {
|
|
796
|
-
this.stream = evt.streams[0]
|
|
797
|
-
this.observer.start(this.container, (isIntersecting) => {
|
|
798
|
-
if (isIntersecting)
|
|
799
|
-
this.resume()
|
|
800
|
-
else
|
|
801
|
-
this.pause()
|
|
802
|
-
})
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* 流是否为空
|
|
807
|
-
*/
|
|
808
|
-
get paused() {
|
|
809
|
-
return this.container.srcObject === null
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
/**
|
|
813
|
-
* 暂停播放
|
|
814
|
-
*/
|
|
815
|
-
pause() {
|
|
816
|
-
this.container.srcObject = null
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* 恢复播放
|
|
821
|
-
*/
|
|
822
|
-
resume() {
|
|
823
|
-
if (this.stream && this.paused)
|
|
824
|
-
this.container.srcObject = this.stream
|
|
825
|
-
}
|
|
826
|
-
}
|
|
6
|
+
export default WebRTCWhep
|