Move toju-app into own its folder
This commit is contained in:
697
toju-app/src/app/infrastructure/realtime/media/media.manager.ts
Normal file
697
toju-app/src/app/infrastructure/realtime/media/media.manager.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars,, id-length */
|
||||
/**
|
||||
* Manages local voice media: getUserMedia, mute, deafen,
|
||||
* attaching/detaching audio tracks to peer connections, bitrate tuning,
|
||||
* and optional RNNoise-based noise reduction.
|
||||
*/
|
||||
import { Subject } from 'rxjs';
|
||||
import { ChatEvent } from '../../../shared-kernel';
|
||||
import { LatencyProfile } from '../realtime.constants';
|
||||
import { PeerData } from '../realtime.types';
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
import { NoiseReductionManager } from './noise-reduction.manager';
|
||||
import {
|
||||
TRACK_KIND_AUDIO,
|
||||
TRACK_KIND_VIDEO,
|
||||
TRANSCEIVER_SEND_RECV,
|
||||
TRANSCEIVER_RECV_ONLY,
|
||||
TRANSCEIVER_INACTIVE,
|
||||
AUDIO_BITRATE_MIN_BPS,
|
||||
AUDIO_BITRATE_MAX_BPS,
|
||||
KBPS_TO_BPS,
|
||||
LATENCY_PROFILE_BITRATES,
|
||||
VOLUME_MIN,
|
||||
VOLUME_MAX,
|
||||
VOICE_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_VOICE_STATE
|
||||
} from '../realtime.constants';
|
||||
|
||||
/**
|
||||
* Callbacks the MediaManager needs from the owning service / peer manager.
|
||||
*/
|
||||
export interface MediaManagerCallbacks {
|
||||
/** All active peer connections (for attaching tracks). */
|
||||
getActivePeers(): Map<string, PeerData>;
|
||||
/** Trigger SDP renegotiation for a specific peer. */
|
||||
renegotiate(peerId: string): Promise<void>;
|
||||
/** Broadcast a message to all peers. */
|
||||
broadcastMessage(event: ChatEvent): void;
|
||||
/** Get identify credentials (for broadcasting). */
|
||||
getIdentifyOderId(): string;
|
||||
getIdentifyDisplayName(): string;
|
||||
}
|
||||
|
||||
export class MediaManager {
|
||||
/** The stream sent to peers (may be raw or denoised). */
|
||||
private localMediaStream: MediaStream | null = null;
|
||||
|
||||
/**
|
||||
* The raw microphone stream from `getUserMedia`.
|
||||
* Kept separately so noise reduction can be toggled
|
||||
* without re-acquiring the mic.
|
||||
*/
|
||||
private rawMicStream: MediaStream | null = null;
|
||||
|
||||
/** Remote audio output volume (0-1). */
|
||||
private remoteAudioVolume = VOLUME_MAX;
|
||||
|
||||
// -- Input gain pipeline (mic volume) --
|
||||
/** The stream BEFORE gain is applied (for identity checks). */
|
||||
private preGainStream: MediaStream | null = null;
|
||||
private inputGainCtx: AudioContext | null = null;
|
||||
private inputGainSourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
private inputGainNode: GainNode | null = null;
|
||||
private inputGainDest: MediaStreamAudioDestinationNode | null = null;
|
||||
/** Normalised 0-1 input gain (1 = 100%). */
|
||||
private inputGainVolume = 1.0;
|
||||
|
||||
/** Voice-presence heartbeat timer. */
|
||||
private voicePresenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Emitted when voice is successfully connected. */
|
||||
readonly voiceConnected$ = new Subject<void>();
|
||||
|
||||
/** RNNoise noise-reduction processor. */
|
||||
private readonly noiseReduction: NoiseReductionManager;
|
||||
|
||||
/**
|
||||
* Tracks the user's *desired* noise-reduction state, independent of
|
||||
* whether the worklet is actually running. This lets us honour the
|
||||
* preference even when it is set before the mic stream is acquired.
|
||||
*/
|
||||
private _noiseReductionDesired = true;
|
||||
|
||||
// State tracked locally (the service exposes these via signals)
|
||||
private isVoiceActive = false;
|
||||
private isMicMuted = false;
|
||||
private isSelfDeafened = false;
|
||||
|
||||
/** Current voice channel room ID (set when joining voice). */
|
||||
private currentVoiceRoomId: string | undefined;
|
||||
/** Current voice channel server ID (set when joining voice). */
|
||||
private currentVoiceServerId: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: MediaManagerCallbacks
|
||||
) {
|
||||
this.noiseReduction = new NoiseReductionManager(logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the callback set at runtime.
|
||||
* Needed because of circular initialisation between managers.
|
||||
*
|
||||
* @param nextCallbacks - The new callback interface to wire into this manager.
|
||||
*/
|
||||
setCallbacks(nextCallbacks: MediaManagerCallbacks): void {
|
||||
this.callbacks = nextCallbacks;
|
||||
}
|
||||
|
||||
/** Returns the current local media stream, or `null` if voice is disabled. */
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.localMediaStream;
|
||||
}
|
||||
/** Returns the raw microphone stream before processing, if available. */
|
||||
getRawMicStream(): MediaStream | null {
|
||||
return this.rawMicStream;
|
||||
}
|
||||
/** Whether voice is currently active (mic captured). */
|
||||
getIsVoiceActive(): boolean {
|
||||
return this.isVoiceActive;
|
||||
}
|
||||
/** Whether the local microphone is muted. */
|
||||
getIsMicMuted(): boolean {
|
||||
return this.isMicMuted;
|
||||
}
|
||||
/** Whether the user has self-deafened. */
|
||||
getIsSelfDeafened(): boolean {
|
||||
return this.isSelfDeafened;
|
||||
}
|
||||
/** Current remote audio output volume (normalised 0-1). */
|
||||
getRemoteAudioVolume(): number {
|
||||
return this.remoteAudioVolume;
|
||||
}
|
||||
/** The voice channel room ID, if currently in voice. */
|
||||
getCurrentVoiceRoomId(): string | undefined {
|
||||
return this.currentVoiceRoomId;
|
||||
}
|
||||
/** The voice channel server ID, if currently in voice. */
|
||||
getCurrentVoiceServerId(): string | undefined {
|
||||
return this.currentVoiceServerId;
|
||||
}
|
||||
/** Whether the user wants noise reduction (may or may not be running yet). */
|
||||
getIsNoiseReductionEnabled(): boolean {
|
||||
return this._noiseReductionDesired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request microphone access via `getUserMedia` and bind the resulting
|
||||
* audio track to every active peer connection.
|
||||
*
|
||||
* If a local stream already exists it is stopped first.
|
||||
*
|
||||
* @returns The captured {@link MediaStream}.
|
||||
* @throws If `getUserMedia` is unavailable (non-secure context) or the user denies access.
|
||||
*/
|
||||
async enableVoice(): Promise<MediaStream> {
|
||||
try {
|
||||
// Stop any existing stream first
|
||||
if (this.localMediaStream) {
|
||||
this.logger.info('Stopping existing local stream before enabling voice');
|
||||
this.localMediaStream.getTracks().forEach((track) => track.stop());
|
||||
this.localMediaStream = null;
|
||||
}
|
||||
|
||||
const mediaConstraints: MediaStreamConstraints = {
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: !this._noiseReductionDesired,
|
||||
autoGainControl: true
|
||||
},
|
||||
video: false
|
||||
};
|
||||
|
||||
this.logger.info('getUserMedia constraints', mediaConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error(
|
||||
'navigator.mediaDevices is not available. ' +
|
||||
'This requires a secure context (HTTPS or localhost). ' +
|
||||
'If accessing from an external device, use HTTPS.'
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
|
||||
this.rawMicStream = stream;
|
||||
|
||||
// If the user wants noise reduction, pipe through the denoiser
|
||||
this.localMediaStream = this._noiseReductionDesired
|
||||
? await this.noiseReduction.enable(stream)
|
||||
: stream;
|
||||
|
||||
// Apply input gain (mic volume) before sending to peers
|
||||
this.applyInputGainToCurrentStream();
|
||||
|
||||
this.logger.logStream('localVoice', this.localMediaStream);
|
||||
|
||||
this.bindLocalTracksToAllPeers();
|
||||
|
||||
this.isVoiceActive = true;
|
||||
this.voiceConnected$.next();
|
||||
return this.localMediaStream;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to getUserMedia', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all local media tracks and remove audio senders from peers.
|
||||
* The peer connections themselves are kept alive.
|
||||
*/
|
||||
disableVoice(): void {
|
||||
this.noiseReduction.disable();
|
||||
this.teardownInputGain();
|
||||
|
||||
// Stop the raw mic tracks (the denoised stream's tracks are
|
||||
// derived nodes and will stop once their source is gone).
|
||||
if (this.rawMicStream) {
|
||||
this.rawMicStream.getTracks().forEach((track) => track.stop());
|
||||
this.rawMicStream = null;
|
||||
}
|
||||
|
||||
this.localMediaStream = null;
|
||||
|
||||
// Remove audio senders but keep connections alive
|
||||
this.callbacks.getActivePeers().forEach((peerData) => {
|
||||
const senders = peerData.connection.getSenders();
|
||||
|
||||
senders.forEach((sender) => {
|
||||
if (sender.track?.kind === TRACK_KIND_AUDIO) {
|
||||
peerData.connection.removeTrack(sender);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.isVoiceActive = false;
|
||||
this.currentVoiceRoomId = undefined;
|
||||
this.currentVoiceServerId = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the local stream from an external source (e.g. voice-controls component).
|
||||
*
|
||||
* The raw stream is saved so noise reduction can be toggled on/off later.
|
||||
* If noise reduction is already enabled the stream is piped through the
|
||||
* denoiser before being sent to peers.
|
||||
*/
|
||||
async setLocalStream(stream: MediaStream): Promise<void> {
|
||||
this.rawMicStream = stream;
|
||||
this.logger.info('setLocalStream - noiseReductionDesired =', this._noiseReductionDesired);
|
||||
|
||||
// Pipe through the denoiser when the user wants noise reduction
|
||||
if (this._noiseReductionDesired) {
|
||||
this.logger.info('Piping new stream through noise reduction');
|
||||
this.localMediaStream = await this.noiseReduction.enable(stream);
|
||||
} else {
|
||||
this.localMediaStream = stream;
|
||||
}
|
||||
|
||||
// Apply input gain (mic volume) before sending to peers
|
||||
this.applyInputGainToCurrentStream();
|
||||
|
||||
this.bindLocalTracksToAllPeers();
|
||||
this.isVoiceActive = true;
|
||||
this.voiceConnected$.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the local microphone mute state.
|
||||
*
|
||||
* @param muted - Explicit state; if omitted, the current state is toggled.
|
||||
*/
|
||||
toggleMute(muted?: boolean): void {
|
||||
const newMutedState = muted !== undefined ? muted : !this.isMicMuted;
|
||||
|
||||
this.isMicMuted = newMutedState;
|
||||
this.applyCurrentMuteState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle self-deafen (suppress all incoming audio playback).
|
||||
*
|
||||
* @param deafened - Explicit state; if omitted, the current state is toggled.
|
||||
*/
|
||||
toggleDeafen(deafened?: boolean): void {
|
||||
this.isSelfDeafened = deafened !== undefined ? deafened : !this.isSelfDeafened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle RNNoise noise reduction on the local microphone.
|
||||
*
|
||||
* When enabled the raw mic stream is routed through the RNNoise
|
||||
* AudioWorklet and peer senders are updated with the denoised track.
|
||||
* When disabled the original raw mic track is restored.
|
||||
*
|
||||
* @param enabled - Explicit state; if omitted, the current state is toggled.
|
||||
*/
|
||||
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
|
||||
const shouldEnable = enabled !== undefined ? enabled : !this._noiseReductionDesired;
|
||||
|
||||
// Always persist the preference
|
||||
this._noiseReductionDesired = shouldEnable;
|
||||
this.logger.info(
|
||||
'Noise reduction desired =',
|
||||
shouldEnable,
|
||||
'| worklet active =',
|
||||
this.noiseReduction.isEnabled
|
||||
);
|
||||
|
||||
// Do not update the browser's built-in noiseSuppression constraint on the
|
||||
// live mic track here. Chromium may share the underlying capture source,
|
||||
// which can leak the constraint change into other active streams. We only
|
||||
// apply the browser constraint when the microphone stream is acquired.
|
||||
|
||||
if (shouldEnable === this.noiseReduction.isEnabled)
|
||||
return;
|
||||
|
||||
if (shouldEnable) {
|
||||
if (!this.rawMicStream) {
|
||||
this.logger.warn(
|
||||
'Cannot enable noise reduction - no mic stream yet (will apply on connect)'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Enabling noise reduction on raw mic stream');
|
||||
const cleanStream = await this.noiseReduction.enable(this.rawMicStream);
|
||||
|
||||
this.localMediaStream = cleanStream;
|
||||
} else {
|
||||
this.noiseReduction.disable();
|
||||
|
||||
if (this.rawMicStream) {
|
||||
this.localMediaStream = this.rawMicStream;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply input gain to the (possibly new) stream
|
||||
this.applyInputGainToCurrentStream();
|
||||
|
||||
// Propagate the new audio track to every peer connection
|
||||
this.bindLocalTracksToAllPeers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the output volume for remote audio.
|
||||
*
|
||||
* @param volume - Normalised value: 0 = silent, 1 = 100%, up to 2 = 200%.
|
||||
*/
|
||||
setOutputVolume(volume: number): void {
|
||||
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(2, volume));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the input (microphone) volume.
|
||||
*
|
||||
* If a local stream is active the gain node is updated in real time.
|
||||
* If no stream exists yet the value is stored and applied on connect.
|
||||
*
|
||||
* @param volume - Normalised 0-1 (0 = silent, 1 = 100%).
|
||||
*/
|
||||
setInputVolume(volume: number): void {
|
||||
this.inputGainVolume = Math.max(0, Math.min(1, volume));
|
||||
|
||||
if (this.inputGainNode) {
|
||||
// Pipeline already exists - just update the gain value
|
||||
this.inputGainNode.gain.value = this.inputGainVolume;
|
||||
} else if (this.localMediaStream) {
|
||||
// Stream is active but gain pipeline hasn't been created yet
|
||||
this.applyInputGainToCurrentStream();
|
||||
this.bindLocalTracksToAllPeers();
|
||||
}
|
||||
}
|
||||
|
||||
/** Get current input gain value (0-1). */
|
||||
getInputVolume(): number {
|
||||
return this.inputGainVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum audio bitrate on every active peer's audio sender.
|
||||
*
|
||||
* The value is clamped between {@link AUDIO_BITRATE_MIN_BPS} and
|
||||
* {@link AUDIO_BITRATE_MAX_BPS}.
|
||||
*
|
||||
* @param kbps - Target bitrate in kilobits per second.
|
||||
*/
|
||||
async setAudioBitrate(kbps: number): Promise<void> {
|
||||
const targetBps = Math.max(
|
||||
AUDIO_BITRATE_MIN_BPS,
|
||||
Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS))
|
||||
);
|
||||
|
||||
this.callbacks.getActivePeers().forEach(async (peerData) => {
|
||||
const sender =
|
||||
peerData.audioSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!sender?.track)
|
||||
return;
|
||||
|
||||
if (peerData.connection.signalingState !== 'stable')
|
||||
return;
|
||||
|
||||
let params: RTCRtpSendParameters;
|
||||
|
||||
try {
|
||||
params = sender.getParameters();
|
||||
} catch (error) {
|
||||
this.logger.warn('getParameters failed; skipping bitrate apply', error);
|
||||
return;
|
||||
}
|
||||
|
||||
params.encodings = params.encodings || [{}];
|
||||
params.encodings[0].maxBitrate = targetBps;
|
||||
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
this.logger.info('Applied audio bitrate', { targetBps });
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to set audio bitrate', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a named latency profile that maps to a predefined bitrate.
|
||||
*
|
||||
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
|
||||
*/
|
||||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||||
await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodically broadcasting voice presence to all peers.
|
||||
*
|
||||
* Optionally records the voice room/server so heartbeats include them.
|
||||
*
|
||||
* @param roomId - The voice channel room ID.
|
||||
* @param serverId - The voice channel server ID.
|
||||
*/
|
||||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
||||
this.stopVoiceHeartbeat();
|
||||
|
||||
// Persist voice channel context so heartbeats and state snapshots include it
|
||||
if (roomId !== undefined)
|
||||
this.currentVoiceRoomId = roomId;
|
||||
|
||||
if (serverId !== undefined)
|
||||
this.currentVoiceServerId = serverId;
|
||||
|
||||
this.voicePresenceTimer = setInterval(() => {
|
||||
if (this.isVoiceActive) {
|
||||
this.broadcastVoicePresence();
|
||||
}
|
||||
}, VOICE_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
// Also send an immediate heartbeat
|
||||
if (this.isVoiceActive) {
|
||||
this.broadcastVoicePresence();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the voice-presence heartbeat timer. */
|
||||
stopVoiceHeartbeat(): void {
|
||||
if (this.voicePresenceTimer) {
|
||||
clearInterval(this.voicePresenceTimer);
|
||||
this.voicePresenceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind local audio/video tracks to all existing peer transceivers.
|
||||
* Restores transceiver direction to sendrecv if previously set to recvonly
|
||||
* (which happens when disableVoice calls removeTrack).
|
||||
*/
|
||||
private bindLocalTracksToAllPeers(): void {
|
||||
const peers = this.callbacks.getActivePeers();
|
||||
|
||||
if (!this.localMediaStream)
|
||||
return;
|
||||
|
||||
const localStream = this.localMediaStream;
|
||||
const localAudioTrack = localStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = localStream.getVideoTracks()[0] || null;
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
if (localAudioTrack) {
|
||||
const audioTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_AUDIO, {
|
||||
preferredSender: peerData.audioSender,
|
||||
excludedSenders: [peerData.screenAudioSender]
|
||||
});
|
||||
const audioSender = audioTransceiver.sender;
|
||||
|
||||
peerData.audioSender = audioSender;
|
||||
|
||||
// Restore direction after removeTrack (which sets it to recvonly)
|
||||
if (
|
||||
audioTransceiver &&
|
||||
(audioTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
audioTransceiver.direction === TRANSCEIVER_INACTIVE)
|
||||
) {
|
||||
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof audioSender.setStreams === 'function') {
|
||||
audioSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
audioSender
|
||||
.replaceTrack(localAudioTrack)
|
||||
.then(() => this.logger.info('audio replaceTrack ok', { peerId }))
|
||||
.catch((error) => this.logger.error('audio replaceTrack failed', error));
|
||||
}
|
||||
|
||||
if (localVideoTrack) {
|
||||
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
||||
preferredSender: peerData.videoSender,
|
||||
excludedSenders: [peerData.screenVideoSender]
|
||||
});
|
||||
const videoSender = videoTransceiver.sender;
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
if (
|
||||
videoTransceiver &&
|
||||
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
videoTransceiver.direction === TRANSCEIVER_INACTIVE)
|
||||
) {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
videoSender
|
||||
.replaceTrack(localVideoTrack)
|
||||
.then(() => this.logger.info('video replaceTrack ok', { peerId }))
|
||||
.catch((error) => this.logger.error('video replaceTrack failed', error));
|
||||
}
|
||||
|
||||
this.callbacks.renegotiate(peerId);
|
||||
});
|
||||
}
|
||||
|
||||
private getOrCreateReusableTransceiver(
|
||||
peerData: PeerData,
|
||||
kind: typeof TRACK_KIND_AUDIO | typeof TRACK_KIND_VIDEO,
|
||||
options: {
|
||||
preferredSender?: RTCRtpSender;
|
||||
excludedSenders?: (RTCRtpSender | undefined)[];
|
||||
}
|
||||
): RTCRtpTransceiver {
|
||||
const excludedSenders = new Set(
|
||||
(options.excludedSenders ?? []).filter((sender): sender is RTCRtpSender => !!sender)
|
||||
);
|
||||
const existingTransceivers = peerData.connection.getTransceivers();
|
||||
const preferredTransceiver = options.preferredSender
|
||||
? existingTransceivers.find((transceiver) => transceiver.sender === options.preferredSender)
|
||||
: null;
|
||||
|
||||
if (preferredTransceiver) {
|
||||
return preferredTransceiver;
|
||||
}
|
||||
|
||||
const attachedSenderTransceiver = existingTransceivers.find((transceiver) =>
|
||||
!excludedSenders.has(transceiver.sender)
|
||||
&& transceiver.sender.track?.kind === kind
|
||||
);
|
||||
|
||||
if (attachedSenderTransceiver) {
|
||||
return attachedSenderTransceiver;
|
||||
}
|
||||
|
||||
const reusableReceiverTransceiver = existingTransceivers.find((transceiver) =>
|
||||
!excludedSenders.has(transceiver.sender)
|
||||
&& !transceiver.sender.track
|
||||
&& transceiver.receiver.track?.kind === kind
|
||||
);
|
||||
|
||||
if (reusableReceiverTransceiver) {
|
||||
return reusableReceiverTransceiver;
|
||||
}
|
||||
|
||||
return peerData.connection.addTransceiver(kind, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast a voice-presence state event to all connected peers. */
|
||||
private broadcastVoicePresence(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||
|
||||
this.callbacks.broadcastMessage({
|
||||
type: P2P_TYPE_VOICE_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
voiceState: {
|
||||
isConnected: this.isVoiceActive,
|
||||
isMuted: this.isMicMuted,
|
||||
isDeafened: this.isSelfDeafened,
|
||||
roomId: this.currentVoiceRoomId,
|
||||
serverId: this.currentVoiceServerId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Input gain helpers --
|
||||
|
||||
/**
|
||||
* Route the current `localMediaStream` through a Web Audio GainNode so
|
||||
* the microphone level can be adjusted without renegotiating peers.
|
||||
*
|
||||
* If a gain pipeline already exists for the same source stream the gain
|
||||
* value is simply updated. Otherwise a new pipeline is created.
|
||||
*/
|
||||
private applyInputGainToCurrentStream(): void {
|
||||
const stream = this.localMediaStream;
|
||||
|
||||
if (!stream)
|
||||
return;
|
||||
|
||||
// If the source stream hasn't changed, just update gain
|
||||
if (this.preGainStream === stream && this.inputGainNode && this.inputGainCtx) {
|
||||
this.inputGainNode.gain.value = this.inputGainVolume;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tear down the old pipeline (if any)
|
||||
this.teardownInputGain();
|
||||
|
||||
// Build new pipeline: source → gain → destination
|
||||
this.preGainStream = stream;
|
||||
this.inputGainCtx = new AudioContext();
|
||||
this.inputGainSourceNode = this.inputGainCtx.createMediaStreamSource(stream);
|
||||
this.inputGainNode = this.inputGainCtx.createGain();
|
||||
this.inputGainNode.gain.value = this.inputGainVolume;
|
||||
this.inputGainDest = this.inputGainCtx.createMediaStreamDestination();
|
||||
|
||||
this.inputGainSourceNode.connect(this.inputGainNode);
|
||||
this.inputGainNode.connect(this.inputGainDest);
|
||||
|
||||
// Replace localMediaStream with the gained stream
|
||||
this.localMediaStream = this.inputGainDest.stream;
|
||||
this.applyCurrentMuteState();
|
||||
}
|
||||
|
||||
/** Keep the active outbound track aligned with the stored mute state. */
|
||||
private applyCurrentMuteState(): void {
|
||||
if (!this.localMediaStream)
|
||||
return;
|
||||
|
||||
const enabled = !this.isMicMuted;
|
||||
|
||||
this.localMediaStream.getAudioTracks().forEach((track) => {
|
||||
track.enabled = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
/** Disconnect and close the input-gain AudioContext. */
|
||||
private teardownInputGain(): void {
|
||||
try {
|
||||
this.inputGainSourceNode?.disconnect();
|
||||
this.inputGainNode?.disconnect();
|
||||
} catch (error) {
|
||||
this.logger.warn('Input gain nodes were already disconnected during teardown', error);
|
||||
}
|
||||
|
||||
if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') {
|
||||
this.inputGainCtx.close().catch((error) => {
|
||||
this.logger.warn('Failed to close input gain audio context', error);
|
||||
});
|
||||
}
|
||||
|
||||
this.inputGainCtx = null;
|
||||
this.inputGainSourceNode = null;
|
||||
this.inputGainNode = null;
|
||||
this.inputGainDest = null;
|
||||
this.preGainStream = null;
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.teardownInputGain();
|
||||
this.disableVoice();
|
||||
this.stopVoiceHeartbeat();
|
||||
this.noiseReduction.destroy();
|
||||
this.voiceConnected$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/**
|
||||
* Manages RNNoise-based noise reduction for microphone audio.
|
||||
*
|
||||
* Uses the `@timephy/rnnoise-wasm` AudioWorklet to process the raw
|
||||
* microphone stream through a neural-network noise gate, producing
|
||||
* a clean output stream that can be sent to peers instead.
|
||||
*
|
||||
* Architecture:
|
||||
* raw mic → AudioContext.createMediaStreamSource
|
||||
* → NoiseSuppressorWorklet (AudioWorkletNode)
|
||||
* → MediaStreamDestination → clean MediaStream
|
||||
*
|
||||
* The manager is intentionally stateless w.r.t. Angular signals;
|
||||
* the owning MediaManager / WebRTCService drives signals.
|
||||
*/
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
|
||||
/** Name used to register / instantiate the AudioWorklet processor. */
|
||||
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
|
||||
/** RNNoise is trained on 48 kHz audio - the AudioContext must match. */
|
||||
const RNNOISE_SAMPLE_RATE = 48_000;
|
||||
/**
|
||||
* Relative path (from the served application root) to the **bundled**
|
||||
* worklet script placed in `public/` and served as a static asset.
|
||||
*/
|
||||
const WORKLET_MODULE_PATH = 'rnnoise-worklet.js';
|
||||
|
||||
export class NoiseReductionManager {
|
||||
/** The AudioContext used for the noise-reduction graph. */
|
||||
private audioContext: AudioContext | null = null;
|
||||
|
||||
/** Source node wrapping the raw microphone stream. */
|
||||
private sourceNode: MediaStreamAudioSourceNode | null = null;
|
||||
|
||||
/** The RNNoise AudioWorklet node. */
|
||||
private workletNode: AudioWorkletNode | null = null;
|
||||
|
||||
/** Destination node that exposes the cleaned stream. */
|
||||
private destinationNode: MediaStreamAudioDestinationNode | null = null;
|
||||
|
||||
/** Whether the worklet module has been loaded into the AudioContext. */
|
||||
private workletLoaded = false;
|
||||
|
||||
/** Whether noise reduction is currently active. */
|
||||
private _isEnabled = false;
|
||||
|
||||
constructor(private readonly logger: WebRTCLogger) {}
|
||||
|
||||
/** Whether noise reduction is currently active. */
|
||||
get isEnabled(): boolean {
|
||||
return this._isEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable noise reduction on a raw microphone stream.
|
||||
*
|
||||
* Builds the AudioWorklet processing graph and returns a new
|
||||
* {@link MediaStream} whose audio has been denoised.
|
||||
*
|
||||
* If the worklet cannot be loaded (e.g. unsupported browser),
|
||||
* the original stream is returned unchanged and an error is logged.
|
||||
*
|
||||
* @param rawStream - The raw `getUserMedia` microphone stream.
|
||||
* @returns A denoised {@link MediaStream}, or the original if setup fails.
|
||||
*/
|
||||
async enable(rawStream: MediaStream): Promise<MediaStream> {
|
||||
if (this._isEnabled && this.destinationNode) {
|
||||
this.logger.info('Noise reduction already enabled, returning existing clean stream');
|
||||
return this.destinationNode.stream;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.buildProcessingGraph(rawStream);
|
||||
this._isEnabled = true;
|
||||
this.logger.info('Noise reduction enabled');
|
||||
return this.destinationNode!.stream;
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to enable noise reduction, returning raw stream', err);
|
||||
this.teardownGraph();
|
||||
return rawStream;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable noise reduction and tear down the processing graph.
|
||||
*
|
||||
* After calling this, the original raw microphone stream should be
|
||||
* used again (the caller is responsible for re-binding tracks).
|
||||
*/
|
||||
disable(): void {
|
||||
if (!this._isEnabled)
|
||||
return;
|
||||
|
||||
this.teardownGraph();
|
||||
this._isEnabled = false;
|
||||
this.logger.info('Noise reduction disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-pipe a new raw stream through the existing noise-reduction graph.
|
||||
*
|
||||
* Useful when the microphone device changes but noise reduction
|
||||
* should stay active.
|
||||
*
|
||||
* @param rawStream - The new raw microphone stream.
|
||||
* @returns The denoised stream, or the raw stream on failure.
|
||||
*/
|
||||
async replaceInputStream(rawStream: MediaStream): Promise<MediaStream> {
|
||||
if (!this._isEnabled)
|
||||
return rawStream;
|
||||
|
||||
try {
|
||||
// Disconnect old source but keep the rest of the graph alive
|
||||
this.sourceNode?.disconnect();
|
||||
|
||||
if (!this.audioContext || !this.workletNode || !this.destinationNode) {
|
||||
throw new Error('Processing graph not initialised');
|
||||
}
|
||||
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(rawStream);
|
||||
this.sourceNode.connect(this.workletNode);
|
||||
|
||||
this.logger.info('Noise reduction input stream replaced');
|
||||
return this.destinationNode.stream;
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to replace noise reduction input', err);
|
||||
return rawStream;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up all resources. Safe to call multiple times. */
|
||||
destroy(): void {
|
||||
this.disable();
|
||||
this.audioContext = null;
|
||||
this.workletLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AudioWorklet processing graph:
|
||||
* rawStream → source → workletNode → destination
|
||||
*/
|
||||
private async buildProcessingGraph(rawStream: MediaStream): Promise<void> {
|
||||
// Reuse or create the AudioContext (must be 48 kHz for RNNoise)
|
||||
if (!this.audioContext || this.audioContext.state === 'closed') {
|
||||
this.audioContext = new AudioContext({ sampleRate: RNNOISE_SAMPLE_RATE });
|
||||
this.workletLoaded = false;
|
||||
}
|
||||
|
||||
// Resume if suspended (browsers auto-suspend until user gesture)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
// Load the worklet module once per AudioContext lifetime
|
||||
if (!this.workletLoaded) {
|
||||
await this.audioContext.audioWorklet.addModule(WORKLET_MODULE_PATH);
|
||||
this.workletLoaded = true;
|
||||
this.logger.info('RNNoise worklet module loaded');
|
||||
}
|
||||
|
||||
// Build the node graph
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(rawStream);
|
||||
this.workletNode = new AudioWorkletNode(this.audioContext, WORKLET_PROCESSOR_NAME);
|
||||
this.destinationNode = this.audioContext.createMediaStreamDestination();
|
||||
|
||||
this.sourceNode.connect(this.workletNode).connect(this.destinationNode);
|
||||
}
|
||||
|
||||
/** Disconnect and release all graph nodes. */
|
||||
private teardownGraph(): void {
|
||||
try {
|
||||
this.sourceNode?.disconnect();
|
||||
} catch (error) {
|
||||
this.logger.warn('Noise reduction source node already disconnected', error);
|
||||
}
|
||||
|
||||
try {
|
||||
this.workletNode?.disconnect();
|
||||
} catch (error) {
|
||||
this.logger.warn('Noise reduction worklet node already disconnected', error);
|
||||
}
|
||||
|
||||
try {
|
||||
this.destinationNode?.disconnect();
|
||||
} catch (error) {
|
||||
this.logger.warn('Noise reduction destination node already disconnected', error);
|
||||
}
|
||||
|
||||
this.sourceNode = null;
|
||||
this.workletNode = null;
|
||||
this.destinationNode = null;
|
||||
|
||||
// Close the context to free hardware resources
|
||||
if (this.audioContext && this.audioContext.state !== 'closed') {
|
||||
this.audioContext.close().catch((error) => {
|
||||
this.logger.warn('Failed to close RNNoise audio context', error);
|
||||
});
|
||||
}
|
||||
|
||||
this.audioContext = null;
|
||||
this.workletLoaded = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../../screen-share.config';
|
||||
import { WebRTCLogger } from '../../logging/webrtc-logger';
|
||||
import { getElectronApi } from '../../../../core/platform/electron/get-electron-api';
|
||||
|
||||
export class BrowserScreenShareCapture {
|
||||
constructor(private readonly logger: WebRTCLogger) {}
|
||||
|
||||
async startCapture(
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): Promise<MediaStream> {
|
||||
const displayConstraints = this.buildDisplayMediaConstraints(options, preset);
|
||||
|
||||
this.logger.info('getDisplayMedia constraints', displayConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||||
throw new Error('navigator.mediaDevices.getDisplayMedia is not available.');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia(displayConstraints);
|
||||
|
||||
this.logAudioTrackSettings(stream);
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
private buildDisplayMediaConstraints(
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): DisplayMediaStreamOptions {
|
||||
const supportedConstraints = navigator.mediaDevices?.getSupportedConstraints?.() as Record<string, boolean> | undefined;
|
||||
const isWindowsElectron = this.isWindowsElectron();
|
||||
const audioConstraints: Record<string, unknown> | false = options.includeSystemAudio
|
||||
? {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false
|
||||
}
|
||||
: false;
|
||||
|
||||
if (audioConstraints && supportedConstraints?.['restrictOwnAudio']) {
|
||||
audioConstraints['restrictOwnAudio'] = true;
|
||||
}
|
||||
|
||||
if (audioConstraints && supportedConstraints?.['suppressLocalAudioPlayback']) {
|
||||
// Windows Electron should keep voice playback audible to the sharer.
|
||||
// Use own-audio restriction to keep the app's playback out of the
|
||||
// captured stream instead of muting local playback.
|
||||
audioConstraints['suppressLocalAudioPlayback'] = !isWindowsElectron;
|
||||
}
|
||||
|
||||
return {
|
||||
video: {
|
||||
width: { ideal: preset.width, max: preset.width },
|
||||
height: { ideal: preset.height, max: preset.height },
|
||||
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
|
||||
},
|
||||
audio: audioConstraints,
|
||||
monitorTypeSurfaces: 'include',
|
||||
selfBrowserSurface: 'exclude',
|
||||
surfaceSwitching: 'include',
|
||||
systemAudio: options.includeSystemAudio ? 'include' : 'exclude'
|
||||
} as DisplayMediaStreamOptions;
|
||||
}
|
||||
|
||||
private logAudioTrackSettings(stream: MediaStream): void {
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
|
||||
if (!audioTrack || typeof audioTrack.getSettings !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = audioTrack.getSettings() as MediaTrackSettings & {
|
||||
restrictOwnAudio?: boolean;
|
||||
suppressLocalAudioPlayback?: boolean;
|
||||
};
|
||||
|
||||
this.logger.info('getDisplayMedia audio track settings', {
|
||||
restrictOwnAudio: settings.restrictOwnAudio ?? null,
|
||||
suppressLocalAudioPlayback: settings.suppressLocalAudioPlayback ?? null
|
||||
});
|
||||
}
|
||||
|
||||
private isWindowsElectron(): boolean {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!getElectronApi()
|
||||
&& /win/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../../screen-share.config';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../realtime.constants';
|
||||
import { WebRTCLogger } from '../../logging/webrtc-logger';
|
||||
import {
|
||||
DesktopSource,
|
||||
ElectronDesktopCaptureResult,
|
||||
ElectronDesktopMediaStreamConstraints,
|
||||
ElectronDesktopSourceSelection,
|
||||
ScreenShareElectronApi
|
||||
} from './shared';
|
||||
|
||||
interface DesktopElectronScreenShareCaptureDependencies {
|
||||
getElectronApi(): ScreenShareElectronApi | null;
|
||||
getSelectDesktopSource(): ((
|
||||
sources: readonly DesktopSource[],
|
||||
options: { includeSystemAudio: boolean }
|
||||
) => Promise<ElectronDesktopSourceSelection>) | undefined;
|
||||
}
|
||||
|
||||
export class DesktopElectronScreenShareCapture {
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private readonly dependencies: DesktopElectronScreenShareCaptureDependencies
|
||||
) {}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return !!this.dependencies.getElectronApi()?.getSources && !this.isLinuxElectron();
|
||||
}
|
||||
|
||||
shouldSuppressRemotePlaybackDuringShare(
|
||||
includeSystemAudio: boolean,
|
||||
usingElectronDesktopCapture: boolean
|
||||
): boolean {
|
||||
// Chromium display-media capture can use own-audio suppression on modern
|
||||
// builds. The Electron desktop-capturer fallback cannot, so keep the old
|
||||
// Windows mute behavior only for that fallback path.
|
||||
return includeSystemAudio && usingElectronDesktopCapture && this.isWindowsElectron();
|
||||
}
|
||||
|
||||
async startCapture(
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): Promise<ElectronDesktopCaptureResult> {
|
||||
const electronApi = this.dependencies.getElectronApi();
|
||||
|
||||
if (!electronApi?.getSources) {
|
||||
throw new Error('Electron desktop capture is unavailable.');
|
||||
}
|
||||
|
||||
const sources = await electronApi.getSources();
|
||||
const selection = await this.resolveSourceSelection(sources, options.includeSystemAudio);
|
||||
const captureOptions = {
|
||||
...options,
|
||||
includeSystemAudio: selection.includeSystemAudio
|
||||
};
|
||||
|
||||
if (!selection.source) {
|
||||
throw new Error('No desktop capture sources were available.');
|
||||
}
|
||||
|
||||
this.logger.info('Selected Electron desktop source', {
|
||||
includeSystemAudio: selection.includeSystemAudio,
|
||||
sourceId: selection.source.id,
|
||||
sourceName: selection.source.name
|
||||
});
|
||||
|
||||
const constraints = this.buildConstraints(selection.source.id, captureOptions, preset);
|
||||
|
||||
this.logger.info('desktopCapturer constraints', constraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('navigator.mediaDevices.getUserMedia is not available (requires HTTPS or localhost).');
|
||||
}
|
||||
|
||||
return {
|
||||
includeSystemAudio: selection.includeSystemAudio,
|
||||
stream: await navigator.mediaDevices.getUserMedia(constraints)
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveSourceSelection(
|
||||
sources: DesktopSource[],
|
||||
includeSystemAudio: boolean
|
||||
): Promise<ElectronDesktopSourceSelection> {
|
||||
const orderedSources = this.sortSources(sources);
|
||||
const defaultSource = orderedSources.find((source) => source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME)
|
||||
?? orderedSources[0];
|
||||
|
||||
if (orderedSources.length === 0) {
|
||||
throw new Error('No desktop capture sources were available.');
|
||||
}
|
||||
|
||||
const selectDesktopSource = this.dependencies.getSelectDesktopSource();
|
||||
|
||||
if (!this.isWindowsElectron() || orderedSources.length < 2 || !selectDesktopSource) {
|
||||
return {
|
||||
includeSystemAudio,
|
||||
source: defaultSource
|
||||
};
|
||||
}
|
||||
|
||||
return await selectDesktopSource(orderedSources, { includeSystemAudio });
|
||||
}
|
||||
|
||||
private sortSources(sources: DesktopSource[]): DesktopSource[] {
|
||||
return [...sources].sort((left, right) => {
|
||||
const weightDiff = this.getSourceWeight(left) - this.getSourceWeight(right);
|
||||
|
||||
if (weightDiff !== 0) {
|
||||
return weightDiff;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
private getSourceWeight(source: DesktopSource): number {
|
||||
return source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME || source.id.startsWith('screen')
|
||||
? 0
|
||||
: 1;
|
||||
}
|
||||
|
||||
private buildConstraints(
|
||||
sourceId: string,
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): ElectronDesktopMediaStreamConstraints {
|
||||
const constraints: ElectronDesktopMediaStreamConstraints = {
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: sourceId,
|
||||
maxWidth: preset.width,
|
||||
maxHeight: preset.height,
|
||||
maxFrameRate: preset.frameRate
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (options.includeSystemAudio) {
|
||||
constraints.audio = {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: sourceId
|
||||
}
|
||||
};
|
||||
} else {
|
||||
constraints.audio = false;
|
||||
}
|
||||
|
||||
return constraints;
|
||||
}
|
||||
|
||||
private isLinuxElectron(): boolean {
|
||||
if (!this.dependencies.getElectronApi() || typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
||||
}
|
||||
|
||||
private isWindowsElectron(): boolean {
|
||||
if (!this.isAvailable() || typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /win/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../../screen-share.config';
|
||||
import { WebRTCLogger } from '../../logging/webrtc-logger';
|
||||
import {
|
||||
LinuxScreenShareAudioRoutingInfo,
|
||||
LinuxScreenShareMonitorAudioChunkPayload,
|
||||
LinuxScreenShareMonitorAudioEndedPayload,
|
||||
LinuxScreenShareMonitorCaptureInfo,
|
||||
ScreenShareElectronApi
|
||||
} from './shared';
|
||||
|
||||
interface LinuxScreenShareMonitorAudioPipeline {
|
||||
audioContext: AudioContext;
|
||||
audioTrack: MediaStreamTrack;
|
||||
bitsPerSample: number;
|
||||
captureId: string;
|
||||
channelCount: number;
|
||||
mediaDestination: MediaStreamAudioDestinationNode;
|
||||
nextStartTime: number;
|
||||
pendingBytes: Uint8Array;
|
||||
sampleRate: number;
|
||||
unsubscribeChunk: () => void;
|
||||
unsubscribeEnded: () => void;
|
||||
}
|
||||
|
||||
interface LinuxElectronScreenShareCaptureDependencies {
|
||||
getElectronApi(): ScreenShareElectronApi | null;
|
||||
onCaptureEnded(): void;
|
||||
startDisplayMedia(options: ScreenShareStartOptions, preset: ScreenShareQualityPreset): Promise<MediaStream>;
|
||||
}
|
||||
|
||||
export class LinuxElectronScreenShareCapture {
|
||||
private audioRoutingActive = false;
|
||||
private audioRoutingResetPromise: Promise<void> | null = null;
|
||||
private monitorAudioPipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private readonly dependencies: LinuxElectronScreenShareCaptureDependencies
|
||||
) {}
|
||||
|
||||
isSupported(): boolean {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const electronApi = this.dependencies.getElectronApi();
|
||||
const platformHint = `${navigator.userAgent} ${navigator.platform}`;
|
||||
|
||||
return !!electronApi?.prepareLinuxScreenShareAudioRouting
|
||||
&& !!electronApi?.activateLinuxScreenShareAudioRouting
|
||||
&& !!electronApi?.deactivateLinuxScreenShareAudioRouting
|
||||
&& !!electronApi?.startLinuxScreenShareMonitorCapture
|
||||
&& !!electronApi?.stopLinuxScreenShareMonitorCapture
|
||||
&& !!electronApi?.onLinuxScreenShareMonitorAudioChunk
|
||||
&& !!electronApi?.onLinuxScreenShareMonitorAudioEnded
|
||||
&& /linux/i.test(platformHint);
|
||||
}
|
||||
|
||||
async awaitPendingReset(): Promise<void> {
|
||||
if (!this.audioRoutingResetPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.audioRoutingResetPromise;
|
||||
}
|
||||
|
||||
scheduleReset(): void {
|
||||
if (!this.audioRoutingActive || this.audioRoutingResetPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.audioRoutingResetPromise = this.resetAudioRouting()
|
||||
.catch((error) => {
|
||||
this.logger.warn('Failed to reset Linux Electron audio routing', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.audioRoutingResetPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
async startCapture(
|
||||
options: ScreenShareStartOptions,
|
||||
preset: ScreenShareQualityPreset
|
||||
): Promise<MediaStream> {
|
||||
const electronApi = this.getRequiredElectronApi();
|
||||
const routingInfo = await electronApi.prepareLinuxScreenShareAudioRouting();
|
||||
|
||||
this.assertAudioRoutingReady(routingInfo, 'Linux Electron audio routing is unavailable.');
|
||||
|
||||
let desktopStream: MediaStream | null = null;
|
||||
|
||||
try {
|
||||
const activation = await electronApi.activateLinuxScreenShareAudioRouting();
|
||||
|
||||
this.assertAudioRoutingReady(activation, 'Failed to activate Linux Electron audio routing.');
|
||||
|
||||
if (!activation.active) {
|
||||
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
|
||||
}
|
||||
|
||||
desktopStream = await this.dependencies.startDisplayMedia({
|
||||
...options,
|
||||
includeSystemAudio: false
|
||||
}, preset);
|
||||
|
||||
const { audioTrack, captureInfo } = await this.startMonitorTrack();
|
||||
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
|
||||
|
||||
desktopStream.getAudioTracks().forEach((track) => track.stop());
|
||||
|
||||
this.audioRoutingActive = true;
|
||||
this.logger.info('Linux Electron screen-share audio routing enabled', {
|
||||
screenShareMonitorSourceName: captureInfo.sourceName,
|
||||
voiceSinkName: activation.voiceSinkName
|
||||
});
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
desktopStream?.getTracks().forEach((track) => track.stop());
|
||||
await this.resetAudioRouting();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getRequiredElectronApi(): Required<Pick<
|
||||
ScreenShareElectronApi,
|
||||
| 'prepareLinuxScreenShareAudioRouting'
|
||||
| 'activateLinuxScreenShareAudioRouting'
|
||||
| 'deactivateLinuxScreenShareAudioRouting'
|
||||
| 'startLinuxScreenShareMonitorCapture'
|
||||
| 'stopLinuxScreenShareMonitorCapture'
|
||||
| 'onLinuxScreenShareMonitorAudioChunk'
|
||||
| 'onLinuxScreenShareMonitorAudioEnded'
|
||||
>> {
|
||||
const electronApi = this.dependencies.getElectronApi();
|
||||
|
||||
if (!electronApi?.prepareLinuxScreenShareAudioRouting
|
||||
|| !electronApi.activateLinuxScreenShareAudioRouting
|
||||
|| !electronApi.deactivateLinuxScreenShareAudioRouting
|
||||
|| !electronApi.startLinuxScreenShareMonitorCapture
|
||||
|| !electronApi.stopLinuxScreenShareMonitorCapture
|
||||
|| !electronApi.onLinuxScreenShareMonitorAudioChunk
|
||||
|| !electronApi.onLinuxScreenShareMonitorAudioEnded) {
|
||||
throw new Error('Linux Electron audio routing is unavailable.');
|
||||
}
|
||||
|
||||
return {
|
||||
prepareLinuxScreenShareAudioRouting: electronApi.prepareLinuxScreenShareAudioRouting,
|
||||
activateLinuxScreenShareAudioRouting: electronApi.activateLinuxScreenShareAudioRouting,
|
||||
deactivateLinuxScreenShareAudioRouting: electronApi.deactivateLinuxScreenShareAudioRouting,
|
||||
startLinuxScreenShareMonitorCapture: electronApi.startLinuxScreenShareMonitorCapture,
|
||||
stopLinuxScreenShareMonitorCapture: electronApi.stopLinuxScreenShareMonitorCapture,
|
||||
onLinuxScreenShareMonitorAudioChunk: electronApi.onLinuxScreenShareMonitorAudioChunk,
|
||||
onLinuxScreenShareMonitorAudioEnded: electronApi.onLinuxScreenShareMonitorAudioEnded
|
||||
};
|
||||
}
|
||||
|
||||
private assertAudioRoutingReady(
|
||||
routingInfo: LinuxScreenShareAudioRoutingInfo,
|
||||
unavailableReason: string
|
||||
): void {
|
||||
if (!routingInfo.available) {
|
||||
throw new Error(routingInfo.reason || unavailableReason);
|
||||
}
|
||||
|
||||
if (!routingInfo.monitorCaptureSupported) {
|
||||
throw new Error('Linux screen-share monitor capture requires restarting the desktop app so the new Electron main process can load.');
|
||||
}
|
||||
}
|
||||
|
||||
private async resetAudioRouting(): Promise<void> {
|
||||
const electronApi = this.dependencies.getElectronApi();
|
||||
const captureId = this.monitorAudioPipeline?.captureId;
|
||||
|
||||
this.audioRoutingActive = false;
|
||||
|
||||
this.disposeMonitorAudioPipeline();
|
||||
|
||||
try {
|
||||
if (captureId && electronApi?.stopLinuxScreenShareMonitorCapture) {
|
||||
await electronApi.stopLinuxScreenShareMonitorCapture(captureId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to stop Linux screen-share monitor capture', error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (electronApi?.deactivateLinuxScreenShareAudioRouting) {
|
||||
await electronApi.deactivateLinuxScreenShareAudioRouting();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to deactivate Linux Electron audio routing', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async startMonitorTrack(): Promise<{
|
||||
audioTrack: MediaStreamTrack;
|
||||
captureInfo: LinuxScreenShareMonitorCaptureInfo;
|
||||
}> {
|
||||
const electronApi = this.dependencies.getElectronApi();
|
||||
|
||||
if (!electronApi?.startLinuxScreenShareMonitorCapture
|
||||
|| !electronApi?.stopLinuxScreenShareMonitorCapture
|
||||
|| !electronApi?.onLinuxScreenShareMonitorAudioChunk
|
||||
|| !electronApi?.onLinuxScreenShareMonitorAudioEnded) {
|
||||
throw new Error('Linux screen-share monitor capture is unavailable.');
|
||||
}
|
||||
|
||||
const queuedChunksByCaptureId = new Map<string, Uint8Array[]>();
|
||||
const queuedEndedReasons = new Map<string, string | undefined>();
|
||||
|
||||
let pipeline: LinuxScreenShareMonitorAudioPipeline | null = null;
|
||||
let captureInfo: LinuxScreenShareMonitorCaptureInfo | null = null;
|
||||
|
||||
const queueChunk = (captureId: string, chunk: Uint8Array): void => {
|
||||
const queuedChunks = queuedChunksByCaptureId.get(captureId) || [];
|
||||
|
||||
queuedChunks.push(this.copyBytes(chunk));
|
||||
queuedChunksByCaptureId.set(captureId, queuedChunks);
|
||||
};
|
||||
const onChunk = (payload: LinuxScreenShareMonitorAudioChunkPayload): void => {
|
||||
if (!pipeline || payload.captureId !== pipeline.captureId) {
|
||||
queueChunk(payload.captureId, payload.chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleMonitorAudioChunk(pipeline, payload.chunk);
|
||||
};
|
||||
const onEnded = (payload: LinuxScreenShareMonitorAudioEndedPayload): void => {
|
||||
if (!pipeline || payload.captureId !== pipeline.captureId) {
|
||||
queuedEndedReasons.set(payload.captureId, payload.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn('Linux screen-share monitor capture ended', payload);
|
||||
this.dependencies.onCaptureEnded();
|
||||
};
|
||||
const unsubscribeChunk = electronApi.onLinuxScreenShareMonitorAudioChunk(onChunk) as () => void;
|
||||
const unsubscribeEnded = electronApi.onLinuxScreenShareMonitorAudioEnded(onEnded) as () => void;
|
||||
|
||||
try {
|
||||
captureInfo = await electronApi.startLinuxScreenShareMonitorCapture() as LinuxScreenShareMonitorCaptureInfo;
|
||||
|
||||
const audioContext = new AudioContext({ sampleRate: captureInfo.sampleRate });
|
||||
const mediaDestination = audioContext.createMediaStreamDestination();
|
||||
|
||||
await audioContext.resume();
|
||||
|
||||
const audioTrack = mediaDestination.stream.getAudioTracks()[0];
|
||||
|
||||
if (!audioTrack) {
|
||||
throw new Error('Renderer audio pipeline did not produce a screen-share monitor track.');
|
||||
}
|
||||
|
||||
pipeline = {
|
||||
audioContext,
|
||||
audioTrack,
|
||||
bitsPerSample: captureInfo.bitsPerSample,
|
||||
captureId: captureInfo.captureId,
|
||||
channelCount: captureInfo.channelCount,
|
||||
mediaDestination,
|
||||
nextStartTime: audioContext.currentTime + 0.05,
|
||||
pendingBytes: new Uint8Array(0),
|
||||
sampleRate: captureInfo.sampleRate,
|
||||
unsubscribeChunk,
|
||||
unsubscribeEnded
|
||||
};
|
||||
|
||||
this.monitorAudioPipeline = pipeline;
|
||||
const activeCaptureId = captureInfo.captureId;
|
||||
|
||||
audioTrack.addEventListener('ended', () => {
|
||||
if (this.monitorAudioPipeline?.captureId === activeCaptureId) {
|
||||
this.dependencies.onCaptureEnded();
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
const queuedChunks = queuedChunksByCaptureId.get(captureInfo.captureId) || [];
|
||||
const activePipeline = pipeline;
|
||||
|
||||
queuedChunks.forEach((chunk) => {
|
||||
this.handleMonitorAudioChunk(activePipeline, chunk);
|
||||
});
|
||||
|
||||
queuedChunksByCaptureId.delete(captureInfo.captureId);
|
||||
|
||||
if (queuedEndedReasons.has(captureInfo.captureId)) {
|
||||
throw new Error(queuedEndedReasons.get(captureInfo.captureId)
|
||||
|| 'Linux screen-share monitor capture ended before audio initialisation completed.');
|
||||
}
|
||||
|
||||
return {
|
||||
audioTrack,
|
||||
captureInfo
|
||||
};
|
||||
} catch (error) {
|
||||
if (pipeline) {
|
||||
this.disposeMonitorAudioPipeline(pipeline.captureId);
|
||||
} else {
|
||||
unsubscribeChunk();
|
||||
unsubscribeEnded();
|
||||
}
|
||||
|
||||
try {
|
||||
await electronApi.stopLinuxScreenShareMonitorCapture(captureInfo?.captureId);
|
||||
} catch (stopError) {
|
||||
this.logger.warn('Failed to stop Linux screen-share monitor capture after startup failure', stopError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private disposeMonitorAudioPipeline(captureId?: string): void {
|
||||
if (!this.monitorAudioPipeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (captureId && captureId !== this.monitorAudioPipeline.captureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pipeline = this.monitorAudioPipeline;
|
||||
|
||||
this.monitorAudioPipeline = null;
|
||||
pipeline.unsubscribeChunk();
|
||||
pipeline.unsubscribeEnded();
|
||||
pipeline.audioTrack.stop();
|
||||
pipeline.pendingBytes = new Uint8Array(0);
|
||||
|
||||
void pipeline.audioContext.close().catch((error) => {
|
||||
this.logger.warn('Failed to close Linux screen-share monitor audio context', error);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMonitorAudioChunk(
|
||||
pipeline: LinuxScreenShareMonitorAudioPipeline,
|
||||
chunk: Uint8Array
|
||||
): void {
|
||||
if (pipeline.bitsPerSample !== 16) {
|
||||
this.logger.warn('Unsupported Linux screen-share monitor capture sample size', {
|
||||
bitsPerSample: pipeline.bitsPerSample,
|
||||
captureId: pipeline.captureId
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const bytesPerSample = pipeline.bitsPerSample / 8;
|
||||
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
|
||||
|
||||
if (!Number.isFinite(bytesPerFrame) || bytesPerFrame <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedBytes = this.concatBytes(pipeline.pendingBytes, chunk);
|
||||
const completeByteLength = combinedBytes.byteLength - (combinedBytes.byteLength % bytesPerFrame);
|
||||
|
||||
if (completeByteLength <= 0) {
|
||||
pipeline.pendingBytes = combinedBytes;
|
||||
return;
|
||||
}
|
||||
|
||||
const completeBytes = combinedBytes.subarray(0, completeByteLength);
|
||||
|
||||
pipeline.pendingBytes = this.copyBytes(combinedBytes.subarray(completeByteLength));
|
||||
|
||||
if (pipeline.audioContext.state !== 'running') {
|
||||
void pipeline.audioContext.resume().catch((error) => {
|
||||
this.logger.warn('Failed to resume Linux screen-share monitor audio context', error);
|
||||
});
|
||||
}
|
||||
|
||||
const frameCount = completeByteLength / bytesPerFrame;
|
||||
const audioBuffer = this.createAudioBuffer(pipeline, completeBytes, frameCount);
|
||||
const source = pipeline.audioContext.createBufferSource();
|
||||
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(pipeline.mediaDestination);
|
||||
|
||||
source.onended = () => {
|
||||
source.disconnect();
|
||||
};
|
||||
|
||||
const now = pipeline.audioContext.currentTime;
|
||||
const startTime = Math.max(pipeline.nextStartTime, now + 0.02);
|
||||
|
||||
source.start(startTime);
|
||||
pipeline.nextStartTime = startTime + audioBuffer.duration;
|
||||
}
|
||||
|
||||
private createAudioBuffer(
|
||||
pipeline: LinuxScreenShareMonitorAudioPipeline,
|
||||
bytes: Uint8Array,
|
||||
frameCount: number
|
||||
): AudioBuffer {
|
||||
const audioBuffer = pipeline.audioContext.createBuffer(pipeline.channelCount, frameCount, pipeline.sampleRate);
|
||||
const sampleData = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const channelData = Array.from(
|
||||
{ length: pipeline.channelCount },
|
||||
(_, channelIndex) => audioBuffer.getChannelData(channelIndex)
|
||||
);
|
||||
const bytesPerSample = pipeline.bitsPerSample / 8;
|
||||
const bytesPerFrame = bytesPerSample * pipeline.channelCount;
|
||||
|
||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||
const frameOffset = frameIndex * bytesPerFrame;
|
||||
|
||||
for (let channelIndex = 0; channelIndex < pipeline.channelCount; channelIndex += 1) {
|
||||
const sampleOffset = frameOffset + (channelIndex * bytesPerSample);
|
||||
|
||||
channelData[channelIndex][frameIndex] = sampleData.getInt16(sampleOffset, true) / 32768;
|
||||
}
|
||||
}
|
||||
|
||||
return audioBuffer;
|
||||
}
|
||||
|
||||
private concatBytes(first: Uint8Array, second: Uint8Array): Uint8Array {
|
||||
if (first.byteLength === 0) {
|
||||
return this.copyBytes(second);
|
||||
}
|
||||
|
||||
if (second.byteLength === 0) {
|
||||
return this.copyBytes(first);
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(first.byteLength + second.byteLength);
|
||||
|
||||
combined.set(first, 0);
|
||||
combined.set(second, first.byteLength);
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
private copyBytes(bytes: Uint8Array): Uint8Array {
|
||||
return bytes.byteLength > 0 ? new Uint8Array(bytes) : new Uint8Array(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
ElectronApi,
|
||||
ElectronWindow,
|
||||
LinuxScreenShareAudioRoutingInfo,
|
||||
LinuxScreenShareMonitorAudioChunkPayload,
|
||||
LinuxScreenShareMonitorAudioEndedPayload,
|
||||
LinuxScreenShareMonitorCaptureInfo
|
||||
} from '../../../../core/platform/electron/electron-api.models';
|
||||
|
||||
export interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ElectronDesktopSourceSelection {
|
||||
includeSystemAudio: boolean;
|
||||
source: DesktopSource;
|
||||
}
|
||||
|
||||
export interface ElectronDesktopCaptureResult {
|
||||
includeSystemAudio: boolean;
|
||||
stream: MediaStream;
|
||||
}
|
||||
|
||||
export type {
|
||||
LinuxScreenShareAudioRoutingInfo,
|
||||
LinuxScreenShareMonitorAudioChunkPayload,
|
||||
LinuxScreenShareMonitorAudioEndedPayload,
|
||||
LinuxScreenShareMonitorCaptureInfo
|
||||
};
|
||||
|
||||
export type ScreenShareElectronApi = Partial<Pick<
|
||||
ElectronApi,
|
||||
| 'getSources'
|
||||
| 'prepareLinuxScreenShareAudioRouting'
|
||||
| 'activateLinuxScreenShareAudioRouting'
|
||||
| 'deactivateLinuxScreenShareAudioRouting'
|
||||
| 'startLinuxScreenShareMonitorCapture'
|
||||
| 'stopLinuxScreenShareMonitorCapture'
|
||||
| 'onLinuxScreenShareMonitorAudioChunk'
|
||||
| 'onLinuxScreenShareMonitorAudioEnded'
|
||||
>>;
|
||||
|
||||
export type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop';
|
||||
chromeMediaSourceId: string;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
maxFrameRate: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop';
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
|
||||
video: ElectronDesktopVideoConstraint;
|
||||
audio?: false | ElectronDesktopAudioConstraint;
|
||||
}
|
||||
|
||||
export type ScreenShareWindow = ElectronWindow;
|
||||
@@ -0,0 +1,575 @@
|
||||
/* eslint-disable, @typescript-eslint/no-non-null-assertion, @typescript-eslint/member-ordering, id-denylist */
|
||||
/**
|
||||
* Manages screen sharing: getDisplayMedia / Electron desktop capturer,
|
||||
* system-audio capture, and attaching screen tracks to peers.
|
||||
*/
|
||||
import { WebRTCLogger } from '../logging/webrtc-logger';
|
||||
import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config';
|
||||
import { PeerData } from '../realtime.types';
|
||||
import {
|
||||
TRACK_KIND_AUDIO,
|
||||
TRACK_KIND_VIDEO,
|
||||
TRANSCEIVER_SEND_RECV,
|
||||
TRANSCEIVER_RECV_ONLY
|
||||
} from '../realtime.constants';
|
||||
import { DEFAULT_SCREEN_SHARE_START_OPTIONS, SCREEN_SHARE_QUALITY_PRESETS } from '../screen-share.config';
|
||||
import { BrowserScreenShareCapture } from './screen-share-platforms/browser-screen-share.capture';
|
||||
import { DesktopElectronScreenShareCapture } from './screen-share-platforms/desktop-electron-screen-share.capture';
|
||||
import { LinuxElectronScreenShareCapture } from './screen-share-platforms/linux-electron-screen-share.capture';
|
||||
import { getElectronApi } from '../../../core/platform/electron/get-electron-api';
|
||||
import { ScreenShareElectronApi } from './screen-share-platforms/shared';
|
||||
|
||||
/**
|
||||
* Callbacks the ScreenShareManager needs from the owning service.
|
||||
*/
|
||||
export interface ScreenShareCallbacks {
|
||||
getActivePeers(): Map<string, PeerData>;
|
||||
getLocalMediaStream(): MediaStream | null;
|
||||
renegotiate(peerId: string): Promise<void>;
|
||||
broadcastCurrentStates(): void;
|
||||
selectDesktopSource?(
|
||||
sources: readonly { id: string; name: string; thumbnail: string }[],
|
||||
options: { includeSystemAudio: boolean }
|
||||
): Promise<{
|
||||
includeSystemAudio: boolean;
|
||||
source: { id: string; name: string; thumbnail: string };
|
||||
}>;
|
||||
updateLocalScreenShareState?(state: LocalScreenShareState): void;
|
||||
}
|
||||
|
||||
type ScreenShareCaptureMethod = 'display-media' | 'electron-desktop' | 'linux-electron';
|
||||
|
||||
export interface LocalScreenShareState {
|
||||
active: boolean;
|
||||
captureMethod: ScreenShareCaptureMethod | null;
|
||||
includeSystemAudio: boolean;
|
||||
stream: MediaStream | null;
|
||||
suppressRemotePlayback: boolean;
|
||||
forceDefaultRemotePlaybackOutput: boolean;
|
||||
}
|
||||
|
||||
export class ScreenShareManager {
|
||||
/** The active screen-capture stream. */
|
||||
private activeScreenStream: MediaStream | null = null;
|
||||
|
||||
/** Optional system-audio stream captured alongside the screen. */
|
||||
private screenAudioStream: MediaStream | null = null;
|
||||
|
||||
/** The quality preset currently applied to the active share. */
|
||||
private activeScreenPreset: ScreenShareQualityPreset | null = null;
|
||||
|
||||
/** Remote peers that explicitly requested screen-share video. */
|
||||
private readonly requestedViewerPeerIds = new Set<string>();
|
||||
|
||||
/** Browser `getDisplayMedia` capture path. */
|
||||
private readonly browserScreenShareCapture: BrowserScreenShareCapture;
|
||||
|
||||
/** Desktop Electron capture path for non-Linux desktop builds. */
|
||||
private readonly desktopElectronScreenShareCapture: DesktopElectronScreenShareCapture;
|
||||
|
||||
/** Linux Electron screen/audio capture path with isolated audio routing. */
|
||||
private readonly linuxElectronScreenShareCapture: LinuxElectronScreenShareCapture;
|
||||
|
||||
/** Whether screen sharing is currently active. */
|
||||
private isScreenActive = false;
|
||||
|
||||
constructor(
|
||||
private readonly logger: WebRTCLogger,
|
||||
private callbacks: ScreenShareCallbacks
|
||||
) {
|
||||
this.browserScreenShareCapture = new BrowserScreenShareCapture(this.logger);
|
||||
this.desktopElectronScreenShareCapture = new DesktopElectronScreenShareCapture(this.logger, {
|
||||
getElectronApi: () => this.getElectronApi(),
|
||||
getSelectDesktopSource: () => this.callbacks.selectDesktopSource
|
||||
});
|
||||
|
||||
this.linuxElectronScreenShareCapture = new LinuxElectronScreenShareCapture(this.logger, {
|
||||
getElectronApi: () => this.getElectronApi(),
|
||||
onCaptureEnded: () => {
|
||||
if (this.isScreenActive) {
|
||||
this.stopScreenShare();
|
||||
}
|
||||
},
|
||||
startDisplayMedia: async (options, preset) =>
|
||||
await this.browserScreenShareCapture.startCapture(options, preset)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the callback set at runtime.
|
||||
* Needed because of circular initialisation between managers.
|
||||
*
|
||||
* @param nextCallbacks - The new callback interface to wire into this manager.
|
||||
*/
|
||||
setCallbacks(nextCallbacks: ScreenShareCallbacks): void {
|
||||
this.callbacks = nextCallbacks;
|
||||
}
|
||||
|
||||
/** Returns the current screen-capture stream, or `null` if inactive. */
|
||||
getScreenStream(): MediaStream | null { return this.activeScreenStream; }
|
||||
/** Whether screen sharing is currently active. */
|
||||
getIsScreenActive(): boolean { return this.isScreenActive; }
|
||||
|
||||
/**
|
||||
* Begin screen sharing.
|
||||
*
|
||||
* On Linux Electron builds, prefers a dedicated PulseAudio/PipeWire routing
|
||||
* path so remote voice playback is kept out of captured system audio.
|
||||
* On Windows Electron builds, prefers `getDisplayMedia` with system audio
|
||||
* so the separate mic `getUserMedia` stream is not disrupted; falls back to
|
||||
* Electron desktop capture only when `getDisplayMedia` fails entirely.
|
||||
* In browser contexts, uses `getDisplayMedia`.
|
||||
*
|
||||
* @param options - Screen-share capture options.
|
||||
* @returns The captured screen {@link MediaStream}.
|
||||
* @throws If both Electron and browser screen capture fail.
|
||||
*/
|
||||
async startScreenShare(options: ScreenShareStartOptions = DEFAULT_SCREEN_SHARE_START_OPTIONS): Promise<MediaStream> {
|
||||
const shareOptions = {
|
||||
...DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||
...options
|
||||
};
|
||||
const preset = SCREEN_SHARE_QUALITY_PRESETS[shareOptions.quality];
|
||||
const electronDesktopCaptureAvailable = this.desktopElectronScreenShareCapture.isAvailable();
|
||||
|
||||
let captureMethod: ScreenShareCaptureMethod | null = null;
|
||||
|
||||
try {
|
||||
this.logger.info('startScreenShare invoked', shareOptions);
|
||||
|
||||
if (this.activeScreenStream) {
|
||||
this.stopScreenShare();
|
||||
}
|
||||
|
||||
await this.linuxElectronScreenShareCapture.awaitPendingReset();
|
||||
|
||||
this.activeScreenStream = null;
|
||||
|
||||
if (shareOptions.includeSystemAudio && this.linuxElectronScreenShareCapture.isSupported()) {
|
||||
try {
|
||||
this.activeScreenStream = await this.linuxElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'linux-electron';
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream && shareOptions.includeSystemAudio) {
|
||||
try {
|
||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'display-media';
|
||||
|
||||
if (this.activeScreenStream.getAudioTracks().length === 0) {
|
||||
if (electronDesktopCaptureAvailable) {
|
||||
// On Windows Electron, keep the getDisplayMedia stream for video
|
||||
// rather than falling through to getUserMedia desktop audio which
|
||||
// can replace or kill the active mic stream.
|
||||
this.logger.warn(
|
||||
'getDisplayMedia did not provide system audio; '
|
||||
+ 'continuing without system audio to preserve mic stream'
|
||||
);
|
||||
|
||||
shareOptions.includeSystemAudio = false;
|
||||
} else {
|
||||
this.logger.warn('getDisplayMedia did not provide system audio; trying next capture method');
|
||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||
this.activeScreenStream = null;
|
||||
captureMethod = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream && electronDesktopCaptureAvailable) {
|
||||
try {
|
||||
const electronCapture = await this.desktopElectronScreenShareCapture.startCapture(shareOptions, preset);
|
||||
|
||||
this.activeScreenStream = electronCapture.stream;
|
||||
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
||||
captureMethod = 'electron-desktop';
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.activeScreenStream) {
|
||||
this.activeScreenStream = await this.browserScreenShareCapture.startCapture(shareOptions, preset);
|
||||
captureMethod = 'display-media';
|
||||
}
|
||||
|
||||
this.configureScreenStream(preset);
|
||||
this.prepareScreenAudio(shareOptions.includeSystemAudio);
|
||||
this.activeScreenPreset = preset;
|
||||
this.attachScreenTracksToPeers(preset);
|
||||
|
||||
this.isScreenActive = true;
|
||||
this.publishLocalScreenShareState(shareOptions.includeSystemAudio, captureMethod);
|
||||
this.callbacks.broadcastCurrentStates();
|
||||
|
||||
const activeScreenStream = this.activeScreenStream;
|
||||
|
||||
if (!activeScreenStream) {
|
||||
throw new Error('Screen sharing did not produce an active stream.');
|
||||
}
|
||||
|
||||
const screenVideoTrack = activeScreenStream.getVideoTracks()[0];
|
||||
|
||||
if (screenVideoTrack) {
|
||||
screenVideoTrack.onended = () => {
|
||||
this.logger.warn('Screen video track ended');
|
||||
this.stopScreenShare();
|
||||
};
|
||||
}
|
||||
|
||||
return activeScreenStream;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start screen share', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop screen sharing and remove screen-share tracks on all peers.
|
||||
*
|
||||
* Stops all screen-capture tracks, resets screen transceivers to receive-only,
|
||||
* and triggers renegotiation.
|
||||
*/
|
||||
stopScreenShare(): void {
|
||||
if (this.activeScreenStream) {
|
||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||
this.activeScreenStream = null;
|
||||
}
|
||||
|
||||
this.linuxElectronScreenShareCapture.scheduleReset();
|
||||
|
||||
this.screenAudioStream = null;
|
||||
this.activeScreenPreset = null;
|
||||
this.isScreenActive = false;
|
||||
this.publishLocalScreenShareState(false, null);
|
||||
this.callbacks.broadcastCurrentStates();
|
||||
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
this.detachScreenTracksFromPeer(peerData, peerId);
|
||||
});
|
||||
}
|
||||
|
||||
requestScreenShareForPeer(peerId: string): void {
|
||||
this.requestedViewerPeerIds.add(peerId);
|
||||
|
||||
if (!this.isScreenActive || !this.activeScreenPreset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerData = this.callbacks.getActivePeers().get(peerId);
|
||||
|
||||
if (!peerData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset);
|
||||
}
|
||||
|
||||
stopScreenShareForPeer(peerId: string): void {
|
||||
this.requestedViewerPeerIds.delete(peerId);
|
||||
|
||||
const peerData = this.callbacks.getActivePeers().get(peerId);
|
||||
|
||||
if (!peerData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.detachScreenTracksFromPeer(peerData, peerId);
|
||||
}
|
||||
|
||||
clearScreenShareRequest(peerId: string): void {
|
||||
this.requestedViewerPeerIds.delete(peerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the current screen-share tracks to a newly-connected peer.
|
||||
*
|
||||
* This is needed when a peer connects after screen sharing already started,
|
||||
* because `startScreenShare()` only pushes tracks to peers that existed at
|
||||
* the time sharing began.
|
||||
*/
|
||||
syncScreenShareToPeer(peerId: string): void {
|
||||
if (
|
||||
!this.requestedViewerPeerIds.has(peerId)
|
||||
|| !this.isScreenActive
|
||||
|| !this.activeScreenStream
|
||||
|| !this.activeScreenPreset
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerData = this.callbacks.getActivePeers().get(peerId);
|
||||
|
||||
if (!peerData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachScreenTracksToPeer(peerData, peerId, this.activeScreenPreset);
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.stopScreenShare();
|
||||
}
|
||||
|
||||
private getElectronApi(): ScreenShareElectronApi | null {
|
||||
return getElectronApi();
|
||||
}
|
||||
|
||||
private publishLocalScreenShareState(
|
||||
includeSystemAudio: boolean,
|
||||
captureMethod: ScreenShareCaptureMethod | null
|
||||
): void {
|
||||
this.callbacks.updateLocalScreenShareState?.({
|
||||
active: this.isScreenActive,
|
||||
captureMethod: this.isScreenActive ? captureMethod : null,
|
||||
includeSystemAudio: this.isScreenActive ? includeSystemAudio : false,
|
||||
stream: this.isScreenActive ? this.activeScreenStream : null,
|
||||
suppressRemotePlayback: this.isScreenActive
|
||||
&& this.desktopElectronScreenShareCapture.shouldSuppressRemotePlaybackDuringShare(
|
||||
includeSystemAudio,
|
||||
captureMethod === 'electron-desktop'
|
||||
),
|
||||
forceDefaultRemotePlaybackOutput: this.isScreenActive
|
||||
&& includeSystemAudio
|
||||
&& captureMethod === 'linux-electron'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dedicated stream for system audio captured alongside the screen.
|
||||
*
|
||||
* @param includeSystemAudio - Whether system audio should be sent.
|
||||
*/
|
||||
private prepareScreenAudio(includeSystemAudio: boolean): void {
|
||||
const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null;
|
||||
|
||||
if (!screenAudioTrack) {
|
||||
if (includeSystemAudio) {
|
||||
this.logger.warn('System audio was requested, but no screen audio track was captured');
|
||||
}
|
||||
|
||||
this.screenAudioStream = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.screenAudioStream = new MediaStream([screenAudioTrack]);
|
||||
this.logger.attachTrackDiagnostics(screenAudioTrack, 'screenAudio');
|
||||
this.logger.logStream('screenAudio', this.screenAudioStream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach screen video and optional system-audio tracks to all
|
||||
* active peer connections, then trigger SDP renegotiation.
|
||||
*
|
||||
* @param options - Screen-share capture options.
|
||||
* @param preset - Selected quality preset for sender tuning.
|
||||
*/
|
||||
private attachScreenTracksToPeers(
|
||||
preset: ScreenShareQualityPreset
|
||||
): void {
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
if (!this.requestedViewerPeerIds.has(peerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachScreenTracksToPeer(peerData, peerId, preset);
|
||||
});
|
||||
}
|
||||
|
||||
private attachScreenTracksToPeer(
|
||||
peerData: PeerData,
|
||||
peerId: string,
|
||||
preset: ScreenShareQualityPreset
|
||||
): void {
|
||||
if (!this.activeScreenStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0];
|
||||
|
||||
if (!screenVideoTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`);
|
||||
|
||||
let videoSender = peerData.videoSender || peerData.connection.getSenders().find((sender) => sender.track?.kind === TRACK_KIND_VIDEO);
|
||||
|
||||
if (!videoSender) {
|
||||
const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
});
|
||||
|
||||
videoSender = videoTransceiver.sender;
|
||||
peerData.videoSender = videoSender;
|
||||
} else {
|
||||
const videoTransceiver = peerData.connection.getTransceivers().find(
|
||||
(transceiver) => transceiver.sender === videoSender
|
||||
);
|
||||
|
||||
if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = videoSender;
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(this.activeScreenStream);
|
||||
}
|
||||
|
||||
videoSender.replaceTrack(screenVideoTrack)
|
||||
.then(() => {
|
||||
this.logger.info('screen video replaceTrack ok', { peerId });
|
||||
void this.applyScreenShareVideoParameters(videoSender, preset, peerId);
|
||||
})
|
||||
.catch((error) => this.logger.error('screen video replaceTrack failed', error));
|
||||
|
||||
const screenAudioTrack = this.screenAudioStream?.getAudioTracks()[0] || null;
|
||||
|
||||
if (screenAudioTrack) {
|
||||
this.logger.attachTrackDiagnostics(screenAudioTrack, `screenAudio:${peerId}`);
|
||||
let screenAudioSender = peerData.screenAudioSender;
|
||||
|
||||
if (!screenAudioSender) {
|
||||
const screenAudioTransceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
});
|
||||
|
||||
screenAudioSender = screenAudioTransceiver.sender;
|
||||
} else {
|
||||
const screenAudioTransceiver = peerData.connection.getTransceivers().find(
|
||||
(transceiver) => transceiver.sender === screenAudioSender
|
||||
);
|
||||
|
||||
if (screenAudioTransceiver?.direction === TRANSCEIVER_RECV_ONLY) {
|
||||
screenAudioTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenAudioSender = screenAudioSender;
|
||||
|
||||
if (typeof screenAudioSender.setStreams === 'function') {
|
||||
screenAudioSender.setStreams(this.activeScreenStream);
|
||||
}
|
||||
|
||||
screenAudioSender.replaceTrack(screenAudioTrack)
|
||||
.then(() => this.logger.info('screen audio replaceTrack ok', { peerId }))
|
||||
.catch((error) => this.logger.error('screen audio replaceTrack failed', error));
|
||||
}
|
||||
|
||||
this.callbacks.renegotiate(peerId);
|
||||
}
|
||||
|
||||
private detachScreenTracksFromPeer(peerData: PeerData, peerId: string): void {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(
|
||||
(transceiver) => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender
|
||||
);
|
||||
const screenAudioTransceiver = transceivers.find(
|
||||
(transceiver) => transceiver.sender === peerData.screenAudioSender
|
||||
);
|
||||
|
||||
if (videoTransceiver) {
|
||||
videoTransceiver.sender.replaceTrack(null).catch((error) => {
|
||||
this.logger.error('Failed to clear screen video sender track', error, { peerId });
|
||||
});
|
||||
|
||||
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
||||
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
|
||||
}
|
||||
}
|
||||
|
||||
if (screenAudioTransceiver) {
|
||||
screenAudioTransceiver.sender.replaceTrack(null).catch((error) => {
|
||||
this.logger.error('Failed to clear screen audio sender track', error, { peerId });
|
||||
});
|
||||
|
||||
if (screenAudioTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
||||
screenAudioTransceiver.direction = TRANSCEIVER_RECV_ONLY;
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = undefined;
|
||||
peerData.screenAudioSender = undefined;
|
||||
|
||||
this.callbacks.renegotiate(peerId);
|
||||
}
|
||||
|
||||
private isScreenShareSelectionAborted(error: unknown): boolean {
|
||||
return error instanceof Error
|
||||
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||
}
|
||||
|
||||
private rethrowIfScreenShareAborted(error: unknown): void {
|
||||
if (this.isScreenShareSelectionAborted(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private configureScreenStream(preset: ScreenShareQualityPreset): void {
|
||||
const screenVideoTrack = this.activeScreenStream?.getVideoTracks()[0];
|
||||
|
||||
if (!screenVideoTrack) {
|
||||
throw new Error('Screen capture returned no video track.');
|
||||
}
|
||||
|
||||
if ('contentHint' in screenVideoTrack) {
|
||||
screenVideoTrack.contentHint = preset.contentHint;
|
||||
}
|
||||
|
||||
this.logger.attachTrackDiagnostics(screenVideoTrack, 'screenVideo');
|
||||
this.logger.logStream('screen', this.activeScreenStream);
|
||||
|
||||
if (typeof screenVideoTrack.applyConstraints === 'function') {
|
||||
screenVideoTrack.applyConstraints({
|
||||
width: { ideal: preset.width, max: preset.width },
|
||||
height: { ideal: preset.height, max: preset.height },
|
||||
frameRate: { ideal: preset.frameRate, max: preset.frameRate }
|
||||
}).catch((error) => {
|
||||
this.logger.warn('Failed to re-apply screen video constraints', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async applyScreenShareVideoParameters(
|
||||
sender: RTCRtpSender,
|
||||
preset: ScreenShareQualityPreset,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const params = sender.getParameters();
|
||||
const encodings = params.encodings?.length ? params.encodings : [{} as RTCRtpEncodingParameters];
|
||||
|
||||
params.encodings = encodings.map((encoding, index) => index === 0
|
||||
? {
|
||||
...encoding,
|
||||
maxBitrate: preset.maxBitrateBps,
|
||||
maxFramerate: preset.frameRate,
|
||||
scaleResolutionDownBy: preset.scaleResolutionDownBy ?? encoding.scaleResolutionDownBy ?? 1
|
||||
}
|
||||
: encoding);
|
||||
|
||||
(params as RTCRtpSendParameters & { degradationPreference?: string }).degradationPreference = preset.degradationPreference;
|
||||
|
||||
await sender.setParameters(params);
|
||||
this.logger.info('Applied screen-share sender parameters', {
|
||||
peerId,
|
||||
maxBitrate: preset.maxBitrateBps,
|
||||
maxFramerate: preset.frameRate
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to apply screen-share sender parameters', error, { peerId });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { MediaManager } from './media.manager';
|
||||
import { LatencyProfile } from '../realtime.constants';
|
||||
import { VoiceStateSnapshot } from '../realtime.types';
|
||||
|
||||
interface VoiceSessionControllerDependencies {
|
||||
mediaManager: MediaManager;
|
||||
getIsScreenSharing(): boolean;
|
||||
setVoiceConnected(connected: boolean): void;
|
||||
setMuted(muted: boolean): void;
|
||||
setDeafened(deafened: boolean): void;
|
||||
setNoiseReductionEnabled(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export class VoiceSessionController {
|
||||
private voiceServerId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: VoiceSessionControllerDependencies
|
||||
) {}
|
||||
|
||||
getVoiceServerId(): string | null {
|
||||
return this.voiceServerId;
|
||||
}
|
||||
|
||||
getEffectiveServerId(activeServerId: string | null): string | null {
|
||||
return this.voiceServerId || activeServerId;
|
||||
}
|
||||
|
||||
handleVoiceConnected(): void {
|
||||
this.dependencies.setVoiceConnected(true);
|
||||
}
|
||||
|
||||
getCurrentVoiceState(): VoiceStateSnapshot {
|
||||
return {
|
||||
isConnected: this.dependencies.mediaManager.getIsVoiceActive(),
|
||||
isMuted: this.dependencies.mediaManager.getIsMicMuted(),
|
||||
isDeafened: this.dependencies.mediaManager.getIsSelfDeafened(),
|
||||
isScreenSharing: this.dependencies.getIsScreenSharing(),
|
||||
roomId: this.dependencies.mediaManager.getCurrentVoiceRoomId(),
|
||||
serverId: this.dependencies.mediaManager.getCurrentVoiceServerId()
|
||||
};
|
||||
}
|
||||
|
||||
async enableVoice(): Promise<MediaStream> {
|
||||
const stream = await this.dependencies.mediaManager.enableVoice();
|
||||
|
||||
this.syncMediaSignals();
|
||||
return stream;
|
||||
}
|
||||
|
||||
disableVoice(): void {
|
||||
this.voiceServerId = null;
|
||||
this.dependencies.mediaManager.disableVoice();
|
||||
this.dependencies.setVoiceConnected(false);
|
||||
}
|
||||
|
||||
async setLocalStream(stream: MediaStream): Promise<void> {
|
||||
await this.dependencies.mediaManager.setLocalStream(stream);
|
||||
this.syncMediaSignals();
|
||||
}
|
||||
|
||||
toggleMute(muted?: boolean): void {
|
||||
this.dependencies.mediaManager.toggleMute(muted);
|
||||
this.dependencies.setMuted(this.dependencies.mediaManager.getIsMicMuted());
|
||||
}
|
||||
|
||||
toggleDeafen(deafened?: boolean): void {
|
||||
this.dependencies.mediaManager.toggleDeafen(deafened);
|
||||
this.dependencies.setDeafened(this.dependencies.mediaManager.getIsSelfDeafened());
|
||||
}
|
||||
|
||||
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
|
||||
await this.dependencies.mediaManager.toggleNoiseReduction(enabled);
|
||||
this.dependencies.setNoiseReductionEnabled(this.dependencies.mediaManager.getIsNoiseReductionEnabled());
|
||||
}
|
||||
|
||||
setOutputVolume(volume: number): void {
|
||||
this.dependencies.mediaManager.setOutputVolume(volume);
|
||||
}
|
||||
|
||||
setInputVolume(volume: number): void {
|
||||
this.dependencies.mediaManager.setInputVolume(volume);
|
||||
}
|
||||
|
||||
async setAudioBitrate(kbps: number): Promise<void> {
|
||||
return await this.dependencies.mediaManager.setAudioBitrate(kbps);
|
||||
}
|
||||
|
||||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||||
return await this.dependencies.mediaManager.setLatencyProfile(profile);
|
||||
}
|
||||
|
||||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
||||
if (serverId) {
|
||||
this.voiceServerId = serverId;
|
||||
}
|
||||
|
||||
this.dependencies.mediaManager.startVoiceHeartbeat(roomId, serverId);
|
||||
}
|
||||
|
||||
stopVoiceHeartbeat(): void {
|
||||
this.dependencies.mediaManager.stopVoiceHeartbeat();
|
||||
}
|
||||
|
||||
resetVoiceSession(): void {
|
||||
this.voiceServerId = null;
|
||||
this.dependencies.mediaManager.stopVoiceHeartbeat();
|
||||
this.dependencies.mediaManager.disableVoice();
|
||||
this.dependencies.setVoiceConnected(false);
|
||||
}
|
||||
|
||||
private syncMediaSignals(): void {
|
||||
this.dependencies.setVoiceConnected(this.dependencies.mediaManager.getIsVoiceActive());
|
||||
this.dependencies.setMuted(this.dependencies.mediaManager.getIsMicMuted());
|
||||
this.dependencies.setDeafened(this.dependencies.mediaManager.getIsSelfDeafened());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user