diff --git a/electron/cqrs/commands/handlers/saveRoom.ts b/electron/cqrs/commands/handlers/saveRoom.ts index b02b2b7..534a765 100644 --- a/electron/cqrs/commands/handlers/saveRoom.ts +++ b/electron/cqrs/commands/handlers/saveRoom.ts @@ -19,7 +19,8 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS icon: room.icon ?? null, iconUpdatedAt: room.iconUpdatedAt ?? null, permissions: room.permissions != null ? JSON.stringify(room.permissions) : null, - channels: room.channels != null ? JSON.stringify(room.channels) : null + channels: room.channels != null ? JSON.stringify(room.channels) : null, + members: room.members != null ? JSON.stringify(room.members) : null }); await repo.save(entity); diff --git a/electron/cqrs/commands/handlers/updateRoom.ts b/electron/cqrs/commands/handlers/updateRoom.ts index 140516b..9c6493e 100644 --- a/electron/cqrs/commands/handlers/updateRoom.ts +++ b/electron/cqrs/commands/handlers/updateRoom.ts @@ -12,7 +12,8 @@ const ROOM_TRANSFORMS: TransformMap = { isPrivate: boolToInt, userCount: (val) => (val ?? 0), permissions: jsonOrNull, - channels: jsonOrNull + channels: jsonOrNull, + members: jsonOrNull }; export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise { diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index 7c2270c..a0f7463 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -60,7 +60,8 @@ export function rowToRoom(row: RoomEntity) { icon: row.icon ?? undefined, iconUpdatedAt: row.iconUpdatedAt ?? undefined, permissions: row.permissions ? JSON.parse(row.permissions) : undefined, - channels: row.channels ? JSON.parse(row.channels) : undefined + channels: row.channels ? JSON.parse(row.channels) : undefined, + members: row.members ? JSON.parse(row.members) : undefined }; } diff --git a/electron/cqrs/types.ts b/electron/cqrs/types.ts index ce17446..f682711 100644 --- a/electron/cqrs/types.ts +++ b/electron/cqrs/types.ts @@ -103,6 +103,7 @@ export interface RoomPayload { iconUpdatedAt?: number; permissions?: unknown; channels?: unknown[]; + members?: unknown[]; } export interface BanPayload { diff --git a/electron/entities/RoomEntity.ts b/electron/entities/RoomEntity.ts index 0f13717..3d7afe0 100644 --- a/electron/entities/RoomEntity.ts +++ b/electron/entities/RoomEntity.ts @@ -47,4 +47,7 @@ export class RoomEntity { @Column('text', { nullable: true }) channels!: string | null; + + @Column('text', { nullable: true }) + members!: string | null; } diff --git a/electron/migrations/1000000000001-AddRoomMembers.ts b/electron/migrations/1000000000001-AddRoomMembers.ts new file mode 100644 index 0000000..6b55aa7 --- /dev/null +++ b/electron/migrations/1000000000001-AddRoomMembers.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoomMembers1000000000001 implements MigrationInterface { + name = 'AddRoomMembers1000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + const columns = await queryRunner.query(`PRAGMA table_info("rooms")`) as Array<{ name?: string }>; + const hasMembersColumn = Array.isArray(columns) + && columns.some((column) => column.name === 'members'); + + if (!hasMembersColumn) { + await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "members" TEXT`); + } + } + + public async down(_queryRunner: QueryRunner): Promise { + // Forward-only migration: SQLite column removal is intentionally omitted. + } +} diff --git a/eslint.config.js b/eslint.config.js index 9351680..1ac6887 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -62,6 +62,13 @@ module.exports = tseslint.config( ], processor: angular.processInlineTemplates, rules: { + '@angular-eslint/component-max-inline-declarations': [ + 'error', + { + template: 3, + styles: 0 + } + ], 'no-dashes/no-unicode-dashes': 'error', '@typescript-eslint/no-extraneous-class': 'off', '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ], @@ -141,6 +148,15 @@ module.exports = tseslint.config( '@stylistic/js/space-in-parens': 'error', '@stylistic/js/space-unary-ops': 'error', '@stylistic/js/spaced-comment': ['error','always',{ markers:['/'] }], + '@stylistic/js/array-bracket-spacing': 'error', + '@stylistic/js/array-element-newline': ['error', { + multiline: true, + minItems: 3 + }], + '@stylistic/js/array-bracket-newline': ['error', { + multiline: true, + minItems: 3 + }], "import-newlines/enforce": [ "error", 2 diff --git a/patches/restore-audio-leveling.patch b/patches/restore-audio-leveling.patch new file mode 100644 index 0000000..557c607 --- /dev/null +++ b/patches/restore-audio-leveling.patch @@ -0,0 +1,1563 @@ +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 (0–1). ++ */ ++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 (0–1). 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 { ++ 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): 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; ++ ++ 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(); ++ ++ /** 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 { ++ 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): 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 { ++ // 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 { ++ 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 { ++ 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 { ++ 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 @@ + + + ++ ++ ++
++
++ ++

Voice Leveling

++
++
++ ++
++
++

Voice Leveling

++

Automatically equalise volume across speakers

++
++ ++
++ ++ ++ @if (voiceLeveling.enabled()) { ++
++ ++
++ ++ ++
++ -30 (quiet) ++ -12 (loud) ++
++
++ ++ ++
++ ++ ++
++ ++ ++
++ ++ ++
++ 3 dB (subtle) ++ 20 dB (strong) ++
++
++ ++ ++
++ ++ ++
++ ++ ++
++
++

Noise Floor Gate

++

Prevents boosting silence

++
++ ++
++
++ } ++
++
+ +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([]); +@@ -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(); + private pendingRemoteStreams = new Map(); +@@ -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 { ++ 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 { +@@ -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(); diff --git a/public/voice-leveling-worklet.js b/public/voice-leveling-worklet.js deleted file mode 100644 index 7c666ac..0000000 --- a/public/voice-leveling-worklet.js +++ /dev/null @@ -1,442 +0,0 @@ -/** - * 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 (0–1). - */ -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 (0–1). 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 a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 7bb0742..88c6848 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 1adbabb..4b0c763 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -17,6 +17,7 @@ import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { UsersEffects } from './store/users/users.effects'; import { RoomsEffects } from './store/rooms/rooms.effects'; +import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'; import { STORE_DEVTOOLS_MAX_AGE } from './core/constants'; /** Root application configuration providing routing, HTTP, NgRx store, and devtools. */ @@ -34,7 +35,8 @@ export const appConfig: ApplicationConfig = { MessagesEffects, MessagesSyncEffects, UsersEffects, - RoomsEffects + RoomsEffects, + RoomMembersSyncEffects ]), provideStoreDevtools({ maxAge: STORE_DEVTOOLS_MAX_AGE, diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index dfa516d..fc3adb2 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -17,6 +17,9 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; /** Key used to persist voice settings (input/output devices, volume). */ export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; +/** Key used to persist per-user volume overrides (0–200%). */ +export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; + /** Regex that extracts a roomId from a `/room/:roomId` URL path. */ export const ROOM_URL_PATTERN = /\/room\/([^/]+)/; @@ -34,6 +37,3 @@ 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 a/src/app/core/models/index.ts b/src/app/core/models/index.ts index 982d6df..e669699 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -49,6 +49,31 @@ export interface User { screenShareState?: ScreenShareState; } +/** + * Persisted membership record for a room/server. + * + * Unlike `User`, this survives when a member goes offline so the UI can + * continue to list known server members. + */ +export interface RoomMember { + /** The member's local application/database identifier. */ + id: string; + /** Optional network-wide peer identifier. */ + oderId?: string; + /** Login username (best effort; may be synthesized from display name). */ + username: string; + /** Human-readable display name shown in the UI. */ + displayName: string; + /** Optional avatar URL. */ + avatarUrl?: string; + /** Role within the room/server. */ + role: UserRole; + /** Epoch timestamp (ms) when the member first joined. */ + joinedAt: number; + /** Epoch timestamp (ms) when the member was last seen online. */ + lastSeenAt: number; +} + /** * A communication channel within a server (either text or voice). */ @@ -141,6 +166,8 @@ export interface Room { permissions?: RoomPermissions; /** Text and voice channels within the server. */ channels?: Channel[]; + /** Persisted member roster, including offline users. */ + members?: RoomMember[]; } /** @@ -307,6 +334,9 @@ export type ChatEventType = | 'room-settings-update' | 'voice-state' | 'chat-inventory-request' + | 'member-roster-request' + | 'member-roster' + | 'member-leave' | 'voice-state-request' | 'state-request' | 'screen-state' @@ -362,6 +392,8 @@ export interface ChatEvent { role?: UserRole; /** Updated channel list. */ channels?: Channel[]; + /** Synced room member roster. */ + members?: RoomMember[]; } /** diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 96219fa..40ea53a 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -9,4 +9,3 @@ 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 a/src/app/core/services/voice-leveling.service.ts b/src/app/core/services/voice-leveling.service.ts deleted file mode 100644 index 34ad60f..0000000 --- a/src/app/core/services/voice-leveling.service.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * 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 { - 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): 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; - - 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 a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index 20ee66d..f63844a 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -576,6 +576,15 @@ export class WebRTCService implements OnDestroy { return this.mediaManager.getLocalStream(); } + /** + * Get the raw local microphone stream before gain / RNNoise processing. + * + * @returns The raw microphone {@link MediaStream}, or `null` if voice is not active. + */ + getRawMicStream(): MediaStream | null { + return this.mediaManager.getRawMicStream(); + } + /** * Request microphone access and start sending audio to all peers. * @@ -648,6 +657,18 @@ export class WebRTCService implements OnDestroy { this.mediaManager.setOutputVolume(volume); } + /** + * Set the input (microphone) volume. + * + * Adjusts a Web Audio GainNode on the local mic stream so the level + * sent to peers changes in real time without renegotiation. + * + * @param volume - Normalised volume (0-1). + */ + setInputVolume(volume: number): void { + this.mediaManager.setInputVolume(volume); + } + /** * Set the maximum audio bitrate for all peer connections. * diff --git a/src/app/core/services/webrtc/index.ts b/src/app/core/services/webrtc/index.ts index ab1b400..ca30142 100644 --- a/src/app/core/services/webrtc/index.ts +++ b/src/app/core/services/webrtc/index.ts @@ -12,4 +12,3 @@ 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 a/src/app/core/services/webrtc/media.manager.ts b/src/app/core/services/webrtc/media.manager.ts index 4e08f9c..9984374 100644 --- a/src/app/core/services/webrtc/media.manager.ts +++ b/src/app/core/services/webrtc/media.manager.ts @@ -55,6 +55,16 @@ export class MediaManager { /** Remote audio output volume (0-1). */ private remoteAudioVolume = VOLUME_MAX; + // -- Input gain pipeline (mic volume) -- + /** The stream BEFORE gain is applied (for identity checks). */ + private preGainStream: MediaStream | null = null; + private inputGainCtx: AudioContext | null = null; + private inputGainSourceNode: MediaStreamAudioSourceNode | null = null; + private inputGainNode: GainNode | null = null; + private inputGainDest: MediaStreamAudioDestinationNode | null = null; + /** Normalised 0-1 input gain (1 = 100%). */ + private inputGainVolume = 1.0; + /** Voice-presence heartbeat timer. */ private voicePresenceTimer: ReturnType | null = null; @@ -69,7 +79,7 @@ export class MediaManager { * whether the worklet is actually running. This lets us honour the * preference even when it is set before the mic stream is acquired. */ - private _noiseReductionDesired = false; + private _noiseReductionDesired = true; // State tracked locally (the service exposes these via signals) private isVoiceActive = false; @@ -102,6 +112,10 @@ export class MediaManager { getLocalStream(): MediaStream | null { return this.localMediaStream; } + /** Returns the raw microphone stream before processing, if available. */ + getRawMicStream(): MediaStream | null { + return this.rawMicStream; + } /** Whether voice is currently active (mic captured). */ getIsVoiceActive(): boolean { return this.isVoiceActive; @@ -152,7 +166,7 @@ export class MediaManager { const mediaConstraints: MediaStreamConstraints = { audio: { echoCancellation: true, - noiseSuppression: true, + noiseSuppression: !this._noiseReductionDesired, autoGainControl: true }, video: false @@ -177,6 +191,9 @@ export class MediaManager { ? await this.noiseReduction.enable(stream) : stream; + // Apply input gain (mic volume) before sending to peers + this.applyInputGainToCurrentStream(); + this.logger.logStream('localVoice', this.localMediaStream); this.bindLocalTracksToAllPeers(); @@ -196,6 +213,7 @@ export class MediaManager { */ disableVoice(): void { this.noiseReduction.disable(); + this.teardownInputGain(); // Stop the raw mic tracks (the denoised stream's tracks are // derived nodes and will stop once their source is gone). @@ -241,6 +259,9 @@ export class MediaManager { this.localMediaStream = stream; } + // Apply input gain (mic volume) before sending to peers + this.applyInputGainToCurrentStream(); + this.bindLocalTracksToAllPeers(); this.isVoiceActive = true; this.voiceConnected$.next(); @@ -252,16 +273,10 @@ export class MediaManager { * @param muted - Explicit state; if omitted, the current state is toggled. */ toggleMute(muted?: boolean): void { - if (this.localMediaStream) { - const audioTracks = this.localMediaStream.getAudioTracks(); - const newMutedState = muted !== undefined ? muted : !this.isMicMuted; + const newMutedState = muted !== undefined ? muted : !this.isMicMuted; - audioTracks.forEach((track) => { - track.enabled = !newMutedState; - }); - - this.isMicMuted = newMutedState; - } + this.isMicMuted = newMutedState; + this.applyCurrentMuteState(); } /** @@ -294,6 +309,11 @@ export class MediaManager { this.noiseReduction.isEnabled ); + // Do not update the browser's built-in noiseSuppression constraint on the + // live mic track here. Chromium may share the underlying capture source, + // which can leak the constraint change into other active streams. We only + // apply the browser constraint when the microphone stream is acquired. + if (shouldEnable === this.noiseReduction.isEnabled) return; @@ -318,6 +338,9 @@ export class MediaManager { } } + // Re-apply input gain to the (possibly new) stream + this.applyInputGainToCurrentStream(); + // Propagate the new audio track to every peer connection this.bindLocalTracksToAllPeers(); } @@ -331,6 +354,32 @@ export class MediaManager { this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume)); } + /** + * Set the input (microphone) volume. + * + * If a local stream is active the gain node is updated in real time. + * If no stream exists yet the value is stored and applied on connect. + * + * @param volume - Normalised 0-1 (0 = silent, 1 = 100%). + */ + setInputVolume(volume: number): void { + this.inputGainVolume = Math.max(0, Math.min(1, volume)); + + if (this.inputGainNode) { + // Pipeline already exists - just update the gain value + this.inputGainNode.gain.value = this.inputGainVolume; + } else if (this.localMediaStream) { + // Stream is active but gain pipeline hasn't been created yet + this.applyInputGainToCurrentStream(); + this.bindLocalTracksToAllPeers(); + } + } + + /** Get current input gain value (0-1). */ + getInputVolume(): number { + return this.inputGainVolume; + } + /** * Set the maximum audio bitrate on every active peer's audio sender. * @@ -525,8 +574,79 @@ export class MediaManager { }); } + // -- Input gain helpers -- + + /** + * Route the current `localMediaStream` through a Web Audio GainNode so + * the microphone level can be adjusted without renegotiating peers. + * + * If a gain pipeline already exists for the same source stream the gain + * value is simply updated. Otherwise a new pipeline is created. + */ + private applyInputGainToCurrentStream(): void { + const stream = this.localMediaStream; + + if (!stream) + return; + + // If the source stream hasn't changed, just update gain + if (this.preGainStream === stream && this.inputGainNode && this.inputGainCtx) { + this.inputGainNode.gain.value = this.inputGainVolume; + return; + } + + // Tear down the old pipeline (if any) + this.teardownInputGain(); + + // Build new pipeline: source → gain → destination + this.preGainStream = stream; + this.inputGainCtx = new AudioContext(); + this.inputGainSourceNode = this.inputGainCtx.createMediaStreamSource(stream); + this.inputGainNode = this.inputGainCtx.createGain(); + this.inputGainNode.gain.value = this.inputGainVolume; + this.inputGainDest = this.inputGainCtx.createMediaStreamDestination(); + + this.inputGainSourceNode.connect(this.inputGainNode); + this.inputGainNode.connect(this.inputGainDest); + + // Replace localMediaStream with the gained stream + this.localMediaStream = this.inputGainDest.stream; + this.applyCurrentMuteState(); + } + + /** Keep the active outbound track aligned with the stored mute state. */ + private applyCurrentMuteState(): void { + if (!this.localMediaStream) + return; + + const enabled = !this.isMicMuted; + + this.localMediaStream.getAudioTracks().forEach((track) => { + track.enabled = enabled; + }); + } + + /** Disconnect and close the input-gain AudioContext. */ + private teardownInputGain(): void { + try { + this.inputGainSourceNode?.disconnect(); + this.inputGainNode?.disconnect(); + } catch { /* already disconnected */ } + + if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') { + this.inputGainCtx.close().catch(() => {}); + } + + this.inputGainCtx = null; + this.inputGainSourceNode = null; + this.inputGainNode = null; + this.inputGainDest = null; + this.preGainStream = null; + } + /** Clean up all resources. */ destroy(): void { + this.teardownInputGain(); this.disableVoice(); this.stopVoiceHeartbeat(); this.noiseReduction.destroy(); diff --git a/src/app/core/services/webrtc/voice-leveling.manager.ts b/src/app/core/services/webrtc/voice-leveling.manager.ts deleted file mode 100644 index b920bc1..0000000 --- a/src/app/core/services/webrtc/voice-leveling.manager.ts +++ /dev/null @@ -1,382 +0,0 @@ -/* 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(); - - /** 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 { - 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): 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 { - // 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 { - 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 { - 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 { - 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 a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts index b2fef30..e5affca 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.ts +++ b/src/app/features/admin/admin-panel/admin-panel.component.ts @@ -205,11 +205,14 @@ export class AdminPanelComponent { /** Change a member's role and broadcast the update to all peers. */ changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void { + const roomId = this.currentRoom()?.id; + this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.webrtc.broadcastMessage({ type: 'role-change', + roomId, targetUserId: user.id, role }); diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index ef8df8e..cef643e 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -33,7 +33,7 @@ class="w-4 h-4" /> Users - {{ onlineUsers().length }} + {{ knownUserCount() }} @@ -149,7 +149,10 @@ @if (voiceUsersInRoom(ch.id).length > 0) {
@for (u of voiceUsersInRoom(ch.id); track u.id) { -
+
} + @if (isUserLocallyMuted(u)) { + + }
}
@@ -300,8 +310,42 @@
} + + @if (offlineRoomMembers().length > 0) { +
+

Offline - {{ offlineRoomMembers().length }}

+
+ @for (member of offlineRoomMembers(); track member.oderId || member.id) { +
+
+ + +
+
+
+

{{ member.displayName }}

+ @if (member.role === 'host') { + Owner + } @else if (member.role === 'admin') { + Admin + } @else if (member.role === 'moderator') { + Mod + } +
+

Offline

+
+
+ } +
+
+ } + - @if (onlineUsersFiltered().length === 0) { + @if (onlineUsersFiltered().length === 0 && offlineRoomMembers().length === 0) {

No other users in this server

@@ -406,6 +450,17 @@ } + +@if (showVolumeMenu()) { + +} + @if (showCreateChannelDialog()) { ('channels'); @@ -87,6 +103,31 @@ export class RoomsSidePanelComponent { activeChannelId = this.store.selectSignal(selectActiveChannelId); textChannels = this.store.selectSignal(selectTextChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels); + roomMembers = computed(() => this.currentRoom()?.members ?? []); + offlineRoomMembers = computed(() => { + const current = this.currentUser(); + const onlineIds = new Set(this.onlineUsers().map((user) => user.oderId || user.id)); + + if (current) { + onlineIds.add(current.oderId || current.id); + } + + return this.roomMembers().filter((member) => !onlineIds.has(this.roomMemberKey(member))); + }); + knownUserCount = computed(() => { + const memberIds = new Set( + this.roomMembers() + .map((member) => this.roomMemberKey(member)) + .filter(Boolean) + ); + const current = this.currentUser(); + + if (current) { + memberIds.add(current.oderId || current.id); + } + + return memberIds.size; + }); // Channel context menu state showChannelMenu = signal(false); @@ -108,6 +149,13 @@ export class RoomsSidePanelComponent { userMenuY = signal(0); contextMenuUser = signal(null); + // Per-user volume context menu state + showVolumeMenu = signal(false); + volumeMenuX = signal(0); + volumeMenuY = signal(0); + volumeMenuPeerId = signal(''); + volumeMenuDisplayName = signal(''); + /** Return online users excluding the current user. */ // Filter out current user from online users list onlineUsersFiltered() { @@ -118,6 +166,10 @@ export class RoomsSidePanelComponent { return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId); } + private roomMemberKey(member: RoomMember): string { + return member.oderId || member.id; + } + /** Check whether the current user has permission to manage channels. */ canManageChannels(): boolean { const room = this.currentRoom(); @@ -287,9 +339,27 @@ export class RoomsSidePanelComponent { this.showUserMenu.set(false); } + /** Open the per-user volume context menu for a voice channel participant. */ + openVoiceUserVolumeMenu(evt: MouseEvent, user: User) { + evt.preventDefault(); + + // Don't show volume menu for the local user + const me = this.currentUser(); + + if (user.id === me?.id || user.oderId === me?.oderId) + return; + + this.volumeMenuPeerId.set(user.oderId || user.id); + this.volumeMenuDisplayName.set(user.displayName); + this.volumeMenuX.set(evt.clientX); + this.volumeMenuY.set(evt.clientY); + this.showVolumeMenu.set(true); + } + /** Change a user's role and broadcast the update to connected peers. */ changeUserRole(role: 'admin' | 'moderator' | 'member') { const user = this.contextMenuUser(); + const roomId = this.currentRoom()?.id; this.closeUserMenu(); @@ -298,6 +368,7 @@ export class RoomsSidePanelComponent { // Broadcast role change to peers this.webrtc.broadcastMessage({ type: 'role-change', + roomId, targetUserId: user.id, role }); @@ -377,11 +448,29 @@ export class RoomsSidePanelComponent { private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void { this.updateVoiceStateStore(roomId, room, current); + this.trackCurrentUserMic(); this.startVoiceHeartbeat(roomId, room); this.broadcastVoiceConnected(roomId, room, current); this.startVoiceSession(roomId, room); } + private trackCurrentUserMic(): void { + const userId = this.currentUser()?.oderId || this.currentUser()?.id; + const micStream = this.webrtc.getRawMicStream(); + + if (userId && micStream) { + this.voiceActivity.trackLocalMic(userId, micStream); + } + } + + private untrackCurrentUserMic(): void { + const userId = this.currentUser()?.oderId || this.currentUser()?.id; + + if (userId) { + this.voiceActivity.untrackLocalMic(userId); + } + } + private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void { if (!current?.id) return; @@ -445,6 +534,8 @@ export class RoomsSidePanelComponent { // Stop voice heartbeat this.webrtc.stopVoiceHeartbeat(); + this.untrackCurrentUserMic(); + // Disable voice locally this.webrtc.disableVoice(); @@ -484,11 +575,7 @@ export class RoomsSidePanelComponent { /** Count the number of users connected to a voice channel in the current room. */ voiceOccupancy(roomId: string): number { - const users = this.onlineUsers(); - const room = this.currentRoom(); - - return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id) - .length; + return this.voiceUsersInRoom(roomId).length; } /** Dispatch a viewer:focus event to display a remote user's screen share. */ @@ -505,6 +592,13 @@ export class RoomsSidePanelComponent { window.dispatchEvent(evt); } + /** Check whether the local user has muted a specific voice user. */ + isUserLocallyMuted(user: User): boolean { + const peerId = user.oderId || user.id; + + return this.voicePlayback.isUserMuted(peerId); + } + /** Check whether a user is currently sharing their screen. */ isUserSharing(userId: string): boolean { const me = this.currentUser(); @@ -524,13 +618,33 @@ export class RoomsSidePanelComponent { return !!stream && stream.getVideoTracks().length > 0; } - /** Return all users currently connected to a specific voice channel. */ + /** Return all users currently connected to a specific voice channel, including the local user. */ voiceUsersInRoom(roomId: string) { const room = this.currentRoom(); - - return this.onlineUsers().filter( + const me = this.currentUser(); + const remoteUsers = this.onlineUsers().filter( (user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id ); + + // Include the local user at the top if they are in this voice channel + if ( + me?.voiceState?.isConnected && + me.voiceState?.roomId === roomId && + me.voiceState?.serverId === room?.id + ) { + // Avoid duplicates if the current user is already in onlineUsers + const meId = me.id; + const meOderId = me.oderId; + const alreadyIncluded = remoteUsers.some( + (user) => user.id === meId || user.oderId === meOderId + ); + + if (!alreadyIncluded) { + return [me, ...remoteUsers]; + } + } + + return remoteUsers; } /** Check whether the current user is connected to the specified voice channel. */ diff --git a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts index 0468027..fbc8c43 100644 --- a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts +++ b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts @@ -52,11 +52,14 @@ export class MembersSettingsComponent { } changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void { + const roomId = this.server()?.id; + this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.webrtcService.broadcastMessage({ type: 'role-change', + roomId, targetUserId: user.id, role }); diff --git a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html index 423e837..94e61d0 100644 --- a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html +++ b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.html @@ -229,178 +229,4 @@ - - -
-
- -

Voice Leveling

-
-
- -
-
-

Voice Leveling

-

Automatically equalise volume across speakers

-
- -
- - - @if (voiceLeveling.enabled()) { -
- -
- - -
- -30 (quiet) - -12 (loud) -
-
- - -
- - -
- - -
- - -
- 3 dB (subtle) - 20 dB (strong) -
-
- - -
- - -
- - -
-
-

Noise Floor Gate

-

Prevents boosting silence

-
- -
-
- } -
-
diff --git a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts index ec8672e..88ef92a 100644 --- a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts +++ b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts @@ -10,12 +10,11 @@ import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideMic, lucideHeadphones, - lucideAudioLines, - lucideActivity + lucideAudioLines } from '@ng-icons/lucide'; import { WebRTCService } from '../../../../core/services/webrtc.service'; -import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service'; +import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants'; @@ -36,15 +35,14 @@ interface AudioDevice { provideIcons({ lucideMic, lucideHeadphones, - lucideAudioLines, - lucideActivity + lucideAudioLines }) ], templateUrl: './voice-settings.component.html' }) export class VoiceSettingsComponent { private webrtcService = inject(WebRTCService); - readonly voiceLeveling = inject(VoiceLevelingService); + private voicePlayback = inject(VoicePlaybackService); readonly audioService = inject(NotificationAudioService); inputDevices = signal([]); @@ -56,7 +54,7 @@ export class VoiceSettingsComponent { audioBitrate = signal(96); latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); includeSystemAudio = signal(false); - noiseReduction = signal(false); + noiseReduction = signal(true); constructor() { this.loadVoiceSettings(); @@ -123,6 +121,11 @@ export class VoiceSettingsComponent { if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) { this.webrtcService.toggleNoiseReduction(this.noiseReduction()); } + + // Apply persisted volume levels to the live audio pipelines + this.webrtcService.setInputVolume(this.inputVolume() / 100); + this.webrtcService.setOutputVolume(this.outputVolume() / 100); + this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); } saveVoiceSettings(): void { @@ -162,6 +165,7 @@ export class VoiceSettingsComponent { const input = event.target as HTMLInputElement; this.inputVolume.set(parseInt(input.value, 10)); + this.webrtcService.setInputVolume(this.inputVolume() / 100); this.saveVoiceSettings(); } @@ -170,6 +174,7 @@ export class VoiceSettingsComponent { this.outputVolume.set(parseInt(input.value, 10)); this.webrtcService.setOutputVolume(this.outputVolume() / 100); + this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); this.saveVoiceSettings(); } @@ -203,40 +208,6 @@ export class VoiceSettingsComponent { this.saveVoiceSettings(); } - /* ── Voice Leveling handlers ───────────────────────────────── */ - - onVoiceLevelingToggle(): void { - this.voiceLeveling.setEnabled(!this.voiceLeveling.enabled()); - } - - onTargetDbfsChange(event: Event): void { - const input = event.target as HTMLInputElement; - - this.voiceLeveling.setTargetDbfs(parseInt(input.value, 10)); - } - - onStrengthChange(event: Event): void { - const select = event.target as HTMLSelectElement; - - this.voiceLeveling.setStrength(select.value as 'low' | 'medium' | 'high'); - } - - onMaxGainDbChange(event: Event): void { - const input = event.target as HTMLInputElement; - - this.voiceLeveling.setMaxGainDb(parseInt(input.value, 10)); - } - - onSpeedChange(event: Event): void { - const select = event.target as HTMLSelectElement; - - this.voiceLeveling.setSpeed(select.value as 'slow' | 'medium' | 'fast'); - } - - onNoiseGateToggle(): void { - this.voiceLeveling.setNoiseGate(!this.voiceLeveling.noiseGate()); - } - onNotificationVolumeChange(event: Event): void { const input = event.target as HTMLInputElement; diff --git a/src/app/features/settings/settings.component.ts b/src/app/features/settings/settings.component.ts index b5aef1a..becdd79 100644 --- a/src/app/features/settings/settings.component.ts +++ b/src/app/features/settings/settings.component.ts @@ -68,7 +68,7 @@ export class SettingsComponent implements OnInit { newServerUrl = ''; autoReconnect = true; searchAllServers = true; - noiseReduction = false; + noiseReduction = true; /** Load persisted connection settings on component init. */ ngOnInit(): void { diff --git a/src/app/features/voice/voice-controls/services/voice-playback.service.ts b/src/app/features/voice/voice-controls/services/voice-playback.service.ts index 2d07aad..99170bd 100644 --- a/src/app/features/voice/voice-controls/services/voice-playback.service.ts +++ b/src/app/features/voice/voice-controls/services/voice-playback.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { WebRTCService } from '../../../../core/services/webrtc.service'; -import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service'; +import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants'; export interface PlaybackOptions { isConnected: boolean; @@ -8,14 +8,58 @@ export interface PlaybackOptions { isDeafened: boolean; } +/** + * Per-peer Web Audio pipeline that routes the remote MediaStream + * through a GainNode so volume can be amplified beyond 100% (up to 200%). + * + * Chrome/Electron workaround: a muted HTMLAudioElement is attached to + * the stream first so that `createMediaStreamSource` actually outputs + * audio. The element itself is silent - all audible output comes from + * the GainNode -> AudioContext.destination path. + */ +interface PeerAudioPipeline { + /** Muted