diff --git a/README.md b/README.md index 7c24d025..c9413fe6 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,10 @@ If `opts` is specified, then the default options (shown below) will be overridde sdpTransform: function (sdp) { return sdp }, stream: false, streams: [], + preferredCodecs: { + audio: [], + video: [] + }, trickle: true, allowHalfTrickle: false, wrtc: {}, // RTCPeerConnection/RTCSessionDescription/RTCIceCandidate @@ -291,6 +295,7 @@ The options do the following: - `sdpTransform` - function to transform the generated SDP signaling data (for advanced users) - `stream` - if video/voice is desired, pass stream returned from [`getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) - `streams` - an array of MediaStreams returned from [`getUserMedia`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) +- `preferredCodecs` - prefer codecs for outbound tracks (by kind), using `RTCRtpTransceiver.setCodecPreferences` where supported. Example: `{ video: ['video/AV1', 'video/VP9', 'video/VP8'], audio: ['audio/opus'] }`. If the browser does not support codec preferences (or the codec is not supported), it falls back to the browser’s default. - `trickle` - set to `false` to disable [trickle ICE](http://webrtchacks.com/trickle-ice/) and get a single 'signal' event (slower) - `wrtc` - custom webrtc implementation, mainly useful in node to specify in the [wrtc](https://npmjs.com/package/wrtc) package. Contains an object with the properties: - [`RTCPeerConnection`](https://www.w3.org/TR/webrtc/#dom-rtcpeerconnection) diff --git a/index.ts b/index.ts index fdd1a457..7cc0bcd2 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1,17 @@ /*! simple-peer. MIT License. Feross Aboukhadijeh */ -import Lite, { PeerOptions } from './lite.js' +import Lite, { PeerLiteOptions } from './lite.js' import errCode from 'err-code' import { MediaStream, MediaStreamTrack, RTCRtpSender, RTCRtpTransceiver } from 'webrtc-polyfill' +interface PreferredCodecs { + video?: string[] + audio?: string[] +} + +interface PeerOptions extends PeerLiteOptions { + preferredCodecs?: PreferredCodecs +} + /** * WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods. * Duplex stream. @@ -10,6 +19,7 @@ import { MediaStream, MediaStreamTrack, RTCRtpSender, RTCRtpTransceiver } from ' class Peer extends Lite { streams: MediaStream[] _senderMap: WeakMap> + preferredCodecs?: PreferredCodecs constructor (opts: PeerOptions = {}) { super(opts) @@ -17,6 +27,7 @@ class Peer extends Lite { this.streams = opts.streams || (opts.stream ? [opts.stream] : []) // support old "stream" option this._senderMap = new WeakMap() + this.preferredCodecs = opts.preferredCodecs if (this.streams) { this.streams.forEach(stream => { @@ -28,6 +39,47 @@ class Peer extends Lite { } } + _setPreferredCodecs (kind: 'audio' | 'video', transceiver: RTCRtpTransceiver | null): void { + const preferred = this.preferredCodecs?.[kind] + if (!preferred || preferred.length === 0) return + if (!transceiver?.setCodecPreferences) return + if (typeof RTCRtpSender.getCapabilities !== 'function') return + + const capabilities = RTCRtpSender.getCapabilities(kind) + if (!capabilities?.codecs?.length) return + + const normalized = preferred.map(codec => codec.toLowerCase()) + const ordered: RTCRtpCodecCapability[] = [] + const used = new Set() + + normalized.forEach(pref => { + const prefIsFull = pref.includes('/') + capabilities.codecs.forEach((codec, index) => { + if (used.has(index)) return + const mime = codec.mimeType?.toLowerCase() + if (!mime) return + if (mime.endsWith('/rtx') || mime.endsWith('/red') || mime.endsWith('/ulpfec')) return + if (prefIsFull ? mime === pref : mime.endsWith('/' + pref)) { + used.add(index) + ordered.push(codec) + } + }) + }) + + capabilities.codecs.forEach((codec, index) => { + if (used.has(index)) return + ordered.push(codec) + }) + + transceiver.setCodecPreferences(ordered) + } + + _getTransceiverForSender (sender: RTCRtpSender): RTCRtpTransceiver | null { + const transceivers = this._pc!.getTransceivers?.() + if (!transceivers) return null + return transceivers.find(transceiver => transceiver.sender === sender) || null + } + /** * Add a Transceiver to the connection. */ @@ -38,7 +90,10 @@ class Peer extends Lite { if (this.initiator) { try { - this._pc!.addTransceiver(kind, init as RTCRtpTransceiverInit) + const transceiver = this._pc!.addTransceiver(kind, init as RTCRtpTransceiverInit) + if (kind === 'audio' || kind === 'video') { + this._setPreferredCodecs(kind, transceiver) + } this._needsNegotiation() } catch (err) { this.__destroy(errCode(err as Error, 'ERR_ADD_TRANSCEIVER')) @@ -78,6 +133,9 @@ class Peer extends Lite { sender = this._pc!.addTrack(track, stream) submap.set(stream, sender) this._senderMap.set(track, submap) + if (track.kind === 'audio' || track.kind === 'video') { + this._setPreferredCodecs(track.kind, this._getTransceiverForSender(sender)) + } this._needsNegotiation() } else if ((sender as RTCRtpSender & { removed?: boolean }).removed) { throw errCode(new Error('Track has been removed. You should enable/disable tracks that you want to re-add.'), 'ERR_SENDER_REMOVED') @@ -185,5 +243,5 @@ class Peer extends Lite { export default Peer export { Peer } -export type { PeerOptions, SignalData, AddressInfo, StatsReport } from './lite.js' - +export type { PeerLiteOptions, SignalData, AddressInfo, StatsReport } from './lite.js' +export type { PeerOptions, PreferredCodecs } diff --git a/lite.ts b/lite.ts index c0cc2c2c..053c5022 100644 --- a/lite.ts +++ b/lite.ts @@ -33,7 +33,7 @@ interface SignalData { } } -interface PeerOptions { +interface PeerLiteOptions { initiator?: boolean channelConfig?: RTCDataChannelInit channelName?: string @@ -161,7 +161,7 @@ class Peer extends EventEmitter { static config: RTCConfiguration static channelConfig: RTCDataChannelInit - constructor (opts: PeerOptions = {}) { + constructor (opts: PeerLiteOptions = {}) { super() this.destroyed = false @@ -1045,4 +1045,4 @@ Peer.config = { Peer.channelConfig = {} export default Peer -export { Peer, PeerOptions, SignalData, AddressInfo, StatsReport } +export { Peer, PeerLiteOptions, SignalData, AddressInfo, StatsReport } diff --git a/test/codec-preferences.ts b/test/codec-preferences.ts new file mode 100644 index 00000000..286421a5 --- /dev/null +++ b/test/codec-preferences.ts @@ -0,0 +1,151 @@ +import common from './common.js' +import Peer from '../index.js' +import { test, expect } from 'vitest' + +function getVideoTransceiver (peer: Peer): RTCRtpTransceiver | null { + const transceivers = peer._pc!.getTransceivers?.() + if (!transceivers) return null + return transceivers.find(transceiver => transceiver.receiver?.track?.kind === 'video') || null +} + +function getCodecMimeTypeFromReport (report: RTCStatsReport, rtpType: 'outbound-rtp' | 'inbound-rtp'): string | null { + let rtpReport: RTCStats & { codecId?: string } | undefined + report.forEach(stat => { + if (rtpReport) return + if (stat.type !== rtpType) return + const mediaType = (stat as any).mediaType || (stat as any).kind + if (mediaType === 'video') rtpReport = stat as (RTCStats & { codecId?: string }) + }) + + if (!rtpReport?.codecId) return null + const codecReport = report.get(rtpReport.codecId) as (RTCStats & { mimeType?: string }) | undefined + return codecReport?.mimeType?.toLowerCase() ?? null +} + +function listCodecMimeTypes (report: RTCStatsReport): string[] { + const codecs: string[] = [] + report.forEach(stat => { + if (stat.type !== 'codec') return + const mime = (stat as RTCStats & { mimeType?: string }).mimeType?.toLowerCase() + if (mime && !codecs.includes(mime)) codecs.push(mime) + }) + return codecs +} + +async function waitForReceiverVideoCodec (peer: Peer, timeoutMs = 8000): Promise { + const start = Date.now() + let lastCodecs: string[] = [] + while (Date.now() - start < timeoutMs) { + const transceiver = getVideoTransceiver(peer) + if (!transceiver?.receiver?.getStats) break + const report = await transceiver.receiver.getStats() + const codec = getCodecMimeTypeFromReport(report, 'inbound-rtp') + if (codec) return codec + lastCodecs = listCodecMimeTypes(report) + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`Timed out waiting for inbound video codec. Seen codecs: ${lastCodecs.join(', ') || 'none'}`) +} + +async function waitForSenderVideoCodec (peer: Peer, timeoutMs = 8000): Promise { + const start = Date.now() + let lastCodecs: string[] = [] + while (Date.now() - start < timeoutMs) { + const transceiver = getVideoTransceiver(peer) + if (!transceiver?.sender?.getStats) break + const report = await transceiver.sender.getStats() + const codec = getCodecMimeTypeFromReport(report, 'outbound-rtp') + if (codec) return codec + lastCodecs = listCodecMimeTypes(report) + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`Timed out waiting for outbound video codec. Seen codecs: ${lastCodecs.join(', ') || 'none'}`) +} + +async function waitForVideoCodec (peer: Peer): Promise { + try { + return await waitForReceiverVideoCodec(peer, 4000) + } catch { + return await waitForSenderVideoCodec(peer, 4000) + } +} + +async function attachStreamToVideo (stream: MediaStream): Promise { + const video = document.createElement('video') + video.muted = true + video.autoplay = true + video.playsInline = true + video.srcObject = stream + document.body.appendChild(video) + try { + await video.play() + } catch { + // Ignore autoplay errors; stats should still populate. + } +} + +async function getCameraStream (): Promise { + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('getUserMedia is not available in this browser') + } + return await navigator.mediaDevices.getUserMedia({ video: true, audio: false }) +} + +test('preferredCodecs influences negotiated video codec (getStats)', async function () { + if (!process.browser) return + if (common.isBrowser('ios')) return + // Playwright WebKit does not support starting the webcam + if (common.isBrowser('safari')) return + if (typeof RTCRtpTransceiver === 'undefined') return + if (typeof RTCRtpTransceiver.prototype.setCodecPreferences !== 'function') return + if (typeof RTCRtpSender === 'undefined' || typeof RTCRtpSender.getCapabilities !== 'function') return + if (typeof RTCRtpReceiver === 'undefined' || typeof RTCRtpReceiver.getCapabilities !== 'function') return + const preferred = ['video/vp9'] + + const senderCaps = RTCRtpSender.getCapabilities('video') + const receiverCaps = RTCRtpReceiver.getCapabilities('video') + const supportsVp9 = senderCaps?.codecs?.some(codec => codec.mimeType?.toLowerCase() === 'video/vp9') && + receiverCaps?.codecs?.some(codec => codec.mimeType?.toLowerCase() === 'video/vp9') + if (!supportsVp9) return + + const [stream1, stream2] = await Promise.all([getCameraStream(), getCameraStream()]) + + const peer1 = new Peer({ + initiator: true, + streams: [stream1], + preferredCodecs: { video: preferred } + }) + const peer2 = new Peer({ + streams: [stream2], + preferredCodecs: { video: preferred } + }) + + peer1.on('signal', data => peer2.signal(data)) + peer2.on('signal', data => peer1.signal(data)) + + await new Promise((resolve) => { + let streams = 0 + const onStream = (stream: MediaStream) => { + void attachStreamToVideo(stream) + streams++ + if (streams >= 2) resolve() + } + peer1.on('stream', onStream) + peer2.on('stream', onStream) + }) + + await new Promise(resolve => setTimeout(resolve, 500)) + + const [codec1, codec2] = await Promise.all([ + waitForVideoCodec(peer1), + waitForVideoCodec(peer2) + ]) + + expect(codec1).toBe('video/vp9') + expect(codec2).toBe('video/vp9') + + peer1.destroy() + peer2.destroy() + stream1.getTracks().forEach(track => track.stop()) + stream2.getTracks().forEach(track => track.stop()) +}) diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index ff2c8685..31cabcdc 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -31,9 +31,26 @@ export default defineConfig({ instances: [ // Default to chromium, but can be overridden with --browser.name flag // Supported browsers: chromium, firefox, webkit - { browser: 'chromium', provider: playwright() }, - { browser: 'firefox', provider: playwright() }, - { browser: 'webkit', provider: playwright() }, + { + browser: 'chromium', + provider: playwright({ + launchOptions: { + args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] + } + }) + }, + { + browser: 'firefox', + provider: playwright({ + launchOptions: { + firefoxUserPrefs: { + 'media.navigator.streams.fake': true, + 'media.navigator.permission.disabled': true + } + } + }) + }, + { browser: 'webkit', provider: playwright() } ], headless: true }