Fix bugs and clean noise reduction

This commit is contained in:
2026-03-06 02:22:43 +01:00
parent 0ed9ca93d3
commit 2d84fbd91a
39 changed files with 3443 additions and 1544 deletions

View File

@@ -205,11 +205,14 @@ export class AdminPanelComponent {
/** Change a member's role and broadcast the update to all peers. */
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
const roomId = this.currentRoom()?.id;
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtc.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});

View File

@@ -33,7 +33,7 @@
class="w-4 h-4"
/>
<span>Users</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ knownUserCount() }}</span>
</button>
</div>
</div>
@@ -149,7 +149,10 @@
@if (voiceUsersInRoom(ch.id).length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom(ch.id); track u.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
<div
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40"
(contextmenu)="openVoiceUserVolumeMenu($event, u)"
>
<app-user-avatar
[name]="u.displayName"
[avatarUrl]="u.avatarUrl"
@@ -187,6 +190,13 @@
class="w-4 h-4 text-muted-foreground"
/>
}
@if (isUserLocallyMuted(u)) {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4 text-destructive"
title="Muted by you"
/>
}
</div>
}
</div>
@@ -300,8 +310,42 @@
</div>
}
<!-- Offline Users -->
@if (offlineRoomMembers().length > 0) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
<div class="space-y-1">
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded opacity-80">
<div class="relative">
<app-user-avatar
[name]="member.displayName"
[avatarUrl]="member.avatarUrl"
size="sm"
/>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-gray-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground/80 truncate">{{ member.displayName }}</p>
@if (member.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
<p class="text-[10px] text-muted-foreground">Offline</p>
</div>
</div>
}
</div>
</div>
}
<!-- No other users message -->
@if (onlineUsersFiltered().length === 0) {
@if (onlineUsersFiltered().length === 0 && offlineRoomMembers().length === 0) {
<div class="text-center py-4 text-muted-foreground">
<p class="text-sm">No other users in this server</p>
</div>
@@ -406,6 +450,17 @@
</app-context-menu>
}
<!-- Per-user volume context menu -->
@if (showVolumeMenu()) {
<app-user-volume-menu
[x]="volumeMenuX()"
[y]="volumeMenuY()"
[peerId]="volumeMenuPeerId()"
[displayName]="volumeMenuDisplayName()"
(closed)="showVolumeMenu.set(false)"
/>
}
<!-- Create channel dialog -->
@if (showCreateChannelDialog()) {
<app-confirm-dialog

View File

@@ -2,6 +2,7 @@
import {
Component,
inject,
computed,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
@@ -16,7 +17,8 @@ import {
lucideMonitor,
lucideHash,
lucideUsers,
lucidePlus
lucidePlus,
lucideVolumeX
} from '@ng-icons/lucide';
import {
selectOnlineUsers,
@@ -35,15 +37,18 @@ 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 { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
import {
ContextMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
ConfirmDialogComponent,
UserVolumeMenuComponent
} from '../../../shared';
import {
Channel,
ChatEvent,
RoomMember,
Room,
User
} from '../../../core/models';
@@ -54,7 +59,16 @@ type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
imports: [
CommonModule,
FormsModule,
NgIcon,
VoiceControlsComponent,
ContextMenuComponent,
UserVolumeMenuComponent,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideMessageSquare,
@@ -64,7 +78,8 @@ type TabView = 'channels' | 'users';
lucideMonitor,
lucideHash,
lucideUsers,
lucidePlus
lucidePlus,
lucideVolumeX
})
],
templateUrl: './rooms-side-panel.component.html'
@@ -76,6 +91,7 @@ export class RoomsSidePanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
private voicePlayback = inject(VoicePlaybackService);
voiceActivity = inject(VoiceActivityService);
activeTab = signal<TabView>('channels');
@@ -87,6 +103,31 @@ export class RoomsSidePanelComponent {
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
roomMembers = computed(() => this.currentRoom()?.members ?? []);
offlineRoomMembers = computed(() => {
const current = this.currentUser();
const onlineIds = new Set(this.onlineUsers().map((user) => user.oderId || user.id));
if (current) {
onlineIds.add(current.oderId || current.id);
}
return this.roomMembers().filter((member) => !onlineIds.has(this.roomMemberKey(member)));
});
knownUserCount = computed(() => {
const memberIds = new Set(
this.roomMembers()
.map((member) => this.roomMemberKey(member))
.filter(Boolean)
);
const current = this.currentUser();
if (current) {
memberIds.add(current.oderId || current.id);
}
return memberIds.size;
});
// Channel context menu state
showChannelMenu = signal(false);
@@ -108,6 +149,13 @@ export class RoomsSidePanelComponent {
userMenuY = signal(0);
contextMenuUser = signal<User | null>(null);
// Per-user volume context menu state
showVolumeMenu = signal(false);
volumeMenuX = signal(0);
volumeMenuY = signal(0);
volumeMenuPeerId = signal('');
volumeMenuDisplayName = signal('');
/** Return online users excluding the current user. */
// Filter out current user from online users list
onlineUsersFiltered() {
@@ -118,6 +166,10 @@ export class RoomsSidePanelComponent {
return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
}
private roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}
/** Check whether the current user has permission to manage channels. */
canManageChannels(): boolean {
const room = this.currentRoom();
@@ -287,9 +339,27 @@ export class RoomsSidePanelComponent {
this.showUserMenu.set(false);
}
/** Open the per-user volume context menu for a voice channel participant. */
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
// Don't show volume menu for the local user
const me = this.currentUser();
if (user.id === me?.id || user.oderId === me?.oderId)
return;
this.volumeMenuPeerId.set(user.oderId || user.id);
this.volumeMenuDisplayName.set(user.displayName);
this.volumeMenuX.set(evt.clientX);
this.volumeMenuY.set(evt.clientY);
this.showVolumeMenu.set(true);
}
/** Change a user's role and broadcast the update to connected peers. */
changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser();
const roomId = this.currentRoom()?.id;
this.closeUserMenu();
@@ -298,6 +368,7 @@ export class RoomsSidePanelComponent {
// Broadcast role change to peers
this.webrtc.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});
@@ -377,11 +448,29 @@ export class RoomsSidePanelComponent {
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
this.updateVoiceStateStore(roomId, room, current);
this.trackCurrentUserMic();
this.startVoiceHeartbeat(roomId, room);
this.broadcastVoiceConnected(roomId, room, current);
this.startVoiceSession(roomId, room);
}
private trackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
const micStream = this.webrtc.getRawMicStream();
if (userId && micStream) {
this.voiceActivity.trackLocalMic(userId, micStream);
}
}
private untrackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
}
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
if (!current?.id)
return;
@@ -445,6 +534,8 @@ export class RoomsSidePanelComponent {
// Stop voice heartbeat
this.webrtc.stopVoiceHeartbeat();
this.untrackCurrentUserMic();
// Disable voice locally
this.webrtc.disableVoice();
@@ -484,11 +575,7 @@ export class RoomsSidePanelComponent {
/** Count the number of users connected to a voice channel in the current room. */
voiceOccupancy(roomId: string): number {
const users = this.onlineUsers();
const room = this.currentRoom();
return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id)
.length;
return this.voiceUsersInRoom(roomId).length;
}
/** Dispatch a viewer:focus event to display a remote user's screen share. */
@@ -505,6 +592,13 @@ export class RoomsSidePanelComponent {
window.dispatchEvent(evt);
}
/** Check whether the local user has muted a specific voice user. */
isUserLocallyMuted(user: User): boolean {
const peerId = user.oderId || user.id;
return this.voicePlayback.isUserMuted(peerId);
}
/** Check whether a user is currently sharing their screen. */
isUserSharing(userId: string): boolean {
const me = this.currentUser();
@@ -524,13 +618,33 @@ export class RoomsSidePanelComponent {
return !!stream && stream.getVideoTracks().length > 0;
}
/** Return all users currently connected to a specific voice channel. */
/** Return all users currently connected to a specific voice channel, including the local user. */
voiceUsersInRoom(roomId: string) {
const room = this.currentRoom();
return this.onlineUsers().filter(
const me = this.currentUser();
const remoteUsers = this.onlineUsers().filter(
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
);
// Include the local user at the top if they are in this voice channel
if (
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id
) {
// Avoid duplicates if the current user is already in onlineUsers
const meId = me.id;
const meOderId = me.oderId;
const alreadyIncluded = remoteUsers.some(
(user) => user.id === meId || user.oderId === meOderId
);
if (!alreadyIncluded) {
return [me, ...remoteUsers];
}
}
return remoteUsers;
}
/** Check whether the current user is connected to the specified voice channel. */

View File

@@ -52,11 +52,14 @@ export class MembersSettingsComponent {
}
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
const roomId = this.server()?.id;
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
this.webrtcService.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
role
});

View File

@@ -229,178 +229,4 @@
</div>
</div>
</section>
<!-- Voice Leveling -->
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucideActivity"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Voice Leveling</h4>
</div>
<div class="space-y-3">
<!-- Master toggle -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Voice Leveling</p>
<p class="text-xs text-muted-foreground">Automatically equalise volume across speakers</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="voiceLeveling.enabled()"
(change)="onVoiceLevelingToggle()"
id="voice-leveling-toggle"
aria-label="Toggle voice leveling"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
<!-- Advanced controls - visible only when enabled -->
@if (voiceLeveling.enabled()) {
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
<!-- Target Loudness -->
<div class="pl-3">
<label
for="target-loudness-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS
</label>
<input
type="range"
[value]="voiceLeveling.targetDbfs()"
(input)="onTargetDbfsChange($event)"
min="-30"
max="-12"
step="1"
id="target-loudness-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5">
<span>-30 (quiet)</span>
<span>-12 (loud)</span>
</div>
</div>
<!-- AGC Strength -->
<div class="pl-3">
<label
for="agc-strength-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>AGC Strength</label
>
<select
(change)="onStrengthChange($event)"
id="agc-strength-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option
value="low"
[selected]="voiceLeveling.strength() === 'low'"
>
Low (gentle)
</option>
<option
value="medium"
[selected]="voiceLeveling.strength() === 'medium'"
>
Medium
</option>
<option
value="high"
[selected]="voiceLeveling.strength() === 'high'"
>
High (aggressive)
</option>
</select>
</div>
<!-- Max Gain Boost -->
<div class="pl-3">
<label
for="max-gain-slider"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB
</label>
<input
type="range"
[value]="voiceLeveling.maxGainDb()"
(input)="onMaxGainDbChange($event)"
min="3"
max="20"
step="1"
id="max-gain-slider"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div class="flex justify-between text-[10px] text-muted-foreground/60 mt-0.5">
<span>3 dB (subtle)</span>
<span>20 dB (strong)</span>
</div>
</div>
<!-- Response Speed -->
<div class="pl-3">
<label
for="response-speed-select"
class="block text-xs font-medium text-muted-foreground mb-1"
>
Response Speed
</label>
<select
(change)="onSpeedChange($event)"
id="response-speed-select"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option
value="slow"
[selected]="voiceLeveling.speed() === 'slow'"
>
Slow (natural)
</option>
<option
value="medium"
[selected]="voiceLeveling.speed() === 'medium'"
>
Medium
</option>
<option
value="fast"
[selected]="voiceLeveling.speed() === 'fast'"
>
Fast (aggressive)
</option>
</select>
</div>
<!-- Noise Floor Gate -->
<div class="pl-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Noise Floor Gate</p>
<p class="text-xs text-muted-foreground">Prevents boosting silence</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="voiceLeveling.noiseGate()"
(change)="onNoiseGateToggle()"
id="noise-gate-toggle"
aria-label="Toggle noise floor gate"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
}
</div>
</section>
</div>

View File

@@ -10,12 +10,11 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideHeadphones,
lucideAudioLines,
lucideActivity
lucideAudioLines
} from '@ng-icons/lucide';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
@@ -36,15 +35,14 @@ interface AudioDevice {
provideIcons({
lucideMic,
lucideHeadphones,
lucideAudioLines,
lucideActivity
lucideAudioLines
})
],
templateUrl: './voice-settings.component.html'
})
export class VoiceSettingsComponent {
private webrtcService = inject(WebRTCService);
readonly voiceLeveling = inject(VoiceLevelingService);
private voicePlayback = inject(VoicePlaybackService);
readonly audioService = inject(NotificationAudioService);
inputDevices = signal<AudioDevice[]>([]);
@@ -56,7 +54,7 @@ export class VoiceSettingsComponent {
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(false);
noiseReduction = signal(true);
constructor() {
this.loadVoiceSettings();
@@ -123,6 +121,11 @@ export class VoiceSettingsComponent {
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) {
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
}
// Apply persisted volume levels to the live audio pipelines
this.webrtcService.setInputVolume(this.inputVolume() / 100);
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
}
saveVoiceSettings(): void {
@@ -162,6 +165,7 @@ export class VoiceSettingsComponent {
const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10));
this.webrtcService.setInputVolume(this.inputVolume() / 100);
this.saveVoiceSettings();
}
@@ -170,6 +174,7 @@ export class VoiceSettingsComponent {
this.outputVolume.set(parseInt(input.value, 10));
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings();
}
@@ -203,40 +208,6 @@ export class VoiceSettingsComponent {
this.saveVoiceSettings();
}
/* ── Voice Leveling handlers ───────────────────────────────── */
onVoiceLevelingToggle(): void {
this.voiceLeveling.setEnabled(!this.voiceLeveling.enabled());
}
onTargetDbfsChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.voiceLeveling.setTargetDbfs(parseInt(input.value, 10));
}
onStrengthChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.voiceLeveling.setStrength(select.value as 'low' | 'medium' | 'high');
}
onMaxGainDbChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.voiceLeveling.setMaxGainDb(parseInt(input.value, 10));
}
onSpeedChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.voiceLeveling.setSpeed(select.value as 'slow' | 'medium' | 'fast');
}
onNoiseGateToggle(): void {
this.voiceLeveling.setNoiseGate(!this.voiceLeveling.noiseGate());
}
onNotificationVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;

View File

@@ -68,7 +68,7 @@ export class SettingsComponent implements OnInit {
newServerUrl = '';
autoReconnect = true;
searchAllServers = true;
noiseReduction = false;
noiseReduction = true;
/** Load persisted connection settings on component init. */
ngOnInit(): void {

View File

@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants';
export interface PlaybackOptions {
isConnected: boolean;
@@ -8,14 +8,58 @@ export interface PlaybackOptions {
isDeafened: boolean;
}
/**
* Per-peer Web Audio pipeline that routes the remote MediaStream
* through a GainNode so volume can be amplified beyond 100% (up to 200%).
*
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to
* the stream first so that `createMediaStreamSource` actually outputs
* audio. The element itself is silent - all audible output comes from
* the GainNode -> AudioContext.destination path.
*/
interface PeerAudioPipeline {
/** Muted <audio> element that "primes" the stream for Web Audio. */
audioElement: HTMLAudioElement;
/** AudioContext for this peer's pipeline. */
context: AudioContext;
/** Source node created from the remote stream. */
sourceNode: MediaStreamAudioSourceNode;
/** GainNode used to control per-user volume (0.0-2.0). */
gainNode: GainNode;
}
@Injectable({ providedIn: 'root' })
export class VoicePlaybackService {
private voiceLeveling = inject(VoiceLevelingService);
private webrtc = inject(WebRTCService);
private remoteAudioElements = new Map<string, HTMLAudioElement>();
/** Active Web Audio pipelines keyed by peer ID. */
private peerPipelines = new Map<string, PeerAudioPipeline>();
private pendingRemoteStreams = new Map<string, MediaStream>();
private rawRemoteStreams = new Map<string, MediaStream>();
/**
* Per-user volume overrides (0-200 integer, maps to 0.0-2.0 gain).
* Keyed by oderId so the setting persists across reconnections.
*/
private userVolumes = new Map<string, number>();
/** Per-user mute state. Keyed by oderId. */
private userMuted = new Map<string, boolean>();
/** Global master output volume (0.0-1.0 from the settings slider). */
private masterVolume = 1;
/** Whether the local user is deafened. */
private deafened = false;
constructor() {
this.loadPersistedVolumes();
}
// ---------------------------------------------------------------------------
// Public API - stream lifecycle
// ---------------------------------------------------------------------------
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
if (!options.isConnected) {
this.pendingRemoteStreams.set(peerId, stream);
@@ -26,39 +70,17 @@ export class VoicePlaybackService {
return;
}
this.removeAudioElement(peerId);
// Always stash the raw stream so we can re-wire on toggle
this.removePipeline(peerId);
this.rawRemoteStreams.set(peerId, stream);
// Start playback immediately with the raw stream
const audio = new Audio();
audio.srcObject = stream;
audio.autoplay = true;
audio.volume = options.outputVolume;
audio.muted = options.isDeafened;
audio.play().catch(() => {});
this.remoteAudioElements.set(peerId, audio);
// Swap to leveled stream if enabled
if (this.voiceLeveling.enabled()) {
this.voiceLeveling.enable(peerId, stream).then((leveledStream) => {
const currentAudio = this.remoteAudioElements.get(peerId);
if (currentAudio && leveledStream !== stream) {
currentAudio.srcObject = leveledStream;
}
})
.catch(() => {});
}
this.masterVolume = options.outputVolume;
this.deafened = options.isDeafened;
this.createPipeline(peerId, stream);
}
removeRemoteAudio(peerId: string): void {
this.pendingRemoteStreams.delete(peerId);
this.rawRemoteStreams.delete(peerId);
this.voiceLeveling.disable(peerId);
this.removeAudioElement(peerId);
this.removePipeline(peerId);
}
playPendingStreams(options: PlaybackOptions): void {
@@ -88,81 +110,232 @@ export class VoicePlaybackService {
}
}
async rebuildAllRemoteAudio(enabled: boolean, options: PlaybackOptions): Promise<void> {
if (enabled) {
for (const [peerId, rawStream] of this.rawRemoteStreams) {
try {
const leveledStream = await this.voiceLeveling.enable(peerId, rawStream);
const audio = this.remoteAudioElements.get(peerId);
if (audio && leveledStream !== rawStream) {
audio.srcObject = leveledStream;
}
} catch {}
}
} else {
this.voiceLeveling.disableAll();
for (const [peerId, rawStream] of this.rawRemoteStreams) {
const audio = this.remoteAudioElements.get(peerId);
if (audio) {
audio.srcObject = rawStream;
}
}
}
this.updateOutputVolume(options.outputVolume);
this.updateDeafened(options.isDeafened);
}
// ---------------------------------------------------------------------------
// Global volume / deafen (master slider from settings)
// ---------------------------------------------------------------------------
updateOutputVolume(volume: number): void {
this.remoteAudioElements.forEach((audio) => {
audio.volume = volume;
});
this.masterVolume = volume;
this.recalcAllGains();
}
updateDeafened(isDeafened: boolean): void {
this.remoteAudioElements.forEach((audio) => {
audio.muted = isDeafened;
});
this.deafened = isDeafened;
this.recalcAllGains();
}
// ---------------------------------------------------------------------------
// Per-user volume (0-200%) and mute
// ---------------------------------------------------------------------------
/** Get the per-user volume for a peer (0-200). Defaults to 100. */
getUserVolume(peerId: string): number {
return this.userVolumes.get(peerId) ?? 100;
}
/** Set per-user volume (0-200) and update the gain node in real time. */
setUserVolume(peerId: string, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
this.userVolumes.set(peerId, clamped);
this.applyGain(peerId);
this.persistVolumes();
}
/** Whether a specific user is muted by the local user. */
isUserMuted(peerId: string): boolean {
return this.userMuted.get(peerId) ?? false;
}
/** Toggle per-user mute. */
setUserMuted(peerId: string, muted: boolean): void {
this.userMuted.set(peerId, muted);
this.applyGain(peerId);
this.persistVolumes();
}
// ---------------------------------------------------------------------------
// Output device routing
// ---------------------------------------------------------------------------
applyOutputDevice(deviceId: string): void {
if (!deviceId)
return;
this.remoteAudioElements.forEach((audio) => {
const anyAudio = audio as any;
this.peerPipelines.forEach((pipeline) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyAudio = pipeline.audioElement as any;
if (typeof anyAudio.setSinkId === 'function') {
anyAudio.setSinkId(deviceId).catch(() => {});
}
// Also try setting sink on the AudioContext destination (Chromium ≥ 110)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyCtx = pipeline.context as any;
if (typeof anyCtx.setSinkId === 'function') {
anyCtx.setSinkId(deviceId).catch(() => {});
}
});
}
teardownAll(): void {
this.remoteAudioElements.forEach((audio) => {
audio.srcObject = null;
audio.remove();
});
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
this.remoteAudioElements.clear();
teardownAll(): void {
this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId));
this.peerPipelines.clear();
this.rawRemoteStreams.clear();
this.pendingRemoteStreams.clear();
}
// ---------------------------------------------------------------------------
// Private - Web Audio pipeline
// ---------------------------------------------------------------------------
/**
* Build the Web Audio graph for a remote peer:
*
* remoteStream
* ↓
* muted <audio> element (Chrome workaround - primes the stream)
* ↓
* MediaStreamSource → GainNode → AudioContext.destination
*/
private createPipeline(peerId: string, stream: MediaStream): void {
// 1) Chrome/Electron workaround: attach stream to a muted <audio>
const audioEl = new Audio();
audioEl.srcObject = stream;
audioEl.muted = true; // silent - we route audio through Web Audio API
audioEl.play().catch(() => {});
// 2) Set up Web Audio graph
const ctx = new AudioContext();
const sourceNode = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain();
sourceNode.connect(gainNode);
gainNode.connect(ctx.destination);
// 3) Store pipeline
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNode, gainNode };
this.peerPipelines.set(peerId, pipeline);
// 4) Apply current gain
this.applyGain(peerId);
}
/** Disconnect and clean up all nodes for a single peer. */
private removePipeline(peerId: string): void {
const pipeline = this.peerPipelines.get(peerId);
if (!pipeline)
return;
try {
pipeline.gainNode.disconnect();
pipeline.sourceNode.disconnect();
} catch {
// nodes may already be disconnected
}
pipeline.audioElement.srcObject = null;
pipeline.audioElement.remove();
if (pipeline.context.state !== 'closed') {
pipeline.context.close().catch(() => {});
}
this.peerPipelines.delete(peerId);
}
/**
* Compute and apply the effective gain for a peer.
*
* effectiveGain = masterVolume × (userVolume / 100)
*
* If the user is deafened or the peer is individually muted the gain
* is set to 0.
*/
private applyGain(peerId: string): void {
const pipeline = this.peerPipelines.get(peerId);
if (!pipeline)
return;
if (this.deafened || this.isUserMuted(peerId)) {
pipeline.gainNode.gain.value = 0;
return;
}
const userVol = this.getUserVolume(peerId) / 100; // 0.0-2.0
const effective = this.masterVolume * userVol;
pipeline.gainNode.gain.value = effective;
}
/** Recalculate gain for every active pipeline. */
private recalcAllGains(): void {
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
}
// ---------------------------------------------------------------------------
// Persistence helpers
// ---------------------------------------------------------------------------
private persistVolumes(): void {
try {
const data: Record<string, { volume: number; muted: boolean }> = {};
this.userVolumes.forEach((vol, id) => {
data[id] = { volume: vol, muted: this.userMuted.get(id) ?? false };
});
// Also persist any muted-only entries
this.userMuted.forEach((muted, id) => {
if (!data[id]) {
data[id] = { volume: 100, muted };
}
});
localStorage.setItem(STORAGE_KEY_USER_VOLUMES, JSON.stringify(data));
} catch {
// localStorage not available
}
}
private loadPersistedVolumes(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY_USER_VOLUMES);
if (!raw)
return;
const data = JSON.parse(raw) as Record<string, { volume: number; muted: boolean }>;
Object.entries(data).forEach(([id, entry]) => {
if (typeof entry.volume === 'number') {
this.userVolumes.set(id, entry.volume);
}
if (entry.muted) {
this.userMuted.set(id, true);
}
});
} catch {
// corrupted data - ignore
}
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
private hasAudio(stream: MediaStream): boolean {
return stream.getAudioTracks().length > 0;
}
private removeAudioElement(peerId: string): void {
const audio = this.remoteAudioElements.get(peerId);
if (audio) {
audio.srcObject = null;
audio.remove();
this.remoteAudioElements.delete(peerId);
}
}
}

View File

@@ -26,7 +26,6 @@ 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 { VoiceLevelingService } from '../../../core/services/voice-leveling.service';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
@@ -67,13 +66,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
private voiceActivity = inject(VoiceActivityService);
private voiceLeveling = inject(VoiceLevelingService);
private voicePlayback = inject(VoicePlaybackService);
private store = inject(Store);
private settingsModal = inject(SettingsModalService);
private remoteStreamSubscription: Subscription | null = null;
/** Unsubscribe function for live voice-leveling toggle notifications. */
private voiceLevelingUnsubscribe: (() => void) | null = null;
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -95,7 +91,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(false);
noiseReduction = signal(true);
private playbackOptions(): PlaybackOptions {
return {
@@ -121,12 +117,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
);
// Listen for live voice-leveling toggle changes so we can
// rebuild all remote Audio elements immediately (no reconnect).
this.voiceLevelingUnsubscribe = this.voiceLeveling.onEnabledChange(
(enabled) => this.voicePlayback.rebuildAllRemoteAudio(enabled, this.playbackOptions())
);
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
const options = this.playbackOptions();
@@ -150,11 +140,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
this.voicePlayback.teardownAll();
this.voiceLeveling.disableAll();
this.remoteStreamSubscription?.unsubscribe();
this.voiceConnectedSubscription?.unsubscribe();
this.voiceLevelingUnsubscribe?.();
}
async loadAudioDevices(): Promise<void> {
@@ -198,7 +186,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audio: {
deviceId: this.selectedInputDevice() || undefined,
echoCancellation: true,
noiseSuppression: true
noiseSuppression: !this.noiseReduction()
}
});
@@ -219,6 +207,25 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.startVoiceHeartbeat(roomId, serverId);
// Update local user's voice state in the store so the side panel
// shows us in the voice channel with a speaking indicator.
const user = this.currentUser();
if (user?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: true,
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
roomId,
serverId
}
})
);
}
// Broadcast voice state to other users
this.webrtcService.broadcastMessage({
type: 'voice-state',
@@ -279,9 +286,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Disable voice (stops audio tracks but keeps peer connections open for chat)
this.webrtcService.disableVoice();
// Tear down all voice leveling pipelines
this.voiceLeveling.disableAll();
this.voicePlayback.teardownAll();
const user = this.currentUser();
@@ -313,6 +317,22 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.isMuted.update((current) => !current);
this.webrtcService.toggleMute(this.isMuted());
// Update local store so the side panel reflects the mute state
const user = this.currentUser();
if (user?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened()
}
})
);
}
// Broadcast mute state change
this.webrtcService.broadcastMessage({
type: 'voice-state',
@@ -349,6 +369,22 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isDeafened: this.isDeafened()
}
});
// Update local store so the side panel reflects the deafen/mute state
const user = this.currentUser();
if (user?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened()
}
})
);
}
}
async toggleScreenShare(): Promise<void> {
@@ -397,6 +433,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10));
this.webrtcService.setInputVolume(this.inputVolume() / 100);
this.saveSettings();
}
@@ -506,6 +543,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private applySettingsToWebRTC(): void {
try {
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
this.webrtcService.setInputVolume(this.inputVolume() / 100);
this.webrtcService.setAudioBitrate(this.audioBitrate());
this.webrtcService.setLatencyProfile(this.latencyProfile());
this.applyOutputDevice();