TelegramPWA/src/lib/secret-sauce/secretsauce.ts

863 lines
23 KiB
TypeScript

import parseSdp from './parseSdp';
import { ColibriClass } from './colibriClass';
import type {
GroupCallConnectionData, GroupCallConnectionState, GroupCallParticipant, JoinGroupCallPayload,
} from './types';
import buildSdp, { Conference, Ssrc } from './buildSdp';
import { black, silence } from './blacksilence';
import {
getAmplitude,
IS_ECHO_CANCELLATION_SUPPORTED,
IS_NOISE_SUPPRESSION_SUPPORTED,
THRESHOLD,
} from './utils';
export type StreamType = 'audio' | 'video' | 'presentation';
const DEFAULT_MID = 3;
type GroupCallState = {
connection?: RTCPeerConnection;
screenshareConnection?: RTCPeerConnection;
dataChannel?: RTCDataChannel;
screenshareDataChannel?: RTCDataChannel;
participants?: GroupCallParticipant[];
conference?: Partial<Conference>;
screenshareConference?: Partial<Conference>;
streams?: Record<string, {
audio?: MediaStream;
video?: MediaStream;
presentation?: MediaStream;
}>;
participantFunctions?: Record<string, {
setVolume?: (volume: number) => void;
toggleMute?: (muted: boolean) => void;
getCurrentAmplitude?: () => number;
}>;
onUpdate?: (...args: any[]) => void;
myId?: string;
black?: MediaStream;
silence?: MediaStream;
updatingParticipantsQueue?: any[];
facingMode?: VideoFacingModeEnum;
isSpeakerDisabled?: boolean;
analyserInterval?: number;
speaking?: Record<string, number>;
audioElement?: HTMLAudioElement;
destination?: MediaStreamAudioDestinationNode;
audioContext?: AudioContext;
mediaStream?: MediaStream;
lastMid: number;
};
let state: GroupCallState | undefined;
export async function getDevices(streamType: StreamType, isInput = true) {
return (await navigator.mediaDevices.enumerateDevices())
.filter((l) => l.kind === `${streamType}${isInput ? 'input' : 'output'}`);
}
export function toggleSpeaker() {
if (!state) {
return;
}
state.isSpeakerDisabled = !state.isSpeakerDisabled;
state?.onUpdate?.({
'@type': 'updateGroupCallConnectionState',
connectionState: 'connected',
isSpeakerDisabled: state.isSpeakerDisabled,
});
if (state.participantFunctions) {
Object.values(state.participantFunctions).forEach((l) => {
l.toggleMute?.(!!state?.isSpeakerDisabled);
});
}
}
function leavePresentation(isFromToggle?: boolean) {
if (!state) {
return;
}
state.screenshareDataChannel?.close();
state.screenshareConnection?.close();
if (!isFromToggle) {
state.onUpdate?.({
'@type': 'updateGroupCallLeavePresentation',
});
}
}
export function toggleNoiseSuppression() {
if (!state || !state.myId || !state.streams) {
return;
}
const audioStream = state.streams[state.myId].audio;
if (!audioStream) {
return;
}
const track = audioStream.getTracks()[0];
if (!track) {
return;
}
// @ts-ignore
const { echoCancellation, noiseSuppression } = track.getConstraints();
track.applyConstraints({
echoCancellation: !echoCancellation,
// @ts-ignore
noiseSuppression: !noiseSuppression,
});
}
export function getUserStreams(userId: string) {
return state?.streams?.[userId];
}
export function setVolume(userId: string, volume: number) {
const participantFunctions = state?.participantFunctions?.[userId];
if (!participantFunctions) return;
participantFunctions.setVolume?.(volume);
}
export function isStreamEnabled(streamType: StreamType, userId?: string) {
const id = userId || state?.myId;
const stream = id && getUserStreams(id)?.[streamType];
if (!stream) return false;
return stream.getTracks()[0]?.enabled;
}
function updateGroupCallStreams(userId: string) {
state?.onUpdate?.({
'@type': 'updateGroupCallStreams',
userId,
hasAudioStream: isStreamEnabled('audio', userId),
hasVideoStream: isStreamEnabled('video', userId),
hasPresentationStream: isStreamEnabled('presentation', userId),
amplitude: state.speaking?.[userId],
});
}
function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'user') {
if (streamType === 'presentation') {
return (navigator.mediaDevices as any).getDisplayMedia({
audio: false,
video: true,
});
}
return navigator.mediaDevices.getUserMedia({
audio: streamType === 'audio' ? {
// @ts-ignore
...(IS_ECHO_CANCELLATION_SUPPORTED && { echoCancellation: true }),
...(IS_NOISE_SUPPRESSION_SUPPORTED && { noiseSuppression: true }),
} : false,
video: streamType === 'video' ? {
facingMode: facing,
} : false,
});
}
export async function switchCameraInput() {
if (!state?.myId || !state.connection || !state.streams || !state.facingMode) {
return;
}
const stream = getUserStreams(state.myId)?.video;
if (!stream) return;
const track = stream.getTracks()[0];
if (!track) {
return;
}
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
if (!sender) {
return;
}
state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment';
try {
const newStream = await getUserStream('video', state.facingMode);
await sender.replaceTrack(newStream.getTracks()[0]);
state.streams[state.myId].video = newStream;
} catch (e) {
}
}
export async function toggleStream(streamType: StreamType, value: boolean | undefined = undefined) {
if (!state || !state.myId || !state.connection || !state.streams) {
return;
}
const stream = getUserStreams(state.myId)?.[streamType];
if (!stream) return;
const track = stream.getTracks()[0];
if (!track) {
return;
}
const sender = [
...state.connection.getSenders(),
...(state.screenshareConnection?.getSenders() || []),
].find((l) => track.id === l.track?.id);
if (!sender) {
return;
}
value = value === undefined ? !track.enabled : value;
try {
if (value && !track.enabled) {
const newStream = await getUserStream(streamType);
await sender.replaceTrack(newStream.getTracks()[0]);
state.streams[state.myId][streamType] = newStream;
if (streamType === 'video') {
state.facingMode = 'user';
} else if (streamType === 'audio') {
const { audioContext } = state;
if (!audioContext) return;
const source = audioContext.createMediaStreamSource(newStream);
const analyser = audioContext.createAnalyser();
analyser.minDecibels = -100;
analyser.maxDecibels = -30;
analyser.smoothingTimeConstant = 0.05;
analyser.fftSize = 1024;
source.connect(analyser);
state = {
...state,
participantFunctions: {
...state.participantFunctions,
[state.myId]: {
...state.participantFunctions?.[state.myId],
getCurrentAmplitude: () => {
const array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(array);
return getAmplitude(array, 1.5);
},
},
},
};
}
} else if (!value && track.enabled) {
track.stop();
const newStream = streamType === 'audio' ? state.silence : state.black;
if (!newStream) return;
await sender.replaceTrack(newStream.getTracks()[0]);
state.streams[state.myId][streamType] = newStream;
if (streamType === 'video') {
state.facingMode = undefined;
}
}
updateGroupCallStreams(state.myId!);
if (streamType === 'presentation' && !value) leavePresentation(true);
} catch (e) {
}
}
function updateConnectionState(connectionState: GroupCallConnectionState) {
state?.onUpdate?.({
'@type': 'updateGroupCallConnectionState',
connectionState,
});
}
export function leaveGroupCall() {
if (!state) {
return;
}
if (state.myId && state.streams?.[state.myId]) {
Object.values(state.streams[state.myId] || {}).forEach((stream) => {
stream?.getTracks().forEach((track) => {
track.stop();
});
});
}
leavePresentation(true);
state.dataChannel?.close();
state.connection?.close();
updateConnectionState('disconnected');
if (state.analyserInterval) {
clearInterval(state.analyserInterval);
}
state = undefined;
}
function analyzeAmplitudes() {
if (!state || !state.participantFunctions) return;
Object.keys(state.participantFunctions).forEach((id) => {
const { getCurrentAmplitude } = state!.participantFunctions![Number(id)];
if (getCurrentAmplitude) {
const amplitude = getCurrentAmplitude();
const prevAmplitude = state!.speaking![id] || 0;
state!.speaking![id] = amplitude;
if ((amplitude > THRESHOLD && prevAmplitude <= THRESHOLD)
|| (amplitude <= THRESHOLD && prevAmplitude > THRESHOLD)) {
updateGroupCallStreams(id);
}
}
});
}
function createDataChannel(connection: RTCPeerConnection) {
const dataChannel = connection.createDataChannel('data', {
id: 0,
});
dataChannel.onopen = () => {
// console.log('Data channel open!');
};
dataChannel.onmessage = (e) => {
// console.log('onmessage');
const data = JSON.parse(e.data) as ColibriClass;
// console.log(data);
switch (data.colibriClass) {
case 'DominantSpeakerEndpointChangeEvent':
break;
case 'SenderVideoConstraints':
break;
case 'EndpointConnectivityStatusChangeEvent':
break;
}
};
dataChannel.onerror = (e) => {
console.log('%conerror', 'background: green; font-size: 5em');
console.error(e);
};
return dataChannel;
}
export async function handleUpdateGroupCallParticipants(updatedParticipants: GroupCallParticipant[]) {
if (!state) {
return;
}
const {
participants, conference, connection, myId,
} = state;
if (!participants || !conference || !connection || !conference.ssrcs || !conference.transport || !myId) {
return;
}
// Joined from another client
if (updatedParticipants.find((participant) => {
return participant.isSelf
&& participant.source
!== state?.conference?.ssrcs?.find((l) => l.isMain && !l.isVideo)?.sourceGroups[0].sources[0];
})) {
leaveGroupCall();
return;
}
const newEndpoints: string[] = [];
updatedParticipants.forEach((participant) => {
console.log('handleUpdateGroupCallParticipants', participant);
if (participant.isSelf) {
if (participant.isMuted && !participant.canSelfUnmute) {
// Muted by admin
toggleStream('audio', false);
toggleStream('video', false);
toggleStream('presentation', false);
}
return;
}
const { isLeft } = participant;
const isAudioLeft = participant.isMuted || participant.isMutedByMe;
const isVideoLeft = !participant.isVideoJoined || !participant.video || isLeft;
const isPresentationLeft = !participant.presentation || isLeft;
let hasVideo = false;
let hasAudio = false;
let hasPresentation = false;
conference.ssrcs!.filter((l) => l.userId === participant.id).forEach((ssrc) => {
if (!ssrc.isVideo) {
if (ssrc.sourceGroups[0].sources[0] === participant.source) {
hasAudio = true;
}
// console.log('has audio, removed=', isAudioLeft);
ssrc.isRemoved = isAudioLeft;
}
if (ssrc.isVideo) {
if (!ssrc.isPresentation) {
if (!!participant.video && ssrc.endpoint === participant.video.endpoint) {
hasVideo = true;
}
// console.log('has video = ', hasVideo, ' removed=', isVideoLeft);
ssrc.isRemoved = isVideoLeft;
}
if (ssrc.isPresentation) {
if (!!participant.presentation && ssrc.endpoint === participant.presentation.endpoint) {
hasPresentation = true;
}
// console.log('has presentation, removed=', isPresentationLeft);
ssrc.isRemoved = isPresentationLeft;
}
}
});
if (!isAudioLeft && !hasAudio) {
// console.log('add audio');
state!.lastMid = state!.lastMid + 1;
conference.ssrcs!.push({
userId: participant.id,
isMain: false,
endpoint: `audio${participant.source}`,
isVideo: false,
sourceGroups: [{
semantics: 'FID',
sources: [participant.source],
}],
mid: state!.lastMid.toString()
});
}
if (!isVideoLeft && !hasVideo && participant.video) {
// console.log('add video', participant.video);
state!.lastMid = state!.lastMid + 1;
newEndpoints.push(participant.video.endpoint);
conference.ssrcs!.push({
userId: participant.id,
isMain: false,
endpoint: participant.video.endpoint,
isVideo: true,
sourceGroups: participant.video.sourceGroups,
mid: state!.lastMid.toString()
});
}
if (!isPresentationLeft && !hasPresentation && participant.presentation) {
// console.log('add presentation');
state!.lastMid = state!.lastMid + 1;
conference.ssrcs!.push({
isPresentation: true,
userId: participant.id,
isMain: false,
endpoint: participant.presentation.endpoint,
isVideo: true,
sourceGroups: participant.presentation.sourceGroups,
mid: state!.lastMid.toString()
});
}
});
if (state.updatingParticipantsQueue) {
state.updatingParticipantsQueue.push(conference);
return;
} else {
state.updatingParticipantsQueue = [];
}
const sdp = buildSdp(conference as Conference);
console.log('build sdp!', sdp);
await connection.setRemoteDescription({
type: 'offer',
sdp,
});
try {
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
updateGroupCallStreams(myId);
if (state.updatingParticipantsQueue.length > 0) {
// eslint-disable-next-line no-restricted-syntax
for (const newConference of state.updatingParticipantsQueue) {
await connection.setRemoteDescription({
type: 'offer',
sdp: buildSdp(newConference as Conference),
});
const answerNew = await connection.createAnswer();
await connection.setLocalDescription(answerNew);
updateGroupCallStreams(myId);
// if (newEndpoints.length > 0) {
// sendDataChannelMessage({
// colibriClass: 'ReceiverVideoConstraints',
// defaultConstraints: {
// maxHeight: 0,
// },
// constraints: {
// ...(newEndpoints.reduce((acc: Record<string, any>, el) => {
// acc[el] = {
// minHeight: 0,
// maxHeight: 1080,
// };
// return acc;
// }, {})),
// },
// onStageEndpoints: [],
// });
// }
}
}
state.updatingParticipantsQueue = undefined;
} catch (e) {
console.error(e);
}
}
//
// function sendDataChannelMessage(message: ColibriClass) {
// if (!state || !state.dataChannel || state.dataChannel.readyState !== 'open') {
// return;
// }
//
// // console.log('SEND!', message);
// state.dataChannel.send(JSON.stringify(message));
// }
export async function handleUpdateGroupCallConnection(data: GroupCallConnectionData, isPresentation: boolean) {
if (!state) {
return;
}
const conference = isPresentation ? state.screenshareConference : state.conference;
const connection = isPresentation ? state.screenshareConnection : state.connection;
if (!conference || !connection || !conference.ssrcs) {
return;
}
const sessionId = Date.now();
const newConference = {
...conference,
transport: data.transport,
sessionId,
audioExtensions: data.audio?.['rtp-hdrexts'],
audioPayloadTypes: data.audio?.['payload-types'],
videoExtensions: data.video?.['rtp-hdrexts'],
videoPayloadTypes: data.video?.['payload-types'],
} as Conference;
state = {
...state,
...(!isPresentation ? { conference: newConference } : { screenshareConference: newConference }),
};
console.warn('update remote description', newConference, buildSdp(newConference, true, isPresentation));
try {
await connection.setRemoteDescription({
type: 'answer',
sdp: buildSdp(newConference, true, isPresentation),
});
// state.resolveTest();
// state.test = true;
} catch (e) {
console.error(e);
}
}
function handleTrack(e: RTCTrackEvent) {
if (!state || !state.audioElement || !state.audioContext || !state.mediaStream) {
return;
}
const ssrc = state.conference?.ssrcs?.find((l) => l.endpoint === e.track.id);
if (!ssrc || !ssrc.userId) {
return;
}
const { userId, isPresentation } = ssrc;
const participant = state.participants?.find((p) => p.id === userId);
const streamType = (e.track.kind === 'video' ? (isPresentation ? 'presentation' : 'video') : 'audio') as StreamType;
e.track.onended = () => {
delete state?.streams?.[userId][streamType];
updateGroupCallStreams(userId);
};
const stream = e.streams[0];
if (e.track.kind === 'audio') {
const { mediaStream } = state;
const audioContext = new (window.AudioContext)();
const source = audioContext.createMediaStreamSource(stream);
const gainNode = audioContext.createGain();
gainNode.gain.value = (participant?.volume || 10000) / 10000;
const muteNode = audioContext.createGain();
gainNode.gain.value = 1;
const analyser = audioContext.createAnalyser();
analyser.minDecibels = -100;
analyser.maxDecibels = -30;
analyser.smoothingTimeConstant = 0.05;
analyser.fftSize = 1024;
source.connect(analyser).connect(muteNode).connect(gainNode).connect(audioContext.destination);
mediaStream!.addTrack(source.mediaStream.getAudioTracks()[0]);
// https://stackoverflow.com/questions/41784137/webrtc-doesnt-work-with-audiocontext#comment117600018_41784241
const test = new Audio();
test.srcObject = stream;
// test.srcObject = source.mediaStream;
test.muted = true;
test.remove();
state = {
...state,
participantFunctions: {
...state.participantFunctions,
[userId]: {
...state.participantFunctions?.[userId],
setVolume: (volume: number) => {
gainNode.gain.value = volume > 1 ? volume * 2 : volume;
},
toggleMute: (muted?: boolean) => {
muteNode.gain.value = muted ? 0 : 1;
},
getCurrentAmplitude: () => {
const array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(array);
return getAmplitude(array, 1.5);
},
},
},
};
}
state = {
...state,
streams: {
...state.streams,
[userId]: {
...state.streams?.[userId],
[streamType]: stream,
},
},
};
updateGroupCallStreams(userId);
}
function initializeConnection(
streams: MediaStream[],
resolve: (payload: JoinGroupCallPayload) => void,
isPresentation = false,
) {
const connection = new RTCPeerConnection();
const dataChannel = isPresentation ? undefined : createDataChannel(connection);
streams.forEach((stream) => stream.getTracks().forEach((track) => {
connection.addTrack(track, stream);
}));
if (!isPresentation) {
connection.oniceconnectionstatechange = () => {
const connectionState = connection.iceConnectionState;
console.log('ice', connectionState);
if (connectionState === 'connected' || connectionState === 'completed') {
updateConnectionState('connected');
} else if (connectionState === 'checking' || connectionState === 'new') {
updateConnectionState('connecting');
} else if (connection.iceConnectionState === 'disconnected') {
updateConnectionState('reconnecting');
}
};
}
connection.ontrack = handleTrack;
connection.onnegotiationneeded = async () => {
if (!state) return;
const { myId } = state;
if (!myId) {
return;
}
const offer = await connection.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: !isPresentation,
});
console.log('created offer!', offer);
await connection.setLocalDescription(offer);
if (!offer.sdp) {
return;
}
const sdp = parseSdp(offer);
console.log('parsed sdp', sdp);
const audioSsrc: Ssrc | undefined = !isPresentation ? {
userId: '',
sourceGroups: [
{
semantics: 'FID',
sources: [sdp.ssrc || 0],
},
],
isRemoved: isPresentation,
isMain: true,
isVideo: false,
isPresentation,
endpoint: isPresentation ? '1' : '0',
mid: isPresentation ? '1' : '0'
} : undefined;
const videoSsrc: Ssrc | undefined = sdp['ssrc-groups'] && {
isPresentation,
userId: '',
sourceGroups: sdp['ssrc-groups'],
isMain: true,
isVideo: true,
endpoint: isPresentation ? '0' : '1',
mid: isPresentation ? '0' : '1'
};
const conference = isPresentation ? state.screenshareConference : state.conference;
const ssrcs: Ssrc[] = [];
if (isPresentation) {
if (videoSsrc) ssrcs.push(videoSsrc);
if (audioSsrc) ssrcs.push(audioSsrc);
} else {
if (audioSsrc) ssrcs.push(audioSsrc);
if (videoSsrc) ssrcs.push(videoSsrc);
}
const audioStream = streams.find((l) => l.getTracks()[0].kind === 'audio');
const videoStream = streams.find((l) => l.getTracks()[0].kind === 'video');
state = {
...state,
...(!isPresentation ? {
conference: {
...conference,
ssrcs,
},
} : {
screenshareConference: {
...conference,
ssrcs,
},
}),
streams: {
...state.streams,
[myId]: {
...state.streams?.[myId],
...(audioStream && { audio: audioStream }),
...(!isPresentation && videoStream ? { video: videoStream } : { presentation: videoStream }),
},
},
};
updateGroupCallStreams(myId);
resolve(sdp);
};
return { connection, dataChannel };
}
export async function startSharingScreen(): Promise<JoinGroupCallPayload | undefined> {
if (!state) {
return undefined;
}
try {
const stream: MediaStream | undefined = await getUserStream('presentation');
if (!stream) {
return undefined;
}
stream.getTracks()[0].onended = () => {
if (state && state.myId) {
delete state.streams?.[state.myId].presentation;
updateGroupCallStreams(state.myId);
leavePresentation();
}
};
return await new Promise((resolve) => {
const { connection, dataChannel } = initializeConnection([stream], resolve, true);
state = {
...state!,
screenshareConnection: connection,
screenshareDataChannel: dataChannel,
};
});
} catch (e) {
return undefined;
}
}
export function joinGroupCall(
myId: string,
audioContext: AudioContext,
audioElement: HTMLAudioElement,
onUpdate: (...args: any[]) => void,
): Promise<JoinGroupCallPayload> {
if (state) {
throw Error('Already in call');
}
updateConnectionState('connecting');
const mediaStream = new MediaStream();
audioElement.srcObject = mediaStream;
audioElement.play().catch((l) => console.warn(l));
state = {
onUpdate,
participants: [],
myId,
speaking: {},
silence: silence(audioContext),
black: black({ width: 640, height: 480 }),
// @ts-ignore
analyserInterval: setInterval(analyzeAmplitudes, 1000),
audioElement,
// destination,
audioContext,
mediaStream,
lastMid: DEFAULT_MID,
};
return new Promise((resolve) => {
state = {
...state!,
...initializeConnection([state!.silence!, state!.black!], resolve),
};
});
}