2026-03-11 16:06:41 +01:00
|
|
|
import {
|
|
|
|
|
Room,
|
|
|
|
|
RoomEvent,
|
|
|
|
|
Track,
|
|
|
|
|
RemoteTrack,
|
|
|
|
|
RemoteTrackPublication,
|
|
|
|
|
RemoteParticipant,
|
|
|
|
|
LocalTrack,
|
|
|
|
|
LocalAudioTrack,
|
|
|
|
|
type AudioCaptureOptions,
|
|
|
|
|
} from 'livekit-client';
|
2026-02-20 08:16:43 -05:00
|
|
|
import { AudioEngine, type SpatialPeerRuntime } from '../audio/audioEngine';
|
|
|
|
|
import type { RemoteUser } from '../network/protocol';
|
|
|
|
|
|
|
|
|
|
export type PeerRuntime = SpatialPeerRuntime & {
|
|
|
|
|
id: string;
|
2026-02-21 16:30:31 -05:00
|
|
|
remoteStream?: MediaStream;
|
2026-02-20 08:16:43 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type StatusHandler = (message: string) => void;
|
|
|
|
|
|
|
|
|
|
export class PeerManager {
|
|
|
|
|
private readonly peers = new Map<string, PeerRuntime>();
|
|
|
|
|
private outputDeviceId = '';
|
2026-03-11 16:06:41 +01:00
|
|
|
private room: Room | null = null;
|
|
|
|
|
private localTrack: LocalAudioTrack | null = null;
|
2026-03-11 16:56:01 +01:00
|
|
|
private pendingOutboundStream: MediaStream | null = null;
|
2026-02-20 08:16:43 -05:00
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly audio: AudioEngine,
|
|
|
|
|
private readonly status: StatusHandler,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
getPeer(id: string): PeerRuntime | undefined {
|
|
|
|
|
return this.peers.get(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getPeers(): Iterable<PeerRuntime> {
|
|
|
|
|
return this.peers.values();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
/** Connect to a LiveKit room using the provided token and URL. */
|
|
|
|
|
async connectToRoom(url: string, token: string): Promise<void> {
|
|
|
|
|
if (this.room) {
|
|
|
|
|
await this.room.disconnect();
|
|
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
const room = new Room({
|
|
|
|
|
audioCaptureDefaults: {
|
|
|
|
|
sampleRate: 48000,
|
|
|
|
|
channelCount: 2,
|
|
|
|
|
echoCancellation: false,
|
|
|
|
|
noiseSuppression: false,
|
|
|
|
|
autoGainControl: false,
|
|
|
|
|
} as AudioCaptureOptions,
|
|
|
|
|
audioOutput: {
|
|
|
|
|
deviceId: this.outputDeviceId || undefined,
|
|
|
|
|
},
|
|
|
|
|
publishDefaults: {
|
|
|
|
|
audioPreset: {
|
|
|
|
|
maxBitrate: 128_000,
|
|
|
|
|
},
|
|
|
|
|
dtx: false,
|
|
|
|
|
red: true,
|
|
|
|
|
stopMicTrackOnMute: false,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
|
|
|
|
if (track.kind !== Track.Kind.Audio) return;
|
|
|
|
|
void this.handleRemoteTrackSubscribed(participant, track);
|
|
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
room.on(RoomEvent.TrackUnsubscribed, (_track: RemoteTrack, _publication: RemoteTrackPublication, participant: RemoteParticipant) => {
|
|
|
|
|
const peer = this.peers.get(participant.identity);
|
|
|
|
|
if (peer) {
|
|
|
|
|
this.audio.cleanupPeerAudio(peer);
|
|
|
|
|
peer.remoteStream = undefined;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
2026-03-11 16:06:41 +01:00
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
room.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
|
|
|
|
|
const peer = this.peers.get(participant.identity);
|
|
|
|
|
if (peer) {
|
2026-02-21 16:30:31 -05:00
|
|
|
this.audio.cleanupPeerAudio(peer);
|
2026-03-11 16:06:41 +01:00
|
|
|
peer.remoteStream = undefined;
|
2026-02-21 16:30:31 -05:00
|
|
|
}
|
2026-03-11 16:06:41 +01:00
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
room.on(RoomEvent.Disconnected, () => {
|
|
|
|
|
this.status('LiveKit disconnected.');
|
|
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
room.on(RoomEvent.Reconnecting, () => {
|
|
|
|
|
this.status('LiveKit reconnecting...');
|
|
|
|
|
});
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
room.on(RoomEvent.Reconnected, () => {
|
|
|
|
|
this.status('LiveKit reconnected.');
|
2026-02-20 08:16:43 -05:00
|
|
|
});
|
|
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
await room.connect(url, token);
|
|
|
|
|
this.room = room;
|
2026-03-11 16:56:01 +01:00
|
|
|
if (this.pendingOutboundStream) {
|
|
|
|
|
const pending = this.pendingOutboundStream;
|
|
|
|
|
this.pendingOutboundStream = null;
|
|
|
|
|
await this.replaceOutgoingTrack(pending);
|
|
|
|
|
}
|
2026-03-11 16:06:41 +01:00
|
|
|
}
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
/** Ensure a peer entry exists for a given user (called when roster arrives). */
|
|
|
|
|
ensurePeer(targetId: string, userData: Partial<RemoteUser>): PeerRuntime {
|
|
|
|
|
const existing = this.peers.get(targetId);
|
|
|
|
|
if (existing) return existing;
|
2026-02-20 08:16:43 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
const peer: PeerRuntime = {
|
|
|
|
|
id: targetId,
|
|
|
|
|
nickname: userData.nickname ?? 'user...',
|
|
|
|
|
x: userData.x ?? 20,
|
|
|
|
|
y: userData.y ?? 20,
|
|
|
|
|
listenGain: 1,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.peers.set(targetId, peer);
|
2026-02-20 08:16:43 -05:00
|
|
|
return peer;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
/** Publish a local audio stream to the LiveKit room. */
|
2026-02-20 08:16:43 -05:00
|
|
|
async replaceOutgoingTrack(stream: MediaStream): Promise<void> {
|
2026-02-25 02:20:09 -05:00
|
|
|
const newTrack = stream.getAudioTracks()[0];
|
2026-03-11 16:06:41 +01:00
|
|
|
if (!newTrack) return;
|
|
|
|
|
|
2026-03-11 16:56:01 +01:00
|
|
|
if (!this.room) {
|
|
|
|
|
this.pendingOutboundStream = stream;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.pendingOutboundStream = null;
|
2026-02-25 02:49:49 -05:00
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
if (this.localTrack) {
|
|
|
|
|
// Replace the underlying MediaStreamTrack on the existing LiveKit track.
|
|
|
|
|
await this.localTrack.replaceTrack(newTrack);
|
|
|
|
|
} else {
|
|
|
|
|
const localAudioTrack = new LocalAudioTrack(newTrack, undefined, false);
|
|
|
|
|
await this.room.localParticipant.publishTrack(localAudioTrack, {
|
|
|
|
|
audioPreset: {
|
|
|
|
|
maxBitrate: 128_000,
|
|
|
|
|
},
|
|
|
|
|
dtx: false,
|
|
|
|
|
red: true,
|
|
|
|
|
stopMicTrackOnMute: false,
|
|
|
|
|
});
|
|
|
|
|
this.localTrack = localAudioTrack;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removePeer(id: string): void {
|
|
|
|
|
const peer = this.peers.get(id);
|
|
|
|
|
if (!peer) return;
|
|
|
|
|
this.audio.cleanupPeerAudio(peer);
|
|
|
|
|
this.peers.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cleanupAll(): void {
|
|
|
|
|
for (const id of this.peers.keys()) {
|
|
|
|
|
this.removePeer(id);
|
|
|
|
|
}
|
2026-03-11 16:06:41 +01:00
|
|
|
if (this.room) {
|
|
|
|
|
void this.room.disconnect();
|
|
|
|
|
this.room = null;
|
|
|
|
|
}
|
|
|
|
|
this.localTrack = null;
|
2026-03-11 16:56:01 +01:00
|
|
|
this.pendingOutboundStream = null;
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPeerPosition(id: string, x: number, y: number): void {
|
|
|
|
|
const peer = this.peers.get(id);
|
|
|
|
|
if (!peer) return;
|
|
|
|
|
peer.x = x;
|
|
|
|
|
peer.y = y;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setPeerNickname(id: string, nickname: string): void {
|
|
|
|
|
const peer = this.peers.get(id);
|
|
|
|
|
if (!peer) return;
|
|
|
|
|
peer.nickname = nickname;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:15:03 -05:00
|
|
|
setPeerListenGain(id: string, gain: number): void {
|
|
|
|
|
const peer = this.peers.get(id);
|
|
|
|
|
if (!peer) return;
|
|
|
|
|
peer.listenGain = gain;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getPeerListenGain(id: string): number {
|
|
|
|
|
const peer = this.peers.get(id);
|
|
|
|
|
if (!peer) return 1;
|
|
|
|
|
return Number.isFinite(peer.listenGain) ? Math.max(0, peer.listenGain as number) : 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 08:16:43 -05:00
|
|
|
async setOutputDevice(deviceId: string): Promise<void> {
|
|
|
|
|
this.outputDeviceId = deviceId;
|
|
|
|
|
for (const peer of this.peers.values()) {
|
|
|
|
|
if (!peer.audioElement) continue;
|
|
|
|
|
const sinkTarget = peer.audioElement as HTMLMediaElement & {
|
|
|
|
|
setSinkId?: (id: string) => Promise<void>;
|
|
|
|
|
};
|
|
|
|
|
await sinkTarget.setSinkId?.(deviceId).catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:30:31 -05:00
|
|
|
suspendRemoteAudio(): void {
|
|
|
|
|
for (const peer of this.peers.values()) {
|
|
|
|
|
this.audio.cleanupPeerAudio(peer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async resumeRemoteAudio(): Promise<void> {
|
|
|
|
|
for (const peer of this.peers.values()) {
|
|
|
|
|
if (!peer.remoteStream) continue;
|
|
|
|
|
await this.audio.attachRemoteStream(peer, peer.remoteStream, this.outputDeviceId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 16:06:41 +01:00
|
|
|
private async handleRemoteTrackSubscribed(participant: RemoteParticipant, track: RemoteTrack): Promise<void> {
|
|
|
|
|
const mediaStreamTrack = track.mediaStreamTrack;
|
|
|
|
|
if (!mediaStreamTrack) return;
|
|
|
|
|
|
|
|
|
|
const stream = new MediaStream([mediaStreamTrack]);
|
2026-03-11 16:56:01 +01:00
|
|
|
let peer = this.peers.get(participant.identity);
|
|
|
|
|
if (!peer) {
|
|
|
|
|
peer = this.ensurePeer(participant.identity, {
|
|
|
|
|
id: participant.identity,
|
|
|
|
|
nickname: participant.name ?? 'user...',
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-11 16:06:41 +01:00
|
|
|
|
|
|
|
|
peer.remoteStream = stream;
|
|
|
|
|
if (this.audio.isVoiceLayerEnabled()) {
|
|
|
|
|
await this.audio.attachRemoteStream(peer, stream, this.outputDeviceId);
|
|
|
|
|
} else {
|
|
|
|
|
this.audio.cleanupPeerAudio(peer);
|
2026-02-20 08:16:43 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|