feat: Add TURN server support
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s

This commit is contained in:
2026-04-18 21:27:04 +02:00
parent 167c45ba8d
commit 44588e8789
60 changed files with 2404 additions and 365 deletions
@@ -0,0 +1,139 @@
import {
Injectable,
inject,
computed,
type Signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { toSignal } from '@angular/core/rxjs-interop';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import type { User } from '../../../../shared-kernel';
/**
* Connectivity health status for a single peer in voice.
*/
export interface PeerConnectivityHealth {
peerId: string;
/** Number of voice peers this peer can send/receive audio to/from. */
connectedPeerCount: number;
/** Total peers expected in voice. */
totalVoicePeers: number;
/** true when this peer has the fewest connections -> warning target. */
hasDesync: boolean;
}
/**
* Tracks per-peer voice connectivity health by comparing the number
* of connected audio streams each peer has. Peers with fewest
* bidirectional audio connections are flagged.
*
* Uses peer latency data as proxy for healthy bidirectional connection.
*/
@Injectable({ providedIn: 'root' })
export class VoiceConnectivityHealthService {
readonly currentUser: Signal<User | null | undefined>;
readonly onlineUsers: Signal<User[]>;
readonly desyncPeerIds: Signal<ReadonlySet<string>>;
readonly localUserHasDesync: Signal<boolean>;
private readonly webrtc = inject(RealtimeSessionFacade);
constructor() {
const store = inject(Store);
this.currentUser = toSignal(store.select(selectCurrentUser));
this.onlineUsers = toSignal(store.select(selectOnlineUsers), { initialValue: [] });
/**
* Map of peerId -> true for peers that have connectivity issues.
* A peer is flagged when it has fewer healthy connections than the
* majority of users in the same voice channel.
*/
this.desyncPeerIds = computed<ReadonlySet<string>>(() => {
const me = this.currentUser();
const myVoice = me?.voiceState;
if (!myVoice?.isConnected || !myVoice.roomId || !myVoice.serverId) {
return new Set<string>();
}
// Find all users in same voice room
const voiceUsers = this.onlineUsers().filter(
(user) =>
user.voiceState?.isConnected
&& user.voiceState.roomId === myVoice.roomId
&& user.voiceState.serverId === myVoice.serverId
);
if (voiceUsers.length < 2) {
return new Set<string>();
}
// Use peer latencies as proxy. A peer we can ping has a working
// data-channel (= working RTCPeerConnection). Peers without latency
// measurements are considered unreachable.
const connectedPeers = this.webrtc.connectedPeers();
const connectedSet = new Set(connectedPeers);
const myKey = me?.oderId || me?.id;
if (!myKey) {
return new Set<string>();
}
// Count how many voice peers each voice user is connected to (from
// the local perspective). We can only see our own connections - but
// if WE can't reach peer X while we CAN reach peers Y and Z, peer X
// is the one with issues.
const unreachableFromUs = new Set<string>();
for (const user of voiceUsers) {
const key = user.oderId || user.id;
if (key === myKey) {
continue;
}
const hasConnection = connectedSet.has(key)
|| connectedSet.has(user.id)
|| connectedSet.has(user.oderId ?? '');
if (!hasConnection) {
unreachableFromUs.add(key);
}
}
// If we can reach everyone, no desync
if (unreachableFromUs.size === 0) {
return new Set<string>();
}
// If we can't reach ANYONE, the problem is likely on our end
const reachableCount = voiceUsers.length - 1 - unreachableFromUs.size;
if (reachableCount === 0 && voiceUsers.length > 2) {
// Everyone unreachable from us -> WE are the problem
return new Set([myKey]);
}
return unreachableFromUs;
});
/**
* Whether the LOCAL user is the one with connectivity issues.
*/
this.localUserHasDesync = computed(() => {
const me = this.currentUser();
const myKey = me?.oderId || me?.id;
return !!myKey && this.desyncPeerIds().has(myKey);
});
}
/**
* Check if a specific peer has a desync warning.
*/
hasPeerDesync(peerKey: string): boolean {
return this.desyncPeerIds().has(peerKey);
}
}
@@ -209,7 +209,7 @@ export class VoicePlaybackService {
* ↓
* muted <audio> element (Chrome workaround - primes the stream)
* ↓
* MediaStreamSource GainNode MediaStreamDestination output <audio>
* MediaStreamSource -> GainNode -> MediaStreamDestination -> output <audio>
*/
private createPipeline(peerId: string, stream: MediaStream): void {
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.