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 './webrtc.service';
|
||||||
export * from './server-directory.service';
|
export * from './server-directory.service';
|
||||||
export * from './voice-session.service';
|
export * from './voice-session.service';
|
||||||
|
export * from './voice-activity.service';
|
||||||
export * from './external-link.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 lastJoinedServer: JoinedServerInfo | null = null;
|
||||||
private readonly memberServerIds = new Set<string>();
|
private readonly memberServerIds = new Set<string>();
|
||||||
private activeServerId: string | null = null;
|
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 serviceDestroyed$ = new Subject<void>();
|
||||||
|
|
||||||
private readonly _localPeerId = signal<string>(uuidv4());
|
private readonly _localPeerId = signal<string>(uuidv4());
|
||||||
@@ -194,18 +198,40 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SIGNALING_TYPE_SERVER_USERS:
|
case SIGNALING_TYPE_SERVER_USERS: {
|
||||||
this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0 });
|
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)) {
|
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 }) => {
|
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
||||||
if (user.oderId && !this.peerManager.activePeerConnections.has(user.oderId)) {
|
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.createPeerConnection(user.oderId, true);
|
||||||
this.peerManager.createAndSendOffer(user.oderId);
|
this.peerManager.createAndSendOffer(user.oderId);
|
||||||
|
if (message.serverId) {
|
||||||
|
this.peerServerMap.set(user.oderId, message.serverId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case SIGNALING_TYPE_USER_JOINED:
|
case SIGNALING_TYPE_USER_JOINED:
|
||||||
this.logger.info('User joined', { displayName: message.displayName, oderId: message.oderId });
|
this.logger.info('User joined', { displayName: message.displayName, oderId: message.oderId });
|
||||||
@@ -217,6 +243,11 @@ export class WebRTCService implements OnDestroy {
|
|||||||
|
|
||||||
case SIGNALING_TYPE_OFFER:
|
case SIGNALING_TYPE_OFFER:
|
||||||
if (message.fromUserId && message.payload?.sdp) {
|
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);
|
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
|
||||||
}
|
}
|
||||||
break;
|
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 {
|
private getCurrentVoiceState(): VoiceStateSnapshot {
|
||||||
return {
|
return {
|
||||||
isConnected: this._isVoiceConnected(),
|
isConnected: this._isVoiceConnected(),
|
||||||
@@ -424,6 +477,15 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
|
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.
|
* 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. */
|
/** Stop local voice capture and remove audio senders from peers. */
|
||||||
disableVoice(): void {
|
disableVoice(): void {
|
||||||
|
this.voiceServerId = null;
|
||||||
this.mediaManager.disableVoice();
|
this.mediaManager.disableVoice();
|
||||||
this._isVoiceConnected.set(false);
|
this._isVoiceConnected.set(false);
|
||||||
}
|
}
|
||||||
@@ -501,10 +564,20 @@ export class WebRTCService implements OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Start broadcasting voice-presence heartbeats to all peers.
|
* 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 roomId - The voice channel room ID.
|
||||||
* @param serverId - The voice channel server ID.
|
* @param serverId - The voice channel server ID.
|
||||||
*/
|
*/
|
||||||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
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);
|
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 from the signaling server and clean up all state. */
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
this.voiceServerId = null;
|
||||||
|
this.peerServerMap.clear();
|
||||||
this.leaveRoom();
|
this.leaveRoom();
|
||||||
this.mediaManager.stopVoiceHeartbeat();
|
this.mediaManager.stopVoiceHeartbeat();
|
||||||
this.signalingManager.close();
|
this.signalingManager.close();
|
||||||
@@ -551,6 +626,8 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fullCleanup(): void {
|
private fullCleanup(): void {
|
||||||
|
this.voiceServerId = null;
|
||||||
|
this.peerServerMap.clear();
|
||||||
this.peerManager.closeAllPeers();
|
this.peerManager.closeAllPeers();
|
||||||
this._connectedPeers.set([]);
|
this._connectedPeers.set([]);
|
||||||
this.mediaManager.disableVoice();
|
this.mediaManager.disableVoice();
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
[name]="u.displayName"
|
[name]="u.displayName"
|
||||||
[avatarUrl]="u.avatarUrl"
|
[avatarUrl]="u.avatarUrl"
|
||||||
size="xs"
|
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>
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
@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 { MessagesActions } from '../../../store/messages/messages.actions';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.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 { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||||
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||||
import { Channel, User } from '../../../core/models';
|
import { Channel, User } from '../../../core/models';
|
||||||
@@ -34,6 +35,7 @@ export class RoomsSidePanelComponent {
|
|||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
private voiceSessionService = inject(VoiceSessionService);
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
|
voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
activeTab = signal<TabView>('channels');
|
activeTab = signal<TabView>('channels');
|
||||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.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 { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
@@ -50,6 +51,7 @@ interface AudioDevice {
|
|||||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||||
private webrtcService = inject(WebRTCService);
|
private webrtcService = inject(WebRTCService);
|
||||||
private voiceSessionService = inject(VoiceSessionService);
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
|
private voiceActivity = inject(VoiceActivityService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private remoteStreamSubscription: Subscription | null = null;
|
private remoteStreamSubscription: Subscription | null = null;
|
||||||
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
||||||
@@ -255,6 +257,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.webrtcService.setLocalStream(stream);
|
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
|
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||||
const roomId = this.currentUser()?.voiceState?.roomId;
|
const roomId = this.currentUser()?.voiceState?.roomId;
|
||||||
this.webrtcService.startVoiceHeartbeat(roomId);
|
this.webrtcService.startVoiceHeartbeat(roomId);
|
||||||
@@ -313,6 +321,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.webrtcService.stopScreenShare();
|
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)
|
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
||||||
this.webrtcService.disableVoice();
|
this.webrtcService.disableVoice();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user