Voice Leveling (untested)

This commit is contained in:
2026-03-03 03:41:59 +01:00
parent cf91d77502
commit 8315df42fc
9 changed files with 1313 additions and 19 deletions

View File

@@ -34,3 +34,6 @@ export const DEFAULT_VOLUME = 100;
/** Default search debounce time in milliseconds. */
export const SEARCH_DEBOUNCE_MS = 300;
/** Key used to persist voice leveling (AGC) settings. */
export const STORAGE_KEY_VOICE_LEVELING_SETTINGS = 'metoyou_voice_leveling_settings';

View File

@@ -8,3 +8,4 @@ export * from './voice-session.service';
export * from './voice-activity.service';
export * from './external-link.service';
export * from './settings-modal.service';
export * from './voice-leveling.service';

View File

@@ -0,0 +1,258 @@
/**
* VoiceLevelingService — Angular service that manages the
* per-speaker voice leveling (AGC) system.
*
* ═══════════════════════════════════════════════════════════════════
* RESPONSIBILITIES
* ═══════════════════════════════════════════════════════════════════
*
* 1. Owns the {@link VoiceLevelingManager} singleton and proxies
* its methods to the rest of the application.
*
* 2. Persists user settings in localStorage and restores them on
* construction so preferences survive across sessions.
*
* 3. Exposes reactive Angular signals for the current settings so
* UI components can bind declaratively.
*
* 4. Provides an `enable` / `disable` / `disableAll` API that
* the voice-controls component uses to insert and remove the
* AGC pipeline from the remote audio playback chain — mirroring
* the {@link NoiseReductionManager} toggle pattern.
*
* 5. Fires a callback when the user toggles the enabled state so
* the voice-controls component can rebuild audio elements live.
*
* ═══════════════════════════════════════════════════════════════════
*/
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
import {
VoiceLevelingManager,
VoiceLevelingSettings,
DEFAULT_VOICE_LEVELING_SETTINGS,
} from './webrtc/voice-leveling.manager';
import { WebRTCLogger } from './webrtc/webrtc-logger';
import { STORAGE_KEY_VOICE_LEVELING_SETTINGS } from '../constants';
@Injectable({ providedIn: 'root' })
export class VoiceLevelingService implements OnDestroy {
/** The underlying per-speaker pipeline manager. */
private readonly manager: VoiceLevelingManager;
/* ── Reactive signals ────────────────────────────────────────── */
private readonly _enabled = signal(DEFAULT_VOICE_LEVELING_SETTINGS.enabled);
private readonly _targetDbfs = signal(DEFAULT_VOICE_LEVELING_SETTINGS.targetDbfs);
private readonly _strength = signal<'low' | 'medium' | 'high'>(DEFAULT_VOICE_LEVELING_SETTINGS.strength);
private readonly _maxGainDb = signal(DEFAULT_VOICE_LEVELING_SETTINGS.maxGainDb);
private readonly _speed = signal<'slow' | 'medium' | 'fast'>(DEFAULT_VOICE_LEVELING_SETTINGS.speed);
private readonly _noiseGate = signal(DEFAULT_VOICE_LEVELING_SETTINGS.noiseGate);
/** Whether voice leveling is enabled. */
readonly enabled = computed(() => this._enabled());
/** Target loudness in dBFS. */
readonly targetDbfs = computed(() => this._targetDbfs());
/** AGC strength preset. */
readonly strength = computed(() => this._strength());
/** Maximum gain boost in dB. */
readonly maxGainDb = computed(() => this._maxGainDb());
/** Gain response speed preset. */
readonly speed = computed(() => this._speed());
/** Whether the noise floor gate is active. */
readonly noiseGate = computed(() => this._noiseGate());
/** Number of speakers currently being processed. */
readonly activeSpeakerCount = computed(() => this.manager.activePipelineCount);
/* ── Enabled-change callbacks ────────────────────────────────── */
private _enabledChangeCallbacks: Array<(enabled: boolean) => void> = [];
constructor() {
const logger = new WebRTCLogger(/* debugEnabled */ false);
this.manager = new VoiceLevelingManager(logger);
// Restore persisted settings
this._loadSettings();
}
/* ── Settings API ────────────────────────────────────────────── */
/**
* Toggle the enabled state.
*
* Unlike the manager's `enable`/`disable` which operate per-peer,
* this is the user-facing master toggle. It persists the setting
* and notifies all registered callbacks so that the voice-controls
* component can rebuild Audio elements immediately.
*/
setEnabled(enabled: boolean): void {
this._enabled.set(enabled);
this._saveSettings();
// Notify listeners so the voice-controls component can rebuild
this._enabledChangeCallbacks.forEach((cb) => cb(enabled));
}
/** Set the target loudness in dBFS (30 to 12). */
setTargetDbfs(value: number): void {
const clamped = Math.max(-30, Math.min(-12, value));
this._targetDbfs.set(clamped);
this._pushAndPersist({ targetDbfs: clamped });
}
/** Set the AGC strength preset. */
setStrength(strength: 'low' | 'medium' | 'high'): void {
this._strength.set(strength);
this._pushAndPersist({ strength });
}
/** Set the maximum gain boost in dB (3 to 20). */
setMaxGainDb(value: number): void {
const clamped = Math.max(3, Math.min(20, value));
this._maxGainDb.set(clamped);
this._pushAndPersist({ maxGainDb: clamped });
}
/** Set the gain response speed preset. */
setSpeed(speed: 'slow' | 'medium' | 'fast'): void {
this._speed.set(speed);
this._pushAndPersist({ speed });
}
/** Toggle the noise floor gate. */
setNoiseGate(enabled: boolean): void {
this._noiseGate.set(enabled);
this._pushAndPersist({ noiseGate: enabled });
}
/* ── Pipeline API (mirrors NoiseReductionManager pattern) ───── */
/**
* Build the AGC pipeline for a remote speaker and return the
* leveled stream. The caller sets this as `audio.srcObject`.
*
* @param peerId The remote peer's unique identifier.
* @param stream The remote peer's raw {@link MediaStream}.
* @returns The leveled {@link MediaStream} for playback.
*/
async enable(peerId: string, stream: MediaStream): Promise<MediaStream> {
return this.manager.enable(peerId, stream);
}
/**
* Tear down the AGC pipeline for a single speaker.
* The caller swaps the Audio element back to the raw stream.
*
* @param peerId The peer to clean up.
*/
disable(peerId: string): void {
this.manager.disable(peerId);
}
/** Tear down all speaker pipelines at once. */
disableAll(): void {
this.manager.disableAll();
}
/**
* Set the post-AGC volume for a specific speaker.
*
* @param peerId The speaker's peer ID.
* @param volume Normalised volume (01).
*/
setSpeakerVolume(peerId: string, volume: number): void {
this.manager.setSpeakerVolume(peerId, volume);
}
/**
* Set the master volume applied after AGC to all speakers.
*
* @param volume Normalised volume (01).
*/
setMasterVolume(volume: number): void {
this.manager.setMasterVolume(volume);
}
/* ── Live toggle notification ────────────────────────────────── */
/**
* Register a callback that fires whenever the user toggles the
* enabled state. Returns an unsubscribe function.
*/
onEnabledChange(callback: (enabled: boolean) => void): () => void {
this._enabledChangeCallbacks.push(callback);
return () => {
this._enabledChangeCallbacks = this._enabledChangeCallbacks.filter(
(cb) => cb !== callback,
);
};
}
/* ── Persistence ─────────────────────────────────────────────── */
/** Push a partial config update to the manager and persist. */
private _pushAndPersist(partial: Partial<VoiceLevelingSettings>): void {
this.manager.updateSettings(partial);
this._saveSettings();
}
/** Persist all current settings to localStorage. */
private _saveSettings(): void {
try {
const settings: VoiceLevelingSettings = {
enabled: this._enabled(),
targetDbfs: this._targetDbfs(),
strength: this._strength(),
maxGainDb: this._maxGainDb(),
speed: this._speed(),
noiseGate: this._noiseGate(),
};
localStorage.setItem(
STORAGE_KEY_VOICE_LEVELING_SETTINGS,
JSON.stringify(settings),
);
} catch { /* localStorage unavailable — ignore */ }
}
/** Load settings from localStorage and apply to the manager. */
private _loadSettings(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_LEVELING_SETTINGS);
if (!raw) return;
const saved = JSON.parse(raw) as Partial<VoiceLevelingSettings>;
if (typeof saved.enabled === 'boolean') this._enabled.set(saved.enabled);
if (typeof saved.targetDbfs === 'number') this._targetDbfs.set(saved.targetDbfs);
if (saved.strength === 'low' || saved.strength === 'medium' || saved.strength === 'high') {
this._strength.set(saved.strength);
}
if (typeof saved.maxGainDb === 'number') this._maxGainDb.set(saved.maxGainDb);
if (saved.speed === 'slow' || saved.speed === 'medium' || saved.speed === 'fast') {
this._speed.set(saved.speed);
}
if (typeof saved.noiseGate === 'boolean') this._noiseGate.set(saved.noiseGate);
// Push the restored settings to the manager
this.manager.updateSettings({
enabled: this._enabled(),
targetDbfs: this._targetDbfs(),
strength: this._strength(),
maxGainDb: this._maxGainDb(),
speed: this._speed(),
noiseGate: this._noiseGate(),
});
} catch { /* corrupted data — use defaults */ }
}
/* ── Cleanup ─────────────────────────────────────────────────── */
ngOnDestroy(): void {
this.manager.destroy();
this._enabledChangeCallbacks = [];
}
}

View File

@@ -12,3 +12,4 @@ export * from './peer-connection.manager';
export * from './media.manager';
export * from './screen-share.manager';
export * from './noise-reduction.manager';
export * from './voice-leveling.manager';

View File

@@ -0,0 +1,359 @@
/**
* VoiceLevelingManager — manages per-speaker automatic gain control
* pipelines for remote voice streams.
*
* ═══════════════════════════════════════════════════════════════════
* ARCHITECTURE
* ═══════════════════════════════════════════════════════════════════
*
* For every remote MediaStream a dedicated processing chain is built:
*
* Remote MediaStreamTrack
* ↓
* MediaStreamSource (AudioContext)
* ↓
* AudioWorkletNode (VoiceLevelingProcessor — per-speaker AGC)
* ↓
* GainNode (post fine-tuning — master volume knob)
* ↓
* MediaStreamDestination → leveled MediaStream
*
* Each speaker gets its own AudioWorkletNode instance so that the
* AGC adapts independently to each person's microphone level.
*
* A fallback mode using {@link DynamicsCompressorNode} is provided
* for browsers that don't support AudioWorklet or SharedArrayBuffer.
*
* ═══════════════════════════════════════════════════════════════════
* DESIGN — mirrors the NoiseReductionManager pattern
* ═══════════════════════════════════════════════════════════════════
*
* • `enable(peerId, rawStream)` builds the pipeline and returns a
* processed stream.
* • `disable(peerId)` tears down the pipeline. The caller swaps
* the Audio element's srcObject back to the raw stream.
* • `disableAll()` tears down every pipeline at once.
*
* The calling component keeps a reference to the original raw stream
* and swaps the Audio element's `srcObject` between the raw stream
* and the leveled stream when the user toggles the feature — exactly
* like noise reduction does for the local mic.
*
* ═══════════════════════════════════════════════════════════════════
*/
import { WebRTCLogger } from './webrtc-logger';
/* ──────────────────────────────────────────────────────────────── */
/* Types */
/* ──────────────────────────────────────────────────────────────── */
/** User-configurable voice leveling parameters. */
export interface VoiceLevelingSettings {
/** Master on/off toggle. When false, audio passes through unchanged. */
enabled: boolean;
/** Target loudness in dBFS (30 … 12). Default 18. */
targetDbfs: number;
/** AGC strength preset. Default 'medium'. */
strength: 'low' | 'medium' | 'high';
/** Maximum gain boost in dB (3 … 20). Default 12. */
maxGainDb: number;
/** Gain response speed preset. Default 'medium'. */
speed: 'slow' | 'medium' | 'fast';
/** Whether the silence noise gate is active. Default false. */
noiseGate: boolean;
}
/** Default settings used when none are explicitly provided. */
export const DEFAULT_VOICE_LEVELING_SETTINGS: VoiceLevelingSettings = {
enabled: false,
targetDbfs: -18,
strength: 'medium',
maxGainDb: 12,
speed: 'medium',
noiseGate: false,
};
/**
* Internal bookkeeping for a single speaker's processing chain.
*/
interface SpeakerPipeline {
ctx: AudioContext;
source: MediaStreamAudioSourceNode;
workletNode: AudioWorkletNode | null;
compressorNode: DynamicsCompressorNode | null;
gainNode: GainNode;
destination: MediaStreamAudioDestinationNode;
originalStream: MediaStream;
isFallback: boolean;
}
/** AudioWorklet module path (served from public/). */
const WORKLET_MODULE_PATH = 'voice-leveling-worklet.js';
/** Processor name — must match `registerProcessor` in the worklet. */
const WORKLET_PROCESSOR_NAME = 'VoiceLevelingProcessor';
/* ──────────────────────────────────────────────────────────────── */
/* Manager */
/* ──────────────────────────────────────────────────────────────── */
export class VoiceLevelingManager {
/** Active per-speaker pipelines keyed by peer ID. */
private readonly pipelines = new Map<string, SpeakerPipeline>();
/** Cached DSP settings pushed to worklets. */
private _settings: VoiceLevelingSettings = { ...DEFAULT_VOICE_LEVELING_SETTINGS };
/** Whether the AudioWorklet module is available. */
private _workletAvailable: boolean | null = null;
/** Shared AudioContext (avoids browser per-page limits). */
private _sharedCtx: AudioContext | null = null;
/** Whether the worklet module has been loaded. */
private _workletLoaded = false;
constructor(private readonly logger: WebRTCLogger) {}
/* ── Public API ─────────────────────────────────────────────── */
get settings(): Readonly<VoiceLevelingSettings> {
return this._settings;
}
get activePeerIds(): string[] {
return Array.from(this.pipelines.keys());
}
get activePipelineCount(): number {
return this.pipelines.size;
}
/**
* Update DSP settings and propagate to all active worklets.
* Only provided keys are updated; the rest stay unchanged.
*/
updateSettings(partial: Partial<VoiceLevelingSettings>): void {
this._settings = { ...this._settings, ...partial };
this.pipelines.forEach((p) => this._pushSettingsToPipeline(p));
}
/**
* Enable voice leveling for a single speaker.
*
* Builds the processing pipeline and returns the leveled
* {@link MediaStream}. The caller sets this as the Audio
* element's `srcObject`.
*
* If a pipeline already exists for this peer with the **same**
* raw stream, the existing leveled stream is returned (no rebuild).
*
* @param peerId Remote peer identifier.
* @param stream The remote peer's raw MediaStream.
* @returns The leveled MediaStream (or raw on failure).
*/
async enable(peerId: string, stream: MediaStream): Promise<MediaStream> {
// Reuse existing pipeline if it targets the same stream
const existing = this.pipelines.get(peerId);
if (existing && existing.originalStream === stream) {
return existing.destination.stream;
}
// Tear down stale pipeline for this peer
if (existing) {
this._disposePipeline(existing);
this.pipelines.delete(peerId);
}
// No audio tracks → nothing to process
if (stream.getAudioTracks().length === 0) {
this.logger.info('VoiceLeveling: no audio tracks, skipping', { peerId });
return stream;
}
try {
const pipeline = await this._buildPipeline(stream);
this.pipelines.set(peerId, pipeline);
this.logger.info('VoiceLeveling: pipeline created', {
peerId,
fallback: pipeline.isFallback,
});
return pipeline.destination.stream;
} catch (err) {
this.logger.error('VoiceLeveling: pipeline build failed, returning raw stream', err);
return stream;
}
}
/**
* Disable voice leveling for a single speaker.
*
* Tears down the pipeline. The caller is responsible for swapping
* the Audio element's `srcObject` back to the raw stream.
*/
disable(peerId: string): void {
const pipeline = this.pipelines.get(peerId);
if (!pipeline) return;
this._disposePipeline(pipeline);
this.pipelines.delete(peerId);
this.logger.info('VoiceLeveling: pipeline removed', { peerId });
}
/** Tear down ALL speaker pipelines. */
disableAll(): void {
this.pipelines.forEach((p) => this._disposePipeline(p));
this.pipelines.clear();
}
setSpeakerVolume(peerId: string, volume: number): void {
const pipeline = this.pipelines.get(peerId);
if (!pipeline) return;
pipeline.gainNode.gain.setValueAtTime(
Math.max(0, Math.min(1, volume)),
pipeline.ctx.currentTime,
);
}
setMasterVolume(volume: number): void {
const clamped = Math.max(0, Math.min(1, volume));
this.pipelines.forEach((pipeline) => {
pipeline.gainNode.gain.setValueAtTime(clamped, pipeline.ctx.currentTime);
});
}
/** Tear down all pipelines and release all resources. */
destroy(): void {
this.disableAll();
if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
this._sharedCtx.close().catch(() => { /* best-effort */ });
}
this._sharedCtx = null;
this._workletLoaded = false;
this._workletAvailable = null;
}
/* ── Pipeline construction ──────────────────────────────────── */
private async _buildPipeline(stream: MediaStream): Promise<SpeakerPipeline> {
const ctx = await this._getOrCreateContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
const source = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain();
gainNode.gain.value = 1.0;
const destination = ctx.createMediaStreamDestination();
const workletOk = await this._ensureWorkletLoaded(ctx);
if (workletOk) {
const workletNode = new AudioWorkletNode(ctx, WORKLET_PROCESSOR_NAME);
source.connect(workletNode);
workletNode.connect(gainNode);
gainNode.connect(destination);
const pipeline: SpeakerPipeline = {
ctx,
source,
workletNode,
compressorNode: null,
gainNode,
destination,
originalStream: stream,
isFallback: false,
};
this._pushSettingsToPipeline(pipeline);
return pipeline;
} else {
this.logger.warn('VoiceLeveling: AudioWorklet unavailable, using fallback compressor');
const compressor = this._createFallbackCompressor(ctx);
source.connect(compressor);
compressor.connect(gainNode);
gainNode.connect(destination);
return {
ctx,
source,
workletNode: null,
compressorNode: compressor,
gainNode,
destination,
originalStream: stream,
isFallback: true,
};
}
}
/**
* Get or create the shared AudioContext.
*
* Uses the system default sample rate (instead of forcing 48 kHz)
* to avoid resampling issues with remote WebRTC streams whose
* sample rate is determined by the sender's codec.
*/
private async _getOrCreateContext(): Promise<AudioContext> {
if (this._sharedCtx && this._sharedCtx.state !== 'closed') {
return this._sharedCtx;
}
this._sharedCtx = new AudioContext();
this._workletLoaded = false;
return this._sharedCtx;
}
private async _ensureWorkletLoaded(ctx: AudioContext): Promise<boolean> {
if (this._workletAvailable === false) return false;
if (this._workletLoaded && this._workletAvailable === true) return true;
try {
await ctx.audioWorklet.addModule(WORKLET_MODULE_PATH);
this._workletLoaded = true;
this._workletAvailable = true;
this.logger.info('VoiceLeveling: worklet module loaded');
return true;
} catch (err) {
this.logger.error('VoiceLeveling: worklet module failed to load', err);
this._workletAvailable = false;
return false;
}
}
private _createFallbackCompressor(ctx: AudioContext): DynamicsCompressorNode {
const compressor = ctx.createDynamicsCompressor();
compressor.threshold.setValueAtTime(-24, ctx.currentTime);
compressor.knee.setValueAtTime(30, ctx.currentTime);
compressor.ratio.setValueAtTime(3, ctx.currentTime);
compressor.attack.setValueAtTime(0.01, ctx.currentTime);
compressor.release.setValueAtTime(0.25, ctx.currentTime);
return compressor;
}
/* ── Settings propagation ───────────────────────────────────── */
private _pushSettingsToPipeline(pipeline: SpeakerPipeline): void {
if (pipeline.workletNode) {
pipeline.workletNode.port.postMessage({
enabled: true, // Pipeline only exists when leveling is on; DSP always active
targetDbfs: this._settings.targetDbfs,
maxGainDb: this._settings.maxGainDb,
strength: this._settings.strength,
speed: this._settings.speed,
noiseGate: this._settings.noiseGate,
});
}
}
/* ── Cleanup ────────────────────────────────────────────────── */
private _disposePipeline(pipeline: SpeakerPipeline): void {
try { pipeline.source.disconnect(); } catch { /* already disconnected */ }
try { pipeline.workletNode?.disconnect(); } catch { /* ok */ }
try { pipeline.compressorNode?.disconnect(); } catch { /* ok */ }
try { pipeline.gainNode.disconnect(); } catch { /* ok */ }
try { pipeline.destination.disconnect(); } catch { /* ok */ }
}
}

View File

@@ -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>

View File

@@ -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());
}
}

View File

@@ -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();