fix bug with voice being global
This commit is contained in:
@@ -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';
|
||||
|
||||
268
src/app/core/services/voice-activity.service.ts
Normal file
268
src/app/core/services/voice-activity.service.ts
Normal 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 0–1 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 (0–1) 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 (0–1). */
|
||||
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 (0–1) 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 0–255, 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user