수달이네 기술 블로그

3. ASR파이프라인 구현(파이프라인 설계 + 오디오 개념) 본문

프로젝트

3. ASR파이프라인 구현(파이프라인 설계 + 오디오 개념)

슬픈 수달이 2026. 4. 14. 19:06

기초적인 ASR 파이프 라인

  1. 오디오: 오디오 소스를 받아온다.(백 or 로컬 마이크)
  2. 백엔드에서 받아올 경우 WebSocket에서 받아옴.
  3. VAD: 발화에서 입력 신호가 아닌 것들은 걸러낸다.(침묵 감지)
  4. SpeechBuffer: 버퍼 안에 입력 신호를 받아 저장해둔다.
  5. Whisper: whisper모델에 해당 신호를 넣어 텍스트로 변환한다.
  6. 결과 반환: 결과를 전송한다.

즉, 위와 같은 코드를 작성하기 위한 기초적인 지식을 공부하고자 한다.

오디오 소스

PCM(Pulse Code Modulation)

아날로그 오디오를 디지털로 표현하는 방식이다.

  • 기본적으로 소리는 공기의 연속적인 압력의 변화이다. → 아날로그 오디오
  • 그러나 컴퓨터는 해당 신호를 다룰 수 없기에 숫자 배열로 변환한다. → 디지털

여기서 사용되는 것이 PCM(Pulse Code Modulation)이다.

1단계 - 샘플링(Sampling)

신호를 일정한 간격으로 쪼개 저장하는 과정이다.

  • 위처럼 파형을 쪼개 나눈다.
  • 여기서 간격의 기준이 샘플레이트이다. (1초에 16000개로 파형을 나눈다 → 16000Hz)

샘플레이트(나이퀴스트 정리)

  • 샘플레이트를 잘게 나눌 수록 파일의 크기가 커지기 때문에 적당한 샘플레이트를 가져야 한다.(소리의 해상도)
  • 음의 높낮이에 따라 Hz가 변하는데 나이퀴스트 이론에 따르면, 기록하려는 소리의 최고 Hz보다 2배 높은 샘플레이트로 녹음해야 제대로 녹음 가능하다고 한다.
    • 이 이유는 간단히 설명하면 높을수록 주파수가 빠르게 진동한다. 그런데 그 진동속도보다 2배 빠르지 않다면, 마루와 골을 제대로 담지 못한다.(소리가 뭉게짐)

2단계-양자화(Quantization)

위 그림처럼 나눈 간격의 소리를 딱 하나의 수로 나타내는 과정이다.

  • 소리의 곡선을 정해진 칸에서 가장 가까운 숫자로 반올림한다.

비트 심도

  • 소리를 얼마나 촘촘하게 나눌 것인가를 정한다.
  • 8-bit, 16-bit는 소리의 높낮이를 $2^8$로 나누거나 $2^{16}$으로 나눈다.
    • 코드에서 int16일경우 -32,768~+32,767 사이의 정수로 표현한다.
  • 여기서 미세한 오차가 적을수록 음질이 좋다고 여긴다.

3단계-부호화(Encoding)

양자화된 정수를 비트로 저장/전송하는 과정

  • 양자화된 숫자를 비트수 만큼의 이진수로 변환한다.

즉, 압축을 하는 과정인데, 이를 통해 다양한 부가 효과를 얻을 수 있다.

  • 부호화를 통해 사람이 듣지 못하는 고음역대를 삭제할 수도 있다.
  • 또한, 전송 중의 오류가 생겼을 때 이를 감지하고 복구하는 코드를 덧붙일 수 있다.(RAID5방식: 패리티비트 적용 등)
  • 부가적인 샘플레이트에 대한 정보 등 포함 가능

추가 단계 - float32변환

    @staticmethod
    def _bytes_to_float32(raw_bytes: bytes) -> np.ndarray:
        pcm_int16 = np.frombuffer(raw_bytes, dtype=np.int16)
        return pcm_int16.astype(np.float32) / 32768.0

ASR파이프라인 중에서 PCM을 통해 int16으로 양자화한 데이터를 float32로 변환한다.

  • ML에선 보통 float32의 데이터를 요구한다 그러나 보통 faster-whisper내부에서 해당 변환을 자동으로 해준다.
  • 그러나, VAD(침묵 감지)에서 threshold(임계점)를 float(-1.0~1.0)를 가정하고 설정할 것인데, → 이것과 다른 int값을 넣을 경우, 아예 해당 기능이 작동하지 않을 것이다.

오디오에서 필요한 Websocket 이해

보통 웹에서 사용하는 HTTP는 클라이언트가 요청해야 서버가 응답하는 구조이다.

그러나, 오디오 스트리밍 처럼 지속적으로 데이터를 주고받아야 하는 상황에선 맞지 않는다.

WebSocket은 한 번 연결되면 양방향으로 계속 데이터를 주고받을 수 있는 연결 구조다.

HTTP:      클라이언트 → 요청 → 서버 → 응답 → 연결 끊김
WebSocket: 클라이언트 ←————— 연결 유지 —————→ 서버
                        언제든 양방향 전송
  • 위와 같은 구조라고 할 수 있다.

클라이언트 모드

ASR이 백엔드에 직접 접속할 때 사용하는 모드이다.

백엔드 서버 (오디오 생산자)
    ↑
    │ ASR이 먼저 연결 요청
    │
ASR Pipeline (오디오 소비자)
  • 즉, 백엔드가 마이크 스트림을 받아 WebSocket으로 뿌려주고, ASR이 거기에서 전사한다.

서버 모드

벡엔드가 ASR에 접속하는 구조

ASR Server (포트 열고 대기)
    ↑
    │ 백엔드가 먼저 연결 요청
    │
백엔드 (오디오 생산자)
  • 즉, ASR을 독립적으로 서비스에 띄워두고, 백엔드가 붙어 전사하는 구조이다.

우리가 구현할 거라면

난 현재 라이브 스트리밍을 실시간으로 전사할 구조를 설계하고 있다.

  • 스트림 시작/종료에 맞춰 백엔드가 연결을 알아서 끊을 수 있어야한다.
  • 여러 방송이 켜졌을 때 백엔드가 각 스트림에 맞춰 ASR을 연결해야한다.

이런 관점에서 보면

  • 클라이언트 모드의 경우 적용하면 구조적으로 불가하다
  • 서버모드로 구현하는 것이 맞음.

async/await(비동기)

오디오 수신은 I/O대기 작업이다. 그런데 이것없이 동기로 짤 경우 데이터가 올 때 까지 프로그램이 멈춘다.

  • whisper는 cpu연산이므로 별도 스레드로 분리하고, 네트워크 I/O는 async로 처리한다

채널

독립적인 소리 신호의 경로이다.

종류 채널 수 설명
Mono 1 단일 신호. 방향감 없음
Stereo 2 좌(L) + 우(R). 공간감 있음
5.1 Surround 6 영화관, 게임 등

그런데 ASR모델에선 굳이 채널을 스테레오나 서라운드로 설정할 필요가 없다.

@dataclass
class PipelineConfig:
    channels: int = int(_env("AUDIO_CHANNELS", "1"))  # 기본값 Mono
  • 따라서 모노로 설정한다.

그러나 화자가 많을 경우, 채널별로 화자를 분리하는 방법도 생각할 수 있다.