From b95c09e248a301dca99f7de1c550aaa17d05aa5c Mon Sep 17 00:00:00 2001 From: Babaev Makhmoud Date: Mon, 6 Oct 2025 00:42:57 +0300 Subject: [PATCH 1/3] Optimize mobile call media processing --- docs/mobile-call-performance.md | 22 +++++ .../useLocalMedia.switchCamera.test.ts | 3 +- .../src/features/call/hooks/useLocalMedia.ts | 15 ++- .../features/call/utils/mediaConstraints.ts | 97 ++++++++++++++++++- frontend/src/lib/audioMixer.ts | 66 ++++++++----- frontend/src/lib/videoMixer.ts | 81 ++++++++++++++-- 6 files changed, 243 insertions(+), 41 deletions(-) create mode 100644 docs/mobile-call-performance.md diff --git a/docs/mobile-call-performance.md b/docs/mobile-call-performance.md new file mode 100644 index 0000000..a1e229f --- /dev/null +++ b/docs/mobile-call-performance.md @@ -0,0 +1,22 @@ +# Анализ нагрузки при мобильном видеозвонке + +## Наблюдения по коду + +- **Микширование видео через Canvas на 60 FPS.** В `VideoStreamMixer` кадры двух потоков постоянно отрисовываются в `canvas` и перехватываются через `captureStream(60)`, что фиксирует частоту 60 кадров в секунду и запускает бесконечный цикл `requestAnimationFrame`. На мобильных устройствах такое перерисовывание на полном разрешении приводит к высокой нагрузке на CPU/GPU и ускоренному расходу батареи.【F:frontend/src/lib/videoMixer.ts†L32-L127】 +- **Постоянный анализ аудио-потока.** `AudioStreamMixer` создает `AudioContext`, подключает `AnalyserNode` и в каждом кадре браузера считывает спектральные данные для регулировки громкости системного звука. Это создает дополнительную постоянную работу для процессора даже если громкость не меняется, а также не останавливается автоматически при выключенном микрофоне пользователя.【F:frontend/src/lib/audioMixer.ts†L46-L141】 +- **Запрос видеопотока без ограничения разрешения.** При инициализации локального потока `getUserMedia` вызывается только с `facingMode: 'user'`, без явного указания допустимого разрешения или частоты кадров. Современные телефоны могут отдавать 1080p/4K на 60 FPS, что значительно нагружает датчик камеры, кодек и канал передачи.【F:frontend/src/features/call/hooks/useLocalMedia.ts†L226-L242】【F:frontend/src/features/call/utils/mediaConstraints.ts†L23-L34】 +- **Восстановление потоков при каждом возврате во вкладку.** При любом переходе во вкладку приложение пытается заново получить `getUserMedia`, даже если текущие дорожки «живы». Это может дергать камеру и создаёт всплески нагрузки и расхода энергии на мобильных устройствах при переключении приложений.【F:frontend/src/features/call/hooks/useLocalMedia.ts†L210-L244】 + +## Предложения по оптимизации + +1. **Адаптивное качество видеопотоков.** Добавить конфигурацию, которая будет ограничивать целевое разрешение (например, 720p/30 FPS) при подключении с мобильных устройств, и понижать его при перегрузке CPU/слабом соединении. Это снизит нагрузку на кодек, уменьшит тепловыделение и расход батареи. +2. **Оптимизация canvas-монтажа.** Снижать частоту отрисовки в `VideoStreamMixer` (например, использовать 30 FPS или `requestVideoFrameCallback` для синхронизации с исходным потоком) и отключать рендер при свернутом UI/отсутствии второго окна. Это уменьшит количество операций рисования и нагрузку на GPU. +3. **Троттлинг аудио-анализатора.** Вместо вызова `requestAnimationFrame` на каждый кадр, измерять громкость с меньшей частотой (например, раз в 100–200 мс через `setInterval`) и останавливать `AudioContext`, когда микрофон выключен. Это сократит постоянную нагрузку на CPU и сэкономит энергию. +4. **Бережное восстановление медиапотока.** Перед повторным вызовом `getUserMedia` при возврате во вкладку проверять `readyState` дорожек и пересоздавать поток только при необходимости. Это снизит количество лишних инициализаций камеры/микрофона, что уменьшит энергопотребление и потенциальные глюки на мобильных устройствах. + +## Задачи для реализации + +1. **"Ввести адаптивные ограничения качества локального видео"** – определить логики выбора подходящих `MediaTrackConstraints` на основе типа устройства/состояния сети и обновить `useLocalMedia` так, чтобы камера запрашивалась с безопасным базовым разрешением и частотой кадров. Цель: снизить нагрузку на мобильные камеры и кодеки без заметной потери качества связи. +2. **"Перевести VideoStreamMixer на экономичный режим рендеринга"** – настроить частоту `canvas.captureStream` и расписание перерисовок так, чтобы в мобильном сценарии использовалось не более 30 FPS и отрисовка приостанавливалась, когда превью не отображается. Цель: уменьшить нагрузку на графический процессор и продлить время работы от батареи при звонке. +3. **"Оптимизировать анализ микрофона в AudioStreamMixer"** – заменить бесконечный `requestAnimationFrame` на менее частые проверки и отключать `AudioContext`, когда микрофон выключен или пользователь не говорит. Цель: сократить постоянное использование CPU при звонке. +4. **"Переписать восстановление локального потока после возврата в приложение"** – добавить проверку готовности текущих `MediaStreamTrack` и запускать `restartLocalStream` только при фактическом завершении дорожек. Цель: исключить лишние `getUserMedia` на мобильных устройствах и избежать скачков нагрузки при переключении между приложениями. diff --git a/frontend/src/features/call/hooks/__tests__/useLocalMedia.switchCamera.test.ts b/frontend/src/features/call/hooks/__tests__/useLocalMedia.switchCamera.test.ts index 39c68aa..ce22a92 100644 --- a/frontend/src/features/call/hooks/__tests__/useLocalMedia.switchCamera.test.ts +++ b/frontend/src/features/call/hooks/__tests__/useLocalMedia.switchCamera.test.ts @@ -109,8 +109,7 @@ describe('useLocalMedia.switchCamera', () => { value: originalMediaDevices, }); } else { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (navigator as { mediaDevices?: MediaDevices }).mediaDevices; + Reflect.deleteProperty(navigator as { mediaDevices?: MediaDevices }, 'mediaDevices'); } vi.restoreAllMocks(); }); diff --git a/frontend/src/features/call/hooks/useLocalMedia.ts b/frontend/src/features/call/hooks/useLocalMedia.ts index e17304e..ae77fa3 100644 --- a/frontend/src/features/call/hooks/useLocalMedia.ts +++ b/frontend/src/features/call/hooks/useLocalMedia.ts @@ -236,9 +236,13 @@ export const useLocalMedia = ({ roomId, messageApi }: UseLocalMediaOptions) => { const setupLocalStream = useCallback(async () => { try { + const videoConstraints = buildVideoConstraints( + activeVideoDeviceId, + desiredFacingModeRef.current ?? 'user', + ); const stream = await navigator.mediaDevices.getUserMedia({ audio: true, - video: { facingMode: 'user' }, + video: videoConstraints, }); await applyLocalStream(stream, { micEnabled: isMicEnabled, cameraEnabled: isCameraEnabled }); } catch (error) { @@ -250,7 +254,14 @@ export const useLocalMedia = ({ roomId, messageApi }: UseLocalMediaOptions) => { } await refreshDevices(); - }, [applyLocalStream, isCameraEnabled, isMicEnabled, messageApi, refreshDevices]); + }, [ + activeVideoDeviceId, + applyLocalStream, + isCameraEnabled, + isMicEnabled, + messageApi, + refreshDevices, + ]); const toggleMicrophone = useCallback(() => { const stream = localStreamRef.current; diff --git a/frontend/src/features/call/utils/mediaConstraints.ts b/frontend/src/features/call/utils/mediaConstraints.ts index 8a0421d..cfc42d3 100644 --- a/frontend/src/features/call/utils/mediaConstraints.ts +++ b/frontend/src/features/call/utils/mediaConstraints.ts @@ -20,15 +20,106 @@ export const guessFacingMode = ( return null; }; +const MOBILE_VIDEO_CONSTRAINTS: MediaTrackConstraints = { + width: { ideal: 960, max: 1280 }, + height: { ideal: 540, max: 720 }, + frameRate: { ideal: 24, max: 30 }, +}; + +const DEFAULT_VIDEO_CONSTRAINTS: MediaTrackConstraints = { + width: { ideal: 1280, max: 1920 }, + height: { ideal: 720, max: 1080 }, + frameRate: { ideal: 30, max: 30 }, +}; + +const CONSERVATIVE_VIDEO_CONSTRAINTS: MediaTrackConstraints = { + width: { ideal: 640, max: 960 }, + height: { ideal: 360, max: 540 }, + frameRate: { ideal: 20, max: 24 }, +}; + +const isMobileDevice = () => { + if (typeof navigator === 'undefined') { + return false; + } + + const userAgent = navigator.userAgent || ''; + return /android|iphone|ipad|ipod|mobile/i.test(userAgent); +}; + +type NetworkQuality = 'slow' | 'unknown' | 'fast'; + +const getNetworkQuality = (): NetworkQuality => { + if (typeof navigator === 'undefined') { + return 'unknown'; + } + + const connection = + (navigator as Navigator & { connection?: unknown }).connection as + | (NetworkInformation & { saveData?: boolean }) + | undefined; + + if (!connection) { + return 'unknown'; + } + + if ('saveData' in connection && connection.saveData) { + return 'slow'; + } + + const effectiveType = connection.effectiveType; + if (!effectiveType) { + return 'unknown'; + } + + if (['slow-2g', '2g', '3g'].includes(effectiveType)) { + return 'slow'; + } + + if (effectiveType === '4g') { + return 'fast'; + } + + return 'unknown'; +}; + +const pickBaseVideoConstraints = (): MediaTrackConstraints => { + const mobile = isMobileDevice(); + const networkQuality = getNetworkQuality(); + + if (networkQuality === 'slow') { + return CONSERVATIVE_VIDEO_CONSTRAINTS; + } + + if (mobile) { + return MOBILE_VIDEO_CONSTRAINTS; + } + + return DEFAULT_VIDEO_CONSTRAINTS; +}; + export const buildVideoConstraints = ( deviceId?: string | null, facingMode?: 'user' | 'environment' | null, ): MediaTrackConstraints => { + const baseConstraints = { ...pickBaseVideoConstraints() }; + if (deviceId) { - return { deviceId: { exact: deviceId } }; + return { + ...baseConstraints, + deviceId: { exact: deviceId }, + }; } + if (facingMode) { - return { facingMode: { exact: facingMode } }; + return { + ...baseConstraints, + facingMode: { exact: facingMode }, + }; } - return { facingMode: 'user' }; + + return { + ...baseConstraints, + facingMode: 'user', + }; }; diff --git a/frontend/src/lib/audioMixer.ts b/frontend/src/lib/audioMixer.ts index 0710470..eb6de0c 100644 --- a/frontend/src/lib/audioMixer.ts +++ b/frontend/src/lib/audioMixer.ts @@ -54,9 +54,9 @@ export class AudioStreamMixer { private systemGain: GainNode | null = null; - private scheduleNextRaf = false; + private volumeIntervalId: number | null = null; - private rafId: number | null = null; + private readonly volumeCheckIntervalMs = 150; private options: AudioMixerOptions; @@ -94,37 +94,53 @@ export class AudioStreamMixer { this.analyser = getAnalyser(this.context); micSource.connect(this.analyser); - this.scheduleNextRaf = true; - const listen = () => { - if (!this.context || !this.analyser || !this.systemGain) { - return; - } - const average = getAverageVolume(this.analyser); - - if (average > microAverageVolume) { - this.systemGain.gain.setTargetAtTime(0.32, this.context.currentTime, 0.05); - } else { - this.systemGain.gain.setTargetAtTime(1, this.context.currentTime, 0.05); - } - - if (this.scheduleNextRaf) { - this.rafId = requestAnimationFrame(listen); - } - }; - - listen(); + this.startVolumeMonitoring(microAverageVolume); } } + private startVolumeMonitoring(microAverageVolume: number) { + if (typeof window === 'undefined') { + return; + } + + if (this.volumeIntervalId !== null) { + window.clearInterval(this.volumeIntervalId); + } + + const checkVolume = () => { + if (!this.context || !this.analyser || !this.systemGain) { + return; + } + + const hasActiveMic = this.options.userAudioStream + ?.getAudioTracks() + .some((track) => track.enabled && track.readyState === 'live'); + + if (!hasActiveMic) { + this.systemGain.gain.setTargetAtTime(1, this.context.currentTime, 0.05); + return; + } + + const average = getAverageVolume(this.analyser); + if (average > microAverageVolume) { + this.systemGain.gain.setTargetAtTime(0.32, this.context.currentTime, 0.05); + } else { + this.systemGain.gain.setTargetAtTime(1, this.context.currentTime, 0.05); + } + }; + + checkVolume(); + this.volumeIntervalId = window.setInterval(checkVolume, this.volumeCheckIntervalMs); + } + getAudioStream() { return this.audioStream; } stop() { - this.scheduleNextRaf = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - this.rafId = null; + if (typeof window !== 'undefined' && this.volumeIntervalId !== null) { + window.clearInterval(this.volumeIntervalId); + this.volumeIntervalId = null; } } diff --git a/frontend/src/lib/videoMixer.ts b/frontend/src/lib/videoMixer.ts index 67b8ec8..1bebaa6 100644 --- a/frontend/src/lib/videoMixer.ts +++ b/frontend/src/lib/videoMixer.ts @@ -38,10 +38,34 @@ export class VideoStreamMixer { private rafId: number | null = null; - private scheduleNextFrame = false; + private isLoopEnabled = false; + + private isVisibilityPaused = false; + + private lastFrameTimestamp = 0; + + private readonly frameIntervalMs = 1000 / 30; private options: MixerOptions; + private readonly handleVisibilityChange = () => { + if (typeof document === 'undefined') { + return; + } + + this.isVisibilityPaused = document.visibilityState === 'hidden'; + + if (this.isVisibilityPaused && this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + return; + } + + if (!this.isVisibilityPaused) { + this.requestNextFrame(); + } + }; + constructor(options: MixerOptions) { this.options = options; } @@ -80,9 +104,15 @@ export class VideoStreamMixer { this.prepareVideoElement(this.options.secondStream); } - this.scheduleNextFrame = true; - this.computeFrame(); - this.videoStream = canvas.captureStream(60); + this.isLoopEnabled = true; + this.isVisibilityPaused = typeof document !== 'undefined' && document.visibilityState === 'hidden'; + this.lastFrameTimestamp = 0; + this.requestNextFrame(); + this.videoStream = canvas.captureStream(30); + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', this.handleVisibilityChange); + } } private prepareVideoElement(streamConfig: StreamConfig) { @@ -99,11 +129,41 @@ export class VideoStreamMixer { } } - private computeFrame = () => { + private requestNextFrame() { + if (!this.isLoopEnabled || this.isVisibilityPaused || this.rafId !== null) { + return; + } + + this.rafId = requestAnimationFrame(this.computeFrame); + } + + private computeFrame = (timestamp?: number) => { if (!this.ctx || !this.canvas) { return; } + if (!this.isLoopEnabled) { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + return; + } + + if (this.isVisibilityPaused) { + this.rafId = null; + return; + } + + const now = typeof timestamp === 'number' ? timestamp : performance.now(); + if (now - this.lastFrameTimestamp < this.frameIntervalMs) { + this.rafId = null; + this.requestNextFrame(); + return; + } + + this.lastFrameTimestamp = now; + const { firstStream, secondStream, sizes } = this.options; const firstVideo = firstStream.videoElement; const firstWidth = firstStream.width ?? sizes.width; @@ -122,9 +182,8 @@ export class VideoStreamMixer { this.ctx.drawImage(secondVideo, secondLeft, secondTop, secondWidth, secondHeight); } - if (this.scheduleNextFrame) { - this.rafId = requestAnimationFrame(this.computeFrame); - } + this.rafId = null; + this.requestNextFrame(); }; getVideoStream() { @@ -132,15 +191,19 @@ export class VideoStreamMixer { } stop() { - this.scheduleNextFrame = false; + this.isLoopEnabled = false; if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } + this.isVisibilityPaused = false; } destroy() { this.stop(); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + } if (this.canvas) { this.canvas.remove(); this.canvas = null; From 30379ac771d2211e0e27019dcc0ece25548922bc Mon Sep 17 00:00:00 2001 From: Babaev Makhmoud Date: Mon, 6 Oct 2025 00:45:30 +0300 Subject: [PATCH 2/3] chore: remove obsolete mobile performance plan --- docs/mobile-call-performance.md | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 docs/mobile-call-performance.md diff --git a/docs/mobile-call-performance.md b/docs/mobile-call-performance.md deleted file mode 100644 index a1e229f..0000000 --- a/docs/mobile-call-performance.md +++ /dev/null @@ -1,22 +0,0 @@ -# Анализ нагрузки при мобильном видеозвонке - -## Наблюдения по коду - -- **Микширование видео через Canvas на 60 FPS.** В `VideoStreamMixer` кадры двух потоков постоянно отрисовываются в `canvas` и перехватываются через `captureStream(60)`, что фиксирует частоту 60 кадров в секунду и запускает бесконечный цикл `requestAnimationFrame`. На мобильных устройствах такое перерисовывание на полном разрешении приводит к высокой нагрузке на CPU/GPU и ускоренному расходу батареи.【F:frontend/src/lib/videoMixer.ts†L32-L127】 -- **Постоянный анализ аудио-потока.** `AudioStreamMixer` создает `AudioContext`, подключает `AnalyserNode` и в каждом кадре браузера считывает спектральные данные для регулировки громкости системного звука. Это создает дополнительную постоянную работу для процессора даже если громкость не меняется, а также не останавливается автоматически при выключенном микрофоне пользователя.【F:frontend/src/lib/audioMixer.ts†L46-L141】 -- **Запрос видеопотока без ограничения разрешения.** При инициализации локального потока `getUserMedia` вызывается только с `facingMode: 'user'`, без явного указания допустимого разрешения или частоты кадров. Современные телефоны могут отдавать 1080p/4K на 60 FPS, что значительно нагружает датчик камеры, кодек и канал передачи.【F:frontend/src/features/call/hooks/useLocalMedia.ts†L226-L242】【F:frontend/src/features/call/utils/mediaConstraints.ts†L23-L34】 -- **Восстановление потоков при каждом возврате во вкладку.** При любом переходе во вкладку приложение пытается заново получить `getUserMedia`, даже если текущие дорожки «живы». Это может дергать камеру и создаёт всплески нагрузки и расхода энергии на мобильных устройствах при переключении приложений.【F:frontend/src/features/call/hooks/useLocalMedia.ts†L210-L244】 - -## Предложения по оптимизации - -1. **Адаптивное качество видеопотоков.** Добавить конфигурацию, которая будет ограничивать целевое разрешение (например, 720p/30 FPS) при подключении с мобильных устройств, и понижать его при перегрузке CPU/слабом соединении. Это снизит нагрузку на кодек, уменьшит тепловыделение и расход батареи. -2. **Оптимизация canvas-монтажа.** Снижать частоту отрисовки в `VideoStreamMixer` (например, использовать 30 FPS или `requestVideoFrameCallback` для синхронизации с исходным потоком) и отключать рендер при свернутом UI/отсутствии второго окна. Это уменьшит количество операций рисования и нагрузку на GPU. -3. **Троттлинг аудио-анализатора.** Вместо вызова `requestAnimationFrame` на каждый кадр, измерять громкость с меньшей частотой (например, раз в 100–200 мс через `setInterval`) и останавливать `AudioContext`, когда микрофон выключен. Это сократит постоянную нагрузку на CPU и сэкономит энергию. -4. **Бережное восстановление медиапотока.** Перед повторным вызовом `getUserMedia` при возврате во вкладку проверять `readyState` дорожек и пересоздавать поток только при необходимости. Это снизит количество лишних инициализаций камеры/микрофона, что уменьшит энергопотребление и потенциальные глюки на мобильных устройствах. - -## Задачи для реализации - -1. **"Ввести адаптивные ограничения качества локального видео"** – определить логики выбора подходящих `MediaTrackConstraints` на основе типа устройства/состояния сети и обновить `useLocalMedia` так, чтобы камера запрашивалась с безопасным базовым разрешением и частотой кадров. Цель: снизить нагрузку на мобильные камеры и кодеки без заметной потери качества связи. -2. **"Перевести VideoStreamMixer на экономичный режим рендеринга"** – настроить частоту `canvas.captureStream` и расписание перерисовок так, чтобы в мобильном сценарии использовалось не более 30 FPS и отрисовка приостанавливалась, когда превью не отображается. Цель: уменьшить нагрузку на графический процессор и продлить время работы от батареи при звонке. -3. **"Оптимизировать анализ микрофона в AudioStreamMixer"** – заменить бесконечный `requestAnimationFrame` на менее частые проверки и отключать `AudioContext`, когда микрофон выключен или пользователь не говорит. Цель: сократить постоянное использование CPU при звонке. -4. **"Переписать восстановление локального потока после возврата в приложение"** – добавить проверку готовности текущих `MediaStreamTrack` и запускать `restartLocalStream` только при фактическом завершении дорожек. Цель: исключить лишние `getUserMedia` на мобильных устройствах и избежать скачков нагрузки при переключении между приложениями. From ebc49bcf46dbfd8927319a5a5fe1bfd4056f6e15 Mon Sep 17 00:00:00 2001 From: Babaev Makhmoud Date: Mon, 6 Oct 2025 00:50:24 +0300 Subject: [PATCH 3/3] Fix navigator connection typing for network quality --- frontend/src/features/call/utils/mediaConstraints.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/call/utils/mediaConstraints.ts b/frontend/src/features/call/utils/mediaConstraints.ts index cfc42d3..f5ae6b9 100644 --- a/frontend/src/features/call/utils/mediaConstraints.ts +++ b/frontend/src/features/call/utils/mediaConstraints.ts @@ -49,15 +49,19 @@ const isMobileDevice = () => { type NetworkQuality = 'slow' | 'unknown' | 'fast'; +type NavigatorConnection = Navigator & { + connection?: { + effectiveType?: string; + saveData?: boolean; + }; +}; + const getNetworkQuality = (): NetworkQuality => { if (typeof navigator === 'undefined') { return 'unknown'; } - const connection = - (navigator as Navigator & { connection?: unknown }).connection as - | (NetworkInformation & { saveData?: boolean }) - | undefined; + const connection = (navigator as NavigatorConnection).connection; if (!connection) { return 'unknown';