Voice Leveling (untested)
This commit is contained in:
@@ -145,4 +145,138 @@
|
||||
</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()"
|
||||
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 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"
|
||||
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 class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label>
|
||||
<select
|
||||
(change)="onStrengthChange($event)"
|
||||
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 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"
|
||||
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 class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Response Speed
|
||||
</label>
|
||||
<select
|
||||
(change)="onSpeedChange($event)"
|
||||
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()"
|
||||
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>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMic, lucideHeadphones, lucideAudioLines } from '@ng-icons/lucide';
|
||||
import { lucideMic, lucideHeadphones, lucideAudioLines, lucideActivity } from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -21,12 +22,14 @@ interface AudioDevice {
|
||||
lucideMic,
|
||||
lucideHeadphones,
|
||||
lucideAudioLines,
|
||||
lucideActivity,
|
||||
}),
|
||||
],
|
||||
templateUrl: './voice-settings.component.html',
|
||||
})
|
||||
export class VoiceSettingsComponent {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
readonly voiceLeveling = inject(VoiceLevelingService);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
@@ -151,4 +154,34 @@ export class VoiceSettingsComponent {
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
||||
import { 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';
|
||||
@@ -62,11 +63,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private voiceSessionService = inject(VoiceSessionService);
|
||||
private voiceActivity = inject(VoiceActivityService);
|
||||
private voiceLeveling = inject(VoiceLevelingService);
|
||||
private store = inject(Store);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private remoteStreamSubscription: Subscription | null = null;
|
||||
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||
/** Raw (unprocessed) remote streams keyed by peer ID — used to swap
|
||||
* between raw playback and leveled playback when the user toggles
|
||||
* the voice leveling setting. */
|
||||
private rawRemoteStreams = new Map<string, MediaStream>();
|
||||
/** Unsubscribe function for live voice-leveling toggle notifications. */
|
||||
private voiceLevelingUnsubscribe: (() => void) | null = null;
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -106,6 +114,12 @@ 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.rebuildAllRemoteAudio(enabled),
|
||||
);
|
||||
|
||||
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
|
||||
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
||||
this.playPendingStreams();
|
||||
@@ -132,9 +146,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
audio.remove();
|
||||
});
|
||||
this.remoteAudioElements.clear();
|
||||
this.rawRemoteStreams.clear();
|
||||
this.voiceLeveling.disableAll();
|
||||
|
||||
this.remoteStreamSubscription?.unsubscribe();
|
||||
this.voiceConnectedSubscription?.unsubscribe();
|
||||
this.voiceLevelingUnsubscribe?.();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,9 +176,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
for (const peerId of connectedPeers) {
|
||||
const stream = this.webrtcService.getRemoteStream(peerId);
|
||||
if (stream && stream.getAudioTracks().length > 0) {
|
||||
// Check if we already have an active audio element for this peer
|
||||
// Check if we already have an active audio element for this peer.
|
||||
// Compare against the stashed raw stream (not srcObject which may
|
||||
// be the leveled stream when voice leveling is enabled).
|
||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||
if (!existingAudio || existingAudio.srcObject !== stream) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
if (!existingAudio || trackedRaw !== stream) {
|
||||
this.playRemoteAudio(peerId, stream);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +191,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private removeRemoteAudio(peerId: string): void {
|
||||
// Remove from pending streams
|
||||
this.pendingRemoteStreams.delete(peerId);
|
||||
this.rawRemoteStreams.delete(peerId);
|
||||
|
||||
// Remove voice leveling pipeline for this speaker
|
||||
this.voiceLeveling.disable(peerId);
|
||||
|
||||
// Remove audio element
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
@@ -195,12 +219,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if audio track is live
|
||||
const audioTrack = audioTracks[0];
|
||||
if (audioTrack.readyState !== 'live') {
|
||||
// Still try to play it - it might become live later
|
||||
}
|
||||
|
||||
// Remove existing audio element for this peer if any
|
||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||
if (existingAudio) {
|
||||
@@ -208,24 +226,65 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
existingAudio.remove();
|
||||
}
|
||||
|
||||
// Create a new audio element for this peer
|
||||
// Always stash the raw stream so we can re-wire on toggle
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
|
||||
// ── Step 1: Immediately start playback with the raw stream ──
|
||||
// This guarantees audio is never lost even if the pipeline
|
||||
// build takes time or fails.
|
||||
const audio = new Audio();
|
||||
audio.srcObject = stream;
|
||||
audio.autoplay = true;
|
||||
audio.volume = this.outputVolume() / 100;
|
||||
|
||||
// Mute if deafened
|
||||
if (this.isDeafened()) {
|
||||
audio.muted = true;
|
||||
}
|
||||
|
||||
// Play the audio
|
||||
audio
|
||||
.play()
|
||||
.then(() => {})
|
||||
.catch((error) => {});
|
||||
|
||||
audio.play().then(() => {}).catch(() => {});
|
||||
this.remoteAudioElements.set(peerId, audio);
|
||||
|
||||
// ── Step 2: Asynchronously swap in the leveled stream ──
|
||||
// Only when voice leveling is enabled. If it fails or is
|
||||
// disabled, playback continues on the raw stream.
|
||||
if (this.voiceLeveling.enabled()) {
|
||||
this.voiceLeveling.enable(peerId, stream).then((leveledStream) => {
|
||||
// Guard: audio element may have been replaced or removed
|
||||
const currentAudio = this.remoteAudioElements.get(peerId);
|
||||
if (currentAudio && leveledStream !== stream) {
|
||||
currentAudio.srcObject = leveledStream;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild all remote Audio elements when the user toggles voice
|
||||
* leveling on or off. This runs synchronously for each peer,
|
||||
* swapping `srcObject` between the raw stream and the leveled one.
|
||||
*
|
||||
* Mirrors the noise-reduction live-toggle pattern.
|
||||
*/
|
||||
private async rebuildAllRemoteAudio(enabled: boolean): Promise<void> {
|
||||
if (enabled) {
|
||||
// Enable: build pipelines and swap to leveled streams
|
||||
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 { /* already playing raw — fine */ }
|
||||
}
|
||||
} else {
|
||||
// Disable: tear down all pipelines, swap back to raw streams
|
||||
this.voiceLeveling.disableAll();
|
||||
for (const [peerId, rawStream] of this.rawRemoteStreams) {
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
if (audio) {
|
||||
audio.srcObject = rawStream;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
@@ -344,12 +403,16 @@ 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();
|
||||
|
||||
// Clear all remote audio elements
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
});
|
||||
this.remoteAudioElements.clear();
|
||||
this.rawRemoteStreams.clear();
|
||||
this.pendingRemoteStreams.clear();
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
Reference in New Issue
Block a user