Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
66 changes: 62 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
/*! simple-peer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
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.
*/
class Peer extends Lite {
streams: MediaStream[]
_senderMap: WeakMap<MediaStreamTrack, WeakMap<MediaStream, RTCRtpSender>>
preferredCodecs?: PreferredCodecs

constructor (opts: PeerOptions = {}) {
super(opts)
if (!this._pc) return

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 => {
Expand All @@ -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<number>()

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.
*/
Expand All @@ -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'))
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 }
6 changes: 3 additions & 3 deletions lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface SignalData {
}
}

interface PeerOptions {
interface PeerLiteOptions {
initiator?: boolean
channelConfig?: RTCDataChannelInit
channelName?: string
Expand Down Expand Up @@ -161,7 +161,7 @@ class Peer extends EventEmitter<PeerEvents> {
static config: RTCConfiguration
static channelConfig: RTCDataChannelInit

constructor (opts: PeerOptions = {}) {
constructor (opts: PeerLiteOptions = {}) {
super()

this.destroyed = false
Expand Down Expand Up @@ -1045,4 +1045,4 @@ Peer.config = {
Peer.channelConfig = {}

export default Peer
export { Peer, PeerOptions, SignalData, AddressInfo, StatsReport }
export { Peer, PeerLiteOptions, SignalData, AddressInfo, StatsReport }
151 changes: 151 additions & 0 deletions test/codec-preferences.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<string> {
try {
return await waitForReceiverVideoCodec(peer, 4000)
} catch {
return await waitForSenderVideoCodec(peer, 4000)
}
}

async function attachStreamToVideo (stream: MediaStream): Promise<void> {
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<MediaStream> {
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<void>((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())
})
23 changes: 20 additions & 3 deletions vitest.browser.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down