Fix bugs and clean noise reduction
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user