fix bug with voice being global

This commit is contained in:
2026-03-02 03:55:50 +01:00
parent e231f4ed05
commit 47304254f3
6 changed files with 366 additions and 4 deletions

View File

@@ -5,4 +5,5 @@ export * from './database.service';
export * from './webrtc.service';
export * from './server-directory.service';
export * from './voice-session.service';
export * from './voice-activity.service';
export * from './external-link.service';

View File

@@ -0,0 +1,268 @@
/**
* VoiceActivityService — monitors audio levels for local microphone
* and remote peer streams, exposing per-user "speaking" state as
* reactive Angular signals.
*
* Usage:
* ```ts
* const speaking = voiceActivity.isSpeaking(userId);
* // speaking() => true when the user's audio level exceeds the threshold
*
* const volume = voiceActivity.volume(userId);
* // volume() => normalised 01 audio level
* ```
*
* Internally uses the Web Audio API ({@link AudioContext} +
* {@link AnalyserNode}) per tracked stream, with a single
* `requestAnimationFrame` poll loop.
*/
import { Injectable, signal, computed, inject, OnDestroy, Signal } from '@angular/core';
import { Subscription } from 'rxjs';
import { WebRTCService } from './webrtc.service';
/** RMS volume threshold (01) above which a user counts as "speaking". */
const SPEAKING_THRESHOLD = 0.015;
/** How many consecutive silent frames before we flip speaking → false. */
const SILENT_FRAME_GRACE = 8;
/** FFT size for the AnalyserNode (smaller = cheaper). */
const FFT_SIZE = 256;
/** Internal bookkeeping for a single tracked stream. */
interface TrackedStream {
/** The AudioContext used for analysis (one per stream to avoid cross-origin issues). */
ctx: AudioContext;
/** Source node wired from the MediaStream. */
source: MediaStreamAudioSourceNode;
/** Analyser node that provides time-domain data. */
analyser: AnalyserNode;
/** Reusable buffer for `getByteTimeDomainData`. */
dataArray: Uint8Array<ArrayBuffer>;
/** Writable signal for the normalised volume (01). */
volumeSignal: ReturnType<typeof signal<number>>;
/** Writable signal for speaking state. */
speakingSignal: ReturnType<typeof signal<boolean>>;
/** Counter of consecutive silent frames. */
silentFrames: number;
/** The MediaStream being analysed (for identity checks). */
stream: MediaStream;
}
@Injectable({ providedIn: 'root' })
export class VoiceActivityService implements OnDestroy {
private readonly webrtc = inject(WebRTCService);
/** All tracked streams keyed by user/peer ID. */
private readonly tracked = new Map<string, TrackedStream>();
/** Animation frame handle. */
private animFrameId: number | null = null;
/** RxJS subscriptions managed by this service. */
private readonly subs: Subscription[] = [];
/** Exposed map: userId → speaking (reactive snapshot). */
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
/** Reactive snapshot of all speaking users (for debugging / bulk consumption). */
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
constructor() {
// Wire up remote stream events
this.subs.push(
this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => {
this.trackStream(peerId, stream);
}),
);
this.subs.push(
this.webrtc.onPeerDisconnected.subscribe((peerId) => {
this.untrackStream(peerId);
}),
);
}
// ── Public API ──────────────────────────────────────────────────
/**
* Start monitoring the current user's local microphone stream.
* Should be called after voice is enabled (mic captured).
*
* @param userId - The local user's ID (used as the key in the speaking map).
* @param stream - The local {@link MediaStream} from `getUserMedia`.
*/
trackLocalMic(userId: string, stream: MediaStream): void {
this.trackStream(userId, stream);
}
/**
* Stop monitoring the current user's local microphone.
*
* @param userId - The local user's ID.
*/
untrackLocalMic(userId: string): void {
this.untrackStream(userId);
}
/**
* Returns a read-only signal that is `true` when the given user
* is currently speaking (audio level above threshold).
*
* If the user is not tracked yet, the returned signal starts as
* `false` and will become reactive once a stream is tracked.
*/
isSpeaking(userId: string): Signal<boolean> {
const entry = this.tracked.get(userId);
if (entry) return entry.speakingSignal.asReadonly();
// Return a computed that re-checks the map so it becomes live
// once the stream is tracked.
return computed(() => this._speakingMap().get(userId) ?? false);
}
/**
* Returns a read-only signal with the normalised (01) volume
* for the given user.
*/
volume(userId: string): Signal<number> {
const entry = this.tracked.get(userId);
if (entry) return entry.volumeSignal.asReadonly();
return computed(() => 0);
}
// ── Stream tracking ─────────────────────────────────────────────
/**
* Begin analysing a {@link MediaStream} for audio activity.
*
* If a stream is already tracked for `id`, it is replaced.
*/
trackStream(id: string, stream: MediaStream): void {
// If we already track this exact stream, skip.
const existing = this.tracked.get(id);
if (existing && existing.stream === stream) return;
// Clean up any previous entry for this id.
if (existing) this.disposeEntry(existing);
const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = FFT_SIZE;
source.connect(analyser);
// Do NOT connect analyser to ctx.destination — we don't want to
// double-play audio; playback is handled elsewhere.
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
const volumeSignal = signal(0);
const speakingSignal = signal(false);
this.tracked.set(id, {
ctx,
source,
analyser,
dataArray,
volumeSignal,
speakingSignal,
silentFrames: 0,
stream,
});
// Ensure the poll loop is running.
this.ensurePolling();
}
/** Stop tracking and dispose resources for a given ID. */
untrackStream(id: string): void {
const entry = this.tracked.get(id);
if (!entry) return;
this.disposeEntry(entry);
this.tracked.delete(id);
this.publishSpeakingMap();
// Stop polling when nothing is tracked.
if (this.tracked.size === 0) this.stopPolling();
}
// ── Polling loop ────────────────────────────────────────────────
private ensurePolling(): void {
if (this.animFrameId !== null) return;
this.poll();
}
private stopPolling(): void {
if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
}
/**
* Single `requestAnimationFrame`-based loop that reads audio levels
* from every tracked analyser and updates signals accordingly.
*/
private poll = (): void => {
let mapDirty = false;
this.tracked.forEach((entry) => {
const { analyser, dataArray, volumeSignal, speakingSignal } = entry;
analyser.getByteTimeDomainData(dataArray);
// Compute RMS volume from time-domain data (values 0255, centred at 128).
let sumSquares = 0;
for (let i = 0; i < dataArray.length; i++) {
const normalised = (dataArray[i] - 128) / 128;
sumSquares += normalised * normalised;
}
const rms = Math.sqrt(sumSquares / dataArray.length);
volumeSignal.set(rms);
const wasSpeaking = speakingSignal();
if (rms >= SPEAKING_THRESHOLD) {
entry.silentFrames = 0;
if (!wasSpeaking) {
speakingSignal.set(true);
mapDirty = true;
}
} else {
entry.silentFrames++;
if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) {
speakingSignal.set(false);
mapDirty = true;
}
}
});
if (mapDirty) this.publishSpeakingMap();
this.animFrameId = requestAnimationFrame(this.poll);
};
/** Rebuild the public speaking-map signal from current entries. */
private publishSpeakingMap(): void {
const map = new Map<string, boolean>();
this.tracked.forEach((entry, id) => {
map.set(id, entry.speakingSignal());
});
this._speakingMap.set(map);
}
// ── Cleanup ─────────────────────────────────────────────────────
private disposeEntry(entry: TrackedStream): void {
try { entry.source.disconnect(); } catch { /* already disconnected */ }
try { entry.ctx.close(); } catch { /* already closed */ }
}
ngOnDestroy(): void {
this.stopPolling();
this.tracked.forEach((entry) => this.disposeEntry(entry));
this.tracked.clear();
this.subs.forEach((s) => s.unsubscribe());
}
}

View File

@@ -58,6 +58,10 @@ export class WebRTCService implements OnDestroy {
private lastJoinedServer: JoinedServerInfo | null = null;
private readonly memberServerIds = new Set<string>();
private activeServerId: string | null = null;
/** The server ID where voice is currently active, or `null` when not in voice. */
private voiceServerId: string | null = null;
/** Maps each remote peer ID to the server they were discovered from. */
private readonly peerServerMap = new Map<string, string>();
private readonly serviceDestroyed$ = new Subject<void>();
private readonly _localPeerId = signal<string>(uuidv4());
@@ -194,18 +198,40 @@ export class WebRTCService implements OnDestroy {
}
break;
case SIGNALING_TYPE_SERVER_USERS:
this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0 });
case SIGNALING_TYPE_SERVER_USERS: {
this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0, serverId: message.serverId });
// Only create peer connections for the voice server (if in voice)
// or the currently active/viewed server (if not in voice).
const effectiveServerId = this.voiceServerId || this.activeServerId;
if (message.serverId && effectiveServerId && message.serverId !== effectiveServerId) {
this.logger.info('Skipping peer connections for non-target server', {
messageServerId: message.serverId,
effectiveServerId,
voiceActive: !!this.voiceServerId,
});
break;
}
if (message.users && Array.isArray(message.users)) {
// Close stale peer connections from other servers
if (message.serverId) {
this.closePeersNotInServer(message.serverId);
}
message.users.forEach((user: { oderId: string; displayName: string }) => {
if (user.oderId && !this.peerManager.activePeerConnections.has(user.oderId)) {
this.logger.info('Create peer connection to existing user', { oderId: user.oderId });
this.logger.info('Create peer connection to existing user', { oderId: user.oderId, serverId: message.serverId });
this.peerManager.createPeerConnection(user.oderId, true);
this.peerManager.createAndSendOffer(user.oderId);
if (message.serverId) {
this.peerServerMap.set(user.oderId, message.serverId);
}
}
});
}
break;
}
case SIGNALING_TYPE_USER_JOINED:
this.logger.info('User joined', { displayName: message.displayName, oderId: message.oderId });
@@ -217,6 +243,11 @@ export class WebRTCService implements OnDestroy {
case SIGNALING_TYPE_OFFER:
if (message.fromUserId && message.payload?.sdp) {
// Track inbound peer as belonging to our effective server
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
if (offerEffectiveServer && !this.peerServerMap.has(message.fromUserId)) {
this.peerServerMap.set(message.fromUserId, offerEffectiveServer);
}
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
}
break;
@@ -235,6 +266,28 @@ export class WebRTCService implements OnDestroy {
}
}
/**
* Close all peer connections that were discovered from a server
* other than `serverId`. Also removes their entries from
* {@link peerServerMap} so the bookkeeping stays clean.
*
* This ensures audio (and data channels) are scoped to only
* the voice-active (or currently viewed) server.
*/
private closePeersNotInServer(serverId: string): void {
const peersToClose: string[] = [];
this.peerServerMap.forEach((peerServerId, peerId) => {
if (peerServerId !== serverId) {
peersToClose.push(peerId);
}
});
for (const peerId of peersToClose) {
this.logger.info('Closing peer from different server', { peerId, currentServer: serverId });
this.peerManager.removePeer(peerId);
this.peerServerMap.delete(peerId);
}
}
private getCurrentVoiceState(): VoiceStateSnapshot {
return {
isConnected: this._isVoiceConnected(),
@@ -424,6 +477,15 @@ export class WebRTCService implements OnDestroy {
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
}
/**
* Get the current local media stream (microphone audio).
*
* @returns The local {@link MediaStream}, or `null` if voice is not active.
*/
getLocalStream(): MediaStream | null {
return this.mediaManager.getLocalStream();
}
/**
* Request microphone access and start sending audio to all peers.
*
@@ -437,6 +499,7 @@ export class WebRTCService implements OnDestroy {
/** Stop local voice capture and remove audio senders from peers. */
disableVoice(): void {
this.voiceServerId = null;
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
}
@@ -501,10 +564,20 @@ export class WebRTCService implements OnDestroy {
/**
* Start broadcasting voice-presence heartbeats to all peers.
*
* Also marks the given server as the active voice server and closes
* any peer connections that belong to other servers so that audio
* is isolated to the correct voice channel.
*
* @param roomId - The voice channel room ID.
* @param serverId - The voice channel server ID.
*/
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
if (serverId) {
this.voiceServerId = serverId;
// Remove peer connections that belong to a different server
// so audio does not leak across voice channels.
this.closePeersNotInServer(serverId);
}
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
}
@@ -535,6 +608,8 @@ export class WebRTCService implements OnDestroy {
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.voiceServerId = null;
this.peerServerMap.clear();
this.leaveRoom();
this.mediaManager.stopVoiceHeartbeat();
this.signalingManager.close();
@@ -551,6 +626,8 @@ export class WebRTCService implements OnDestroy {
}
private fullCleanup(): void {
this.voiceServerId = null;
this.peerServerMap.clear();
this.peerManager.closeAllPeers();
this._connectedPeers.set([]);
this.mediaManager.disableVoice();

View File

@@ -130,7 +130,7 @@
[name]="u.displayName"
[avatarUrl]="u.avatarUrl"
size="xs"
[ringClass]="u.voiceState?.isDeafened ? 'ring-2 ring-red-500' : u.voiceState?.isMuted ? 'ring-2 ring-yellow-500' : 'ring-2 ring-green-500'"
[ringClass]="u.voiceState?.isDeafened ? 'ring-2 ring-red-500' : u.voiceState?.isMuted ? 'ring-2 ring-yellow-500' : voiceActivity.isSpeaking(u.oderId || u.id)() ? 'ring-2 ring-green-400 shadow-[0_0_8px_2px_rgba(74,222,128,0.6)]' : 'ring-2 ring-green-500/40'"
/>
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {

View File

@@ -11,6 +11,7 @@ import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
import { Channel, User } from '../../../core/models';
@@ -34,6 +35,7 @@ export class RoomsSidePanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
voiceActivity = inject(VoiceActivityService);
activeTab = signal<TabView>('channels');
showFloatingControls = this.voiceSessionService.showFloatingControls;

View File

@@ -17,6 +17,7 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
@@ -50,6 +51,7 @@ interface AudioDevice {
export class VoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
private voiceActivity = inject(VoiceActivityService);
private store = inject(Store);
private remoteStreamSubscription: Subscription | null = null;
private remoteAudioElements = new Map<string, HTMLAudioElement>();
@@ -255,6 +257,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.setLocalStream(stream);
// Track local mic for voice-activity visualisation
const userId = this.currentUser()?.id;
if (userId) {
this.voiceActivity.trackLocalMic(userId, stream);
}
// Start voice heartbeat to broadcast presence every 5 seconds
const roomId = this.currentUser()?.voiceState?.roomId;
this.webrtcService.startVoiceHeartbeat(roomId);
@@ -313,6 +321,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.stopScreenShare();
}
// Untrack local mic from voice-activity visualisation
const userId = this.currentUser()?.id;
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
// Disable voice (stops audio tracks but keeps peer connections open for chat)
this.webrtcService.disableVoice();