Files
Toju/patches/restore-audio-leveling.patch

1564 lines
58 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git b/public/voice-leveling-worklet.js a/public/voice-leveling-worklet.js
new file mode 100644
index 0000000..7c666ac
--- /dev/null
+++ a/public/voice-leveling-worklet.js
@@ -0,0 +1,442 @@
+/**
+ * VoiceLevelingProcessor — AudioWorkletProcessor that implements
+ * broadcast-grade per-speaker automatic gain control (AGC).
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ * DSP DESIGN NOTES
+ * ═══════════════════════════════════════════════════════════════════
+ *
+ * This processor mimics WebRTC's Gain Controller 2 (AGC2) behaviour
+ * using a lightweight algorithm suitable for real-time voice in an
+ * AudioWorklet thread.
+ *
+ * Pipeline (per 128-sample render quantum ≈ 2.67 ms @ 48 kHz):
+ *
+ * 1. RMS level estimation (short-term envelope)
+ * 2. Silence gate (freeze gain when below noise floor)
+ * 3. Target gain compute (desired dBFS → linear gain)
+ * 4. Gain smoothing (exponential attack / release)
+ * 5. Max-gain clamp (prevent runaway boost)
+ * 6. Soft-clip limiter (prevent digital overs)
+ *
+ * Key properties:
+ * • No per-frame allocation — all buffers pre-allocated.
+ * • Synchronous processing — no message passing in hot path.
+ * • Uses Float32 throughout — native AudioWorklet format.
+ * • 128-sample quantum fits within 10 ms at 48 kHz (2.67 ms).
+ *
+ * The processor receives configuration via AudioWorkletNode.port
+ * messages and applies them on the next render quantum.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ */
+
+/* ──────────────────────────────────────────────────────────────── */
+/* Constants */
+/* ──────────────────────────────────────────────────────────────── */
+
+/** Processor name registered with `registerProcessor`. */
+const PROCESSOR_NAME = 'VoiceLevelingProcessor';
+
+/**
+ * Web Audio render quantum size — the number of samples processed
+ * in each call to `process()`. The AudioWorklet spec mandates 128.
+ */
+const RENDER_QUANTUM_FRAMES = 128;
+
+/**
+ * Minimum RMS level (linear) below which the input is considered
+ * silence. Gain is frozen/decayed when the signal is this quiet.
+ * Roughly 60 dBFS.
+ */
+const DEFAULT_SILENCE_THRESHOLD = 0.001;
+
+/**
+ * The target RMS level in dBFS. 18 dBFS is a comfortable
+ * conversational loudness for headphone listening.
+ */
+const DEFAULT_TARGET_DBFS = -18;
+
+/** Default maximum gain boost in dB. */
+const DEFAULT_MAX_GAIN_DB = 12;
+
+/** Soft-clip ceiling — prevents digital overs. */
+const SOFT_CLIP_THRESHOLD = 0.95;
+
+/**
+ * Speed presets: attack and release time constants (seconds).
+ *
+ * Attack = how fast gain *decreases* when a loud signal arrives.
+ * Release = how fast gain *increases* when the signal gets quieter.
+ *
+ * Asymmetric: fast attack prevents clipping, slow release sounds
+ * natural and avoids "pumping".
+ */
+const SPEED_PRESETS = {
+ slow: { attack: 0.015, release: 0.800 },
+ medium: { attack: 0.010, release: 0.400 },
+ fast: { attack: 0.005, release: 0.150 },
+};
+
+/**
+ * AGC strength presets: scale the computed gain adjustment.
+ * 1.0 = full correction toward target; lower = gentler leveling.
+ */
+const STRENGTH_PRESETS = {
+ low: 0.5,
+ medium: 0.75,
+ high: 1.0,
+};
+
+/**
+ * When silence is detected, the gain decays toward 1.0 (unity)
+ * at this rate (seconds). This prevents the gain from sitting at
+ * a huge value after long silence and then blasting when speech
+ * resumes.
+ */
+const SILENCE_DECAY_TC = 2.0;
+
+/* ──────────────────────────────────────────────────────────────── */
+/* Helpers */
+/* ──────────────────────────────────────────────────────────────── */
+
+/** Convert decibels to linear gain. */
+function dbToLinear(db) {
+ return Math.pow(10, db / 20);
+}
+
+/** Convert linear amplitude to dBFS. Returns Infinity for 0. */
+function linearToDb(linear) {
+ if (linear <= 0) return -Infinity;
+ return 20 * Math.log10(linear);
+}
+
+/**
+ * Compute the exponential smoothing coefficient (α) for a given
+ * time constant and **frame rate** (not sample rate!).
+ *
+ * Because the envelope / gain update runs once per render quantum
+ * (128 samples), the rate passed here must be frames-per-second
+ * (sampleRate / 128), NOT samples-per-second. Using the raw
+ * sampleRate would produce absurdly small α values, making the
+ * AGC appear frozen.
+ *
+ * α = 1 e^(1 / (tc * fps))
+ *
+ * Larger α → faster response.
+ *
+ * @param {number} tc Time constant in seconds.
+ * @param {number} fps Frame rate (render quanta per second).
+ * @returns {number} Smoothing coefficient (01).
+ */
+function timeConstantToAlpha(tc, fps) {
+ if (tc <= 0) return 1.0;
+ return 1.0 - Math.exp(-1.0 / (tc * fps));
+}
+
+/**
+ * Attempt to use SharedArrayBuffer for the envelope history if
+ * the environment supports it. Falls back to a regular
+ * Float32Array.
+ *
+ * @param {number} length Number of elements.
+ * @returns {Float32Array}
+ */
+function allocateBuffer(length) {
+ try {
+ if (typeof SharedArrayBuffer !== 'undefined') {
+ return new Float32Array(new SharedArrayBuffer(length * 4));
+ }
+ } catch { /* fall through */ }
+ return new Float32Array(length);
+}
+
+/**
+ * Soft-clip function (tanh-based) that prevents digital overs
+ * while preserving signal shape.
+ *
+ * Below the threshold the signal passes through unchanged.
+ * Above it, tanh compression is applied symmetrically.
+ *
+ * @param {number} sample Input sample.
+ * @returns {number} Clipped sample.
+ */
+function softClip(sample) {
+ const abs = Math.abs(sample);
+ if (abs <= SOFT_CLIP_THRESHOLD) return sample;
+ const sign = sample >= 0 ? 1 : -1;
+ // Map (threshold..∞) → (threshold..1) using tanh
+ const excess = (abs - SOFT_CLIP_THRESHOLD) / (1 - SOFT_CLIP_THRESHOLD);
+ return sign * (SOFT_CLIP_THRESHOLD + (1 - SOFT_CLIP_THRESHOLD) * Math.tanh(excess));
+}
+
+/* ──────────────────────────────────────────────────────────────── */
+/* Processor */
+/* ──────────────────────────────────────────────────────────────── */
+
+class VoiceLevelingProcessor extends AudioWorkletProcessor {
+
+ /* ── State ──────────────────────────────────────────────────── */
+
+ /** Whether processing is enabled (bypass when false). */
+ _enabled = true;
+
+ /** Target loudness in dBFS. */
+ _targetDbfs = DEFAULT_TARGET_DBFS;
+
+ /** Maximum gain boost in dB. */
+ _maxGainDb = DEFAULT_MAX_GAIN_DB;
+
+ /** Linear ceiling for the gain multiplier. */
+ _maxGainLinear = dbToLinear(DEFAULT_MAX_GAIN_DB);
+
+ /** AGC strength factor (01). Scales the gain correction. */
+ _strength = STRENGTH_PRESETS.medium;
+
+ /** Whether the silence/noise gate is active. */
+ _noiseGateEnabled = false;
+
+ /** RMS threshold below which input is treated as silence. */
+ _silenceThreshold = DEFAULT_SILENCE_THRESHOLD;
+
+ /** Attack smoothing coefficient. */
+ _alphaAttack = 0;
+
+ /** Release smoothing coefficient. */
+ _alphaRelease = 0;
+
+ /** Silence decay smoothing coefficient. */
+ _alphaSilenceDecay = 0;
+
+ /**
+ * Running RMS envelope (squared, to avoid sqrt every frame).
+ * Smoothed with a one-pole filter.
+ */
+ _envelopeSq = 0;
+
+ /** Current applied gain (linear). Smoothed toward target. */
+ _currentGain = 1.0;
+
+ /**
+ * Pre-allocated buffer used for RMS computation.
+ * Sized to the largest possible render quantum (128 samples).
+ */
+ _scratchBuffer = allocateBuffer(128);
+
+ /* ── Constructor ────────────────────────────────────────────── */
+
+ constructor(options) {
+ super(options);
+
+ // Compute smoothing coefficients from default speed
+ this._applySpeed('medium');
+
+ // Listen for configuration changes from the main thread.
+ // Messages are consumed before the next render quantum.
+ this.port.onmessage = (event) => this._handleMessage(event.data);
+ }
+
+ /* ── Configuration ──────────────────────────────────────────── */
+
+ /**
+ * Handle a configuration message from the main thread.
+ *
+ * Accepted keys:
+ * enabled : boolean
+ * targetDbfs : number (-30 … -12)
+ * maxGainDb : number (3 … 20)
+ * strength : 'low' | 'medium' | 'high'
+ * speed : 'slow' | 'medium' | 'fast'
+ * noiseGate : boolean
+ *
+ * @param {object} msg
+ */
+ _handleMessage(msg) {
+ if (msg == null || typeof msg !== 'object') return;
+
+ if (typeof msg.enabled === 'boolean') {
+ this._enabled = msg.enabled;
+ if (!msg.enabled) {
+ // Reset gain to unity on disable so re-enabling starts clean
+ this._currentGain = 1.0;
+ this._envelopeSq = 0;
+ }
+ }
+
+ if (typeof msg.targetDbfs === 'number') {
+ this._targetDbfs = Math.max(-30, Math.min(-12, msg.targetDbfs));
+ }
+
+ if (typeof msg.maxGainDb === 'number') {
+ const clamped = Math.max(3, Math.min(20, msg.maxGainDb));
+ this._maxGainDb = clamped;
+ this._maxGainLinear = dbToLinear(clamped);
+ }
+
+ if (typeof msg.strength === 'string' && STRENGTH_PRESETS[msg.strength] != null) {
+ this._strength = STRENGTH_PRESETS[msg.strength];
+ }
+
+ if (typeof msg.speed === 'string' && SPEED_PRESETS[msg.speed] != null) {
+ this._applySpeed(msg.speed);
+ }
+
+ if (typeof msg.noiseGate === 'boolean') {
+ this._noiseGateEnabled = msg.noiseGate;
+ }
+ }
+
+ /**
+ * Recompute attack/release/silence-decay coefficients for
+ * the current sample rate.
+ *
+ * IMPORTANT: We use frames-per-second (sampleRate / 128), NOT
+ * the raw sampleRate, because the smoothing filter is applied
+ * once per render quantum — not once per sample.
+ *
+ * @param {'slow' | 'medium' | 'fast'} preset
+ */
+ _applySpeed(preset) {
+ const { attack, release } = SPEED_PRESETS[preset];
+ const fps = sampleRate / RENDER_QUANTUM_FRAMES;
+ this._alphaAttack = timeConstantToAlpha(attack, fps);
+ this._alphaRelease = timeConstantToAlpha(release, fps);
+ this._alphaSilenceDecay = timeConstantToAlpha(SILENCE_DECAY_TC, fps);
+ }
+
+ /* ── DSP ────────────────────────────────────────────────────── */
+
+ /**
+ * Main audio processing callback.
+ *
+ * @param {Float32Array[][]} inputs Input channels.
+ * @param {Float32Array[][]} outputs Output channels.
+ * @returns {boolean} `true` to keep the processor alive.
+ */
+ process(inputs, outputs) {
+ const input = inputs[0];
+ const output = outputs[0];
+
+ // No input → silence pass-through
+ if (!input || input.length === 0 || !input[0]) {
+ return true;
+ }
+
+ const inputChannel = input[0];
+ const outputChannel = output[0];
+ const numSamples = inputChannel.length;
+
+ // ── Bypass mode ──────────────────────────────────────────
+ if (!this._enabled) {
+ // Copy input → output unchanged
+ for (let i = 0; i < numSamples; i++) {
+ outputChannel[i] = inputChannel[i];
+ }
+ // Also copy any additional channels (stereo, etc.)
+ for (let ch = 1; ch < input.length; ch++) {
+ if (output[ch] && input[ch]) {
+ for (let i = 0; i < numSamples; i++) {
+ output[ch][i] = input[ch][i];
+ }
+ }
+ }
+ return true;
+ }
+
+ // ── 1. RMS level estimation ──────────────────────────────
+ //
+ // Compute the RMS of this render quantum and smooth it with
+ // a one-pole IIR filter (exponential moving average).
+ //
+ // We work in the squared domain to avoid a sqrt per sample;
+ // the sqrt is taken only once per quantum for the gain calc.
+
+ let sumSq = 0;
+ for (let i = 0; i < numSamples; i++) {
+ const s = inputChannel[i];
+ sumSq += s * s;
+ }
+ const frameMeanSq = sumSq / numSamples;
+
+ // Smooth envelope: use attack for rising levels, release for falling
+ const alpha = frameMeanSq > this._envelopeSq
+ ? this._alphaAttack
+ : this._alphaRelease;
+ this._envelopeSq += alpha * (frameMeanSq - this._envelopeSq);
+
+ // Current smoothed RMS (linear)
+ const rms = Math.sqrt(Math.max(this._envelopeSq, 1e-12));
+
+ // ── 2. Silence gate ──────────────────────────────────────
+ //
+ // If the RMS is below the silence threshold, do NOT compute
+ // a new gain target. Instead, decay the current gain slowly
+ // toward unity (1.0) so we don't slam the listener when
+ // speech resumes.
+
+ const isSilence = rms < this._silenceThreshold;
+
+ if (isSilence && this._noiseGateEnabled) {
+ // Decay gain toward 1.0
+ this._currentGain += this._alphaSilenceDecay * (1.0 - this._currentGain);
+ } else if (!isSilence) {
+ // ── 3. Target gain computation ───────────────────────
+ //
+ // Desired gain = 10^((targetDbfs currentDbfs) / 20)
+ //
+ // We scale the correction by the strength factor so that
+ // "low" strength applies only 50 % of the correction.
+
+ const currentDbfs = linearToDb(rms);
+ const errorDb = this._targetDbfs - currentDbfs;
+
+ // Scale the correction by strength.
+ // A strength of 1.0 means "correct fully to target".
+ const correctionDb = errorDb * this._strength;
+ let desiredGain = dbToLinear(correctionDb);
+
+ // Clamp to max gain
+ if (desiredGain > this._maxGainLinear) {
+ desiredGain = this._maxGainLinear;
+ }
+ // Never attenuate below a certain floor (we're leveling UP,
+ // but very loud signals still need to be pulled down).
+ // Allow attenuation down to 6 dB.
+ if (desiredGain < 0.5) {
+ desiredGain = 0.5;
+ }
+
+ // ── 4. Gain smoothing ──────────────────────────────
+ //
+ // Exponentially interpolate the current gain toward the
+ // desired gain. Use fast attack (gain DOWN) and slow
+ // release (gain UP) for natural dynamics.
+
+ const gainAlpha = desiredGain < this._currentGain
+ ? this._alphaAttack // Gain is decreasing (loud signal arrived)
+ : this._alphaRelease; // Gain is increasing (signal got quieter)
+
+ this._currentGain += gainAlpha * (desiredGain - this._currentGain);
+ }
+ // If isSilence && !noiseGateEnabled → gain stays as-is (frozen)
+
+ // ── 5. Apply gain & soft-clip ─────────────────────────────
+ const gain = this._currentGain;
+ for (let i = 0; i < numSamples; i++) {
+ outputChannel[i] = softClip(inputChannel[i] * gain);
+ }
+
+ // Copy any additional channels with same gain
+ for (let ch = 1; ch < input.length; ch++) {
+ if (output[ch] && input[ch]) {
+ for (let i = 0; i < numSamples; i++) {
+ output[ch][i] = softClip(input[ch][i] * gain);
+ }
+ }
+ }
+
+ return true;
+ }
+}
+
+registerProcessor(PROCESSOR_NAME, VoiceLevelingProcessor);
diff --git b/src/app/core/constants.ts a/src/app/core/constants.ts
index e343c78..dfa516d 100644
--- b/src/app/core/constants.ts
+++ a/src/app/core/constants.ts
@@ -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';
diff --git b/src/app/core/services/index.ts a/src/app/core/services/index.ts
index 40ea53a..96219fa 100644
--- b/src/app/core/services/index.ts
+++ a/src/app/core/services/index.ts
@@ -9,3 +9,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';
diff --git b/src/app/core/services/voice-leveling.service.ts a/src/app/core/services/voice-leveling.service.ts
new file mode 100644
index 0000000..34ad60f
--- /dev/null
+++ a/src/app/core/services/voice-leveling.service.ts
@@ -0,0 +1,281 @@
+/**
+ * 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.
+ *
+ * ═══════════════════════════════════════════════════════════════════
+ */
+/* eslint-disable @typescript-eslint/member-ordering */
+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: ((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 (0-1).
+ */
+ 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 (0-1).
+ */
+ 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 = [];
+ }
+}
diff --git b/src/app/core/services/webrtc/index.ts a/src/app/core/services/webrtc/index.ts
index ca30142..ab1b400 100644
--- b/src/app/core/services/webrtc/index.ts
+++ a/src/app/core/services/webrtc/index.ts
@@ -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';
diff --git b/src/app/core/services/webrtc/voice-leveling.manager.ts a/src/app/core/services/webrtc/voice-leveling.manager.ts
new file mode 100644
index 0000000..b920bc1
--- /dev/null
+++ a/src/app/core/services/webrtc/voice-leveling.manager.ts
@@ -0,0 +1,382 @@
+/* eslint-disable id-length, max-statements-per-line */
+/**
+ * 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 */ }
+ }
+}
diff --git b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html
index 94e61d0..423e837 100644
--- b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html
+++ a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html
@@ -229,4 +229,178 @@
</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>
diff --git b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts
index f75a0fd..ec8672e 100644
--- b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts
+++ a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts
@@ -10,10 +10,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideHeadphones,
- lucideAudioLines
+ lucideAudioLines,
+ lucideActivity
} from '@ng-icons/lucide';
import { WebRTCService } from '../../../../core/services/webrtc.service';
+import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
@@ -34,13 +36,15 @@ interface AudioDevice {
provideIcons({
lucideMic,
lucideHeadphones,
- lucideAudioLines
+ lucideAudioLines,
+ lucideActivity
})
],
templateUrl: './voice-settings.component.html'
})
export class VoiceSettingsComponent {
private webrtcService = inject(WebRTCService);
+ readonly voiceLeveling = inject(VoiceLevelingService);
readonly audioService = inject(NotificationAudioService);
inputDevices = signal<AudioDevice[]>([]);
@@ -199,6 +203,40 @@ 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;
diff --git b/src/app/features/voice/voice-controls/services/voice-playback.service.ts a/src/app/features/voice/voice-controls/services/voice-playback.service.ts
index 5e97d7a..2d07aad 100644
--- b/src/app/features/voice/voice-controls/services/voice-playback.service.ts
+++ a/src/app/features/voice/voice-controls/services/voice-playback.service.ts
@@ -1,5 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { WebRTCService } from '../../../../core/services/webrtc.service';
+import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
export interface PlaybackOptions {
isConnected: boolean;
@@ -9,6 +10,7 @@ export interface PlaybackOptions {
@Injectable({ providedIn: 'root' })
export class VoicePlaybackService {
+ private voiceLeveling = inject(VoiceLevelingService);
private webrtc = inject(WebRTCService);
private remoteAudioElements = new Map<string, HTMLAudioElement>();
private pendingRemoteStreams = new Map<string, MediaStream>();
@@ -25,7 +27,11 @@ export class VoicePlaybackService {
}
this.removeAudioElement(peerId);
+
+ // Always stash the raw stream so we can re-wire on toggle
this.rawRemoteStreams.set(peerId, stream);
+
+ // Start playback immediately with the raw stream
const audio = new Audio();
audio.srcObject = stream;
@@ -34,11 +40,24 @@ export class VoicePlaybackService {
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(() => {});
+ }
}
removeRemoteAudio(peerId: string): void {
this.pendingRemoteStreams.delete(peerId);
this.rawRemoteStreams.delete(peerId);
+ this.voiceLeveling.disable(peerId);
this.removeAudioElement(peerId);
}
@@ -69,6 +88,34 @@ 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);
+ }
+
updateOutputVolume(volume: number): void {
this.remoteAudioElements.forEach((audio) => {
audio.volume = volume;
diff --git b/src/app/features/voice/voice-controls/voice-controls.component.ts a/src/app/features/voice/voice-controls/voice-controls.component.ts
index d599e67..dce7f6e 100644
--- b/src/app/features/voice/voice-controls/voice-controls.component.ts
+++ a/src/app/features/voice/voice-controls/voice-controls.component.ts
@@ -26,6 +26,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';
@@ -66,10 +67,13 @@ 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);
@@ -117,6 +121,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.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();
@@ -140,9 +150,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
this.voicePlayback.teardownAll();
+ this.voiceLeveling.disableAll();
this.remoteStreamSubscription?.unsubscribe();
this.voiceConnectedSubscription?.unsubscribe();
+ this.voiceLevelingUnsubscribe?.();
}
async loadAudioDevices(): Promise<void> {
@@ -267,6 +279,9 @@ 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();