2025, Nov 08 03:02

Писк при трансляции аудио через Flask‑SocketIO: как мы нашли источник

Как устранить периодический писк в аудиостриме Flask‑SocketIO/WebSocket. Диагностика: ffmpeg, Eventlet, sounddevice. Источник — микрофон веб‑камеры.

Трансляция живого аудио по WebSocket кажется простой — пока не сталкиваешься с артефактами, будто кричащими «гонка данных». Типичный пример: периодический писк примерно два раза в секунду, который прерывает вполне корректные аудиофреймы, захваченные через sounddevice и отправляемые через Flask‑SocketIO. В конфигурации задействован Eventlet с monkey patching, приложение развёрнуто под gunicorn — логично заподозрить конкуренцию, тайминги или сам стек развёртывания. Однако первопричина оказалась в другом.

Конфигурация и симптом

Приложение захватывает сигнал с микрофона и отправляет сырые блоки PCM в комнату Socket.IO. Включён Eventlet с monkey patching, процесс запускается под gunicorn с воркером Eventlet.

import eventlet
eventlet.monkey_patch()
gunicorn -k eventlet -w 1 -t 4 -b 0.0.0.0:5001 app:app

Ниже — ключевая логика потоковой передачи, где и проявлялся писк. Имена условные, но структура и поведение совпадают с оригинальной реализацией.

import sounddevice as audioio
import threading
from extensions import sio as sock
from flask_socketio import join_room, leave_room

class StreamRelay:
    def __init__(self, rate_hz=44100, chan_count=2, slice_len=1024):
        self.rate_hz = rate_hz
        self.chan_count = chan_count
        self.slice_len = slice_len
        self.bg_worker = None
        self.group_name = 'audio_listeners'
        self.live = False

    def begin(self):
        if self.live:
            return
        self.live = True
        self.bg_worker = threading.Thread(target=self._run_capture, daemon=True).start()
        print('[StreamRelay] worker started')

    def end(self):
        if not self.live:
            return
        self.live = False
        self.bg_worker.join(timeout=1)
        print('[StreamRelay] worker stopped')

    def include_peer(self, sid):
        join_room(self.group_name, sid=sid)
        if not self.live:
            self.begin()

    def exclude_peer(self, sid):
        leave_room(self.group_name, sid=sid)

    def _run_capture(self):
        try:
            with audioio.InputStream(samplerate=self.rate_hz, channels=self.chan_count, dtype='int16') as mic:
                while self.live:
                    buf, _ = mic.read(self.slice_len)
                    sock.emit('audio_chunk', buf.tobytes(), room=self.group_name)
                    sock.sleep(0)
        except Exception as exc:
            print(f'[StreamRelay] error: {exc}')

На стороне клиента звук приходит и воспроизводится, но навязчивый периодический писк вмешивается примерно дважды в секунду.

Что происходит на самом деле

Характер шума похож на сбой планировщика или недокорм буфера, поэтому хочется списать всё на Eventlet, цикл WebSocket или взаимодействие потоков. Однако внешняя запись это опровергла. Запись через ffmpeg воспроизвела писк независимо от Flask‑SocketIO или команды запуска, значит артефакт не внедряется веб‑стеком.

Есть ещё одна важная деталь: микрофон встроен в веб‑камеру. При включённой камере писк возникает даже при записи ffmpeg. Когда камеру выключают, микрофон работает корректно. Это сужает причину до уровня железа или драйвера, а не кода приложения, конкуренции или сетевого тракта.

Минимальный контрольный прогон

Простой скрипт Flask‑SocketIO можно использовать как контрольный эксперимент. Даже если такой скрипт порой работает и создаёт впечатление, что дело в сложности приложения, здесь решающим является внешний тест записи, потому что он полностью обходит приложение.

import eventlet
eventlet.monkey_patch()

from flask import Flask, render_template
from flask_socketio import SocketIO
import sounddevice as audioio
import threading

webapp = Flask(__name__)
ws = SocketIO(webapp)

rate_hz = 44100
nchan = 2
framesz = 1024

def capture_loop():
    with audioio.InputStream(samplerate=rate_hz, channels=nchan, dtype='int16') as mic:
        while True:
            chunk, _ = mic.read(framesz)
            ws.emit('audio_chunk', chunk.tobytes())
            ws.sleep(0)

@webapp.route('/')
def homepage():
    return render_template('index.html')

if __name__ == '__main__':
    threading.Thread(target=capture_loop, daemon=True).start()
    ws.run(webapp, host='0.0.0.0', port=5001, allow_unsafe_werkzeug=True)

Решение

Решение не затронуло Flask‑SocketIO, Eventlet, gunicorn или координацию потоков. Источник писка — уровень оборудования или драйверов. Поскольку микрофон интегрирован с веб‑камерой, активная камера вызывала периодический артефакт. После выключения камеры шум исчез, и аудиопоток стал чистым. Это согласуется с внешним тестом через ffmpeg, который воспроизвёл проблему вне приложения.

Для исправления не потребовалось менять код. Митигация на уровне устройства: при отключённой камере вход с микрофона чистый, а путь потоковой передачи остаётся прежним.

Почему это важно

В работе с медиапайплайнами реального времени легко списать слышимые артефакты на примитивы синхронизации, асинхронные циклы или сетевой джиттер. Этот случай показывает, почему важно изолировать источник аудио и проверить его вне приложения. Если внешний рекордер фиксирует ту же проблему, значит она выше по цепочке относительно вашего кода. Это экономит время и избавляет от ненужного рефакторинга здорового стримингового стека.

Выводы

Начните с проверки цепочки захвата независимо от веб‑сервера и фреймворка. Если тот же писк слышен в записи ffmpeg, дальше смотрите на устройство или конфигурацию драйверов. И если микрофон — часть веб‑камеры, протестируйте с выключенной камерой, чтобы понять, исчезает ли артефакт. Эти шаги просты и показательны и помогут не уходить в отладку потоков, планировщиков или Socket.IO, когда виноват сам тракт сигнала.

Статья основана на вопросе с StackOverflow от Daniel и ответе от Daniel.