livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
557 lines (484 loc) • 15.2 kB
text/typescript
import { ClientInfo, ClientInfo_SDK, Transcription as TranscriptionModel } from '@livekit/protocol';
import { getBrowser } from '../utils/browserParser';
import { protocolVersion, version } from '../version';
import CriticalTimers from './timers';
import type LocalAudioTrack from './track/LocalAudioTrack';
import type RemoteAudioTrack from './track/RemoteAudioTrack';
import { VideoCodec, videoCodecs } from './track/options';
import { getNewAudioContext } from './track/utils';
import type { LiveKitReactNativeInfo, TranscriptionSegment } from './types';
const separator = '|';
export const ddExtensionURI =
'https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension';
export function unpackStreamId(packed: string): string[] {
const parts = packed.split(separator);
if (parts.length > 1) {
return [parts[0], packed.substr(parts[0].length + 1)];
}
return [packed, ''];
}
export async function sleep(duration: number): Promise<void> {
return new Promise((resolve) => CriticalTimers.setTimeout(resolve, duration));
}
/** @internal */
export function supportsTransceiver() {
return 'addTransceiver' in RTCPeerConnection.prototype;
}
/** @internal */
export function supportsAddTrack() {
return 'addTrack' in RTCPeerConnection.prototype;
}
export function supportsAdaptiveStream() {
return typeof ResizeObserver !== undefined && typeof IntersectionObserver !== undefined;
}
export function supportsDynacast() {
return supportsTransceiver();
}
export function supportsAV1(): boolean {
if (!('getCapabilities' in RTCRtpSender)) {
return false;
}
if (isSafari()) {
// Safari 17 on iPhone14 reports AV1 capability, but does not actually support it
return false;
}
const capabilities = RTCRtpSender.getCapabilities('video');
let hasAV1 = false;
if (capabilities) {
for (const codec of capabilities.codecs) {
if (codec.mimeType === 'video/AV1') {
hasAV1 = true;
break;
}
}
}
return hasAV1;
}
export function supportsVP9(): boolean {
if (!('getCapabilities' in RTCRtpSender)) {
return false;
}
if (isFireFox()) {
// technically speaking FireFox supports VP9, but SVC publishing is broken
// https://bugzilla.mozilla.org/show_bug.cgi?id=1633876
return false;
}
if (isSafari()) {
const browser = getBrowser();
if (browser?.version && compareVersions(browser.version, '16') < 0) {
// Safari 16 and below does not support VP9
return false;
}
}
const capabilities = RTCRtpSender.getCapabilities('video');
let hasVP9 = false;
if (capabilities) {
for (const codec of capabilities.codecs) {
if (codec.mimeType === 'video/VP9') {
hasVP9 = true;
break;
}
}
}
return hasVP9;
}
export function isSVCCodec(codec?: string): boolean {
return codec === 'av1' || codec === 'vp9';
}
export function supportsSetSinkId(elm?: HTMLMediaElement): boolean {
if (!document) {
return false;
}
if (!elm) {
elm = document.createElement('audio');
}
return 'setSinkId' in elm;
}
export function isBrowserSupported() {
if (typeof RTCPeerConnection === 'undefined') {
return false;
}
return supportsTransceiver() || supportsAddTrack();
}
export function isFireFox(): boolean {
return getBrowser()?.name === 'Firefox';
}
export function isChromiumBased(): boolean {
return getBrowser()?.name === 'Chrome';
}
export function isSafari(): boolean {
return getBrowser()?.name === 'Safari';
}
export function isSafari17(): boolean {
const b = getBrowser();
return b?.name === 'Safari' && b.version.startsWith('17.');
}
export function isMobile(): boolean {
if (!isWeb()) return false;
return (
// @ts-expect-error `userAgentData` is not yet part of typescript
navigator.userAgentData?.mobile ??
/Tablet|iPad|Mobile|Android|BlackBerry/.test(navigator.userAgent)
);
}
export function isE2EESimulcastSupported() {
const browser = getBrowser();
const supportedSafariVersion = '17.2'; // see https://bugs.webkit.org/show_bug.cgi?id=257803
if (browser) {
if (browser.name !== 'Safari' && browser.os !== 'iOS') {
return true;
} else if (
browser.os === 'iOS' &&
browser.osVersion &&
compareVersions(supportedSafariVersion, browser.osVersion) >= 0
) {
return true;
} else if (
browser.name === 'Safari' &&
compareVersions(supportedSafariVersion, browser.version) >= 0
) {
return true;
} else {
return false;
}
}
}
export function isWeb(): boolean {
return typeof document !== 'undefined';
}
export function isReactNative(): boolean {
// navigator.product is deprecated on browsers, but will be set appropriately for react-native.
return navigator.product == 'ReactNative';
}
export function isCloud(serverUrl: URL) {
return (
serverUrl.hostname.endsWith('.livekit.cloud') || serverUrl.hostname.endsWith('.livekit.run')
);
}
function getLKReactNativeInfo(): LiveKitReactNativeInfo | undefined {
// global defined only for ReactNative.
// @ts-ignore
if (global && global.LiveKitReactNativeGlobal) {
// @ts-ignore
return global.LiveKitReactNativeGlobal as LiveKitReactNativeInfo;
}
return undefined;
}
export function getReactNativeOs(): string | undefined {
if (!isReactNative()) {
return undefined;
}
let info = getLKReactNativeInfo();
if (info) {
return info.platform;
}
return undefined;
}
export function getDevicePixelRatio(): number {
if (isWeb()) {
return window.devicePixelRatio;
}
if (isReactNative()) {
let info = getLKReactNativeInfo();
if (info) {
return info.devicePixelRatio;
}
}
return 1;
}
export function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.');
const parts2 = v2.split('.');
const k = Math.min(parts1.length, parts2.length);
for (let i = 0; i < k; ++i) {
const p1 = parseInt(parts1[i], 10);
const p2 = parseInt(parts2[i], 10);
if (p1 > p2) return 1;
if (p1 < p2) return -1;
if (i === k - 1 && p1 === p2) return 0;
}
if (v1 === '' && v2 !== '') {
return -1;
} else if (v2 === '') {
return 1;
}
return parts1.length == parts2.length ? 0 : parts1.length < parts2.length ? -1 : 1;
}
function roDispatchCallback(entries: ResizeObserverEntry[]) {
for (const entry of entries) {
(entry.target as ObservableMediaElement).handleResize(entry);
}
}
function ioDispatchCallback(entries: IntersectionObserverEntry[]) {
for (const entry of entries) {
(entry.target as ObservableMediaElement).handleVisibilityChanged(entry);
}
}
let resizeObserver: ResizeObserver | null = null;
export const getResizeObserver = () => {
if (!resizeObserver) resizeObserver = new ResizeObserver(roDispatchCallback);
return resizeObserver;
};
let intersectionObserver: IntersectionObserver | null = null;
export const getIntersectionObserver = () => {
if (!intersectionObserver) {
intersectionObserver = new IntersectionObserver(ioDispatchCallback, {
root: null,
rootMargin: '0px',
});
}
return intersectionObserver;
};
export interface ObservableMediaElement extends HTMLMediaElement {
handleResize: (entry: ResizeObserverEntry) => void;
handleVisibilityChanged: (entry: IntersectionObserverEntry) => void;
}
export function getClientInfo(): ClientInfo {
const info = new ClientInfo({
sdk: ClientInfo_SDK.JS,
protocol: protocolVersion,
version,
});
if (isReactNative()) {
info.os = getReactNativeOs() ?? '';
}
return info;
}
let emptyVideoStreamTrack: MediaStreamTrack | undefined;
export function getEmptyVideoStreamTrack() {
if (!emptyVideoStreamTrack) {
emptyVideoStreamTrack = createDummyVideoStreamTrack();
}
return emptyVideoStreamTrack.clone();
}
export function createDummyVideoStreamTrack(
width: number = 16,
height: number = 16,
enabled: boolean = false,
paintContent: boolean = false,
) {
const canvas = document.createElement('canvas');
// the canvas size is set to 16 by default, because electron apps seem to fail with smaller values
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.fillRect(0, 0, canvas.width, canvas.height);
if (paintContent && ctx) {
ctx.beginPath();
ctx.arc(width / 2, height / 2, 50, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = 'grey';
ctx.fill();
}
// @ts-ignore
const dummyStream = canvas.captureStream();
const [dummyTrack] = dummyStream.getTracks();
if (!dummyTrack) {
throw Error('Could not get empty media stream video track');
}
dummyTrack.enabled = enabled;
return dummyTrack;
}
let emptyAudioStreamTrack: MediaStreamTrack | undefined;
export function getEmptyAudioStreamTrack() {
if (!emptyAudioStreamTrack) {
// implementation adapted from https://blog.mozilla.org/webrtc/warm-up-with-replacetrack/
const ctx = new AudioContext();
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, 0);
const dst = ctx.createMediaStreamDestination();
oscillator.connect(gain);
gain.connect(dst);
oscillator.start();
[emptyAudioStreamTrack] = dst.stream.getAudioTracks();
if (!emptyAudioStreamTrack) {
throw Error('Could not get empty media stream audio track');
}
emptyAudioStreamTrack.enabled = false;
}
return emptyAudioStreamTrack.clone();
}
export class Future<T> {
promise: Promise<T>;
resolve?: (arg: T) => void;
reject?: (e: any) => void;
onFinally?: () => void;
constructor(
futureBase?: (resolve: (arg: T) => void, reject: (e: any) => void) => void,
onFinally?: () => void,
) {
this.onFinally = onFinally;
this.promise = new Promise<T>(async (resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
if (futureBase) {
await futureBase(resolve, reject);
}
}).finally(() => this.onFinally?.());
}
}
export type AudioAnalyserOptions = {
/**
* If set to true, the analyser will use a cloned version of the underlying mediastreamtrack, which won't be impacted by muting the track.
* Useful for local tracks when implementing things like "seems like you're muted, but trying to speak".
* Defaults to false
*/
cloneTrack?: boolean;
/**
* see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/fftSize
*/
fftSize?: number;
/**
* see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/smoothingTimeConstant
*/
smoothingTimeConstant?: number;
/**
* see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/minDecibels
*/
minDecibels?: number;
/**
* see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/maxDecibels
*/
maxDecibels?: number;
};
/**
* Creates and returns an analyser web audio node that is attached to the provided track.
* Additionally returns a convenience method `calculateVolume` to perform instant volume readings on that track.
* Call the returned `cleanup` function to close the audioContext that has been created for the instance of this helper
*/
export function createAudioAnalyser(
track: LocalAudioTrack | RemoteAudioTrack,
options?: AudioAnalyserOptions,
) {
const opts = {
cloneTrack: false,
fftSize: 2048,
smoothingTimeConstant: 0.8,
minDecibels: -100,
maxDecibels: -80,
...options,
};
const audioContext = getNewAudioContext();
if (!audioContext) {
throw new Error('Audio Context not supported on this browser');
}
const streamTrack = opts.cloneTrack ? track.mediaStreamTrack.clone() : track.mediaStreamTrack;
const mediaStreamSource = audioContext.createMediaStreamSource(new MediaStream([streamTrack]));
const analyser = audioContext.createAnalyser();
analyser.minDecibels = opts.minDecibels;
analyser.maxDecibels = opts.maxDecibels;
analyser.fftSize = opts.fftSize;
analyser.smoothingTimeConstant = opts.smoothingTimeConstant;
mediaStreamSource.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
/**
* Calculates the current volume of the track in the range from 0 to 1
*/
const calculateVolume = () => {
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (const amplitude of dataArray) {
sum += Math.pow(amplitude / 255, 2);
}
const volume = Math.sqrt(sum / dataArray.length);
return volume;
};
const cleanup = async () => {
await audioContext.close();
if (opts.cloneTrack) {
streamTrack.stop();
}
};
return { calculateVolume, analyser, cleanup };
}
/**
* @internal
*/
export class Mutex {
private _locking: Promise<void>;
private _locks: number;
constructor() {
this._locking = Promise.resolve();
this._locks = 0;
}
isLocked() {
return this._locks > 0;
}
lock() {
this._locks += 1;
let unlockNext: () => void;
const willLock = new Promise<void>(
(resolve) =>
(unlockNext = () => {
this._locks -= 1;
resolve();
}),
);
const willUnlock = this._locking.then(() => unlockNext);
this._locking = this._locking.then(() => willLock);
return willUnlock;
}
}
export function isVideoCodec(maybeCodec: string): maybeCodec is VideoCodec {
return videoCodecs.includes(maybeCodec as VideoCodec);
}
export function unwrapConstraint(constraint: ConstrainDOMString): string;
export function unwrapConstraint(constraint: ConstrainULong): number;
export function unwrapConstraint(constraint: ConstrainDOMString | ConstrainULong): string | number {
if (typeof constraint === 'string' || typeof constraint === 'number') {
return constraint;
}
if (Array.isArray(constraint)) {
return constraint[0];
}
if (constraint.exact) {
if (Array.isArray(constraint.exact)) {
return constraint.exact[0];
}
return constraint.exact;
}
if (constraint.ideal) {
if (Array.isArray(constraint.ideal)) {
return constraint.ideal[0];
}
return constraint.ideal;
}
throw Error('could not unwrap constraint');
}
export function toWebsocketUrl(url: string): string {
if (url.startsWith('http')) {
return url.replace(/^(http)/, 'ws');
}
return url;
}
export function toHttpUrl(url: string): string {
if (url.startsWith('ws')) {
return url.replace(/^(ws)/, 'http');
}
return url;
}
export function extractTranscriptionSegments(
transcription: TranscriptionModel,
firstReceivedTimesMap: Map<string, number>,
): TranscriptionSegment[] {
return transcription.segments.map(({ id, text, language, startTime, endTime, final }) => {
const firstReceivedTime = firstReceivedTimesMap.get(id) ?? Date.now();
const lastReceivedTime = Date.now();
if (final) {
firstReceivedTimesMap.delete(id);
} else {
firstReceivedTimesMap.set(id, firstReceivedTime);
}
return {
id,
text,
startTime: Number.parseInt(startTime.toString()),
endTime: Number.parseInt(endTime.toString()),
final,
language,
firstReceivedTime,
lastReceivedTime,
};
});
}