
영상 편집기에서 사용자가 마이크로 한 문장을 녹음하면 브라우저에서는 아무 문제 없이 재생된다. 그런데 같은 파일을 외부 음성합성 API에 보내면 곧바로 실패한다.
녹음이 잘못된 것은 아니다. 브라우저가 만든 오디오와 API가 기대하는 오디오의 형식이 서로 달랐을 뿐이다.
브라우저와 API는 서로 다른 오디오를 기대한다
브라우저의 녹음 API는 보통 WebM 컨테이너에 48kHz 스테레오 오디오를 저장한다. 브라우저에서 재생하기에는 가장 자연스러운 형식이다.
반면 음성합성 API는 다음과 같은 입력을 요구했다.
- WAV
- 16kHz
- Mono
- 16-bit PCM
- 3MB 이하
컨테이너부터 샘플레이트, 채널 수, 비트 깊이까지 모두 달랐다. 브라우저가 만든 Blob을 그대로 업로드하면 415(지원하지 않는 형식)나 413(용량 초과) 오류가 났다. 음성이 아니라 포맷이 문제였다.
변환은 서버가 아니라 브라우저에서 했다
가장 먼저 고민한 것은 변환을 어디에서 할 것인가였다.
보통은 서버가 업로드를 받아 오디오를 변환한 뒤 다음 API로 전달한다. 우리는 대신 WebAssembly 기반 트랜스코딩 엔진을 브라우저에서 실행하는 방식을 택했다.
이렇게 하면 별도의 변환 서버가 필요 없고, 원본 음성이 서버를 거치지 않는다. 대신 변환이 사용자의 기기에서 이루어지고, 처음 쓸 때는 수십 MB의 WASM 엔진을 내려받아야 한다. 영상 제작 도구에서는 브라우저에서 변환하는 구조가 더 단순했다.
WASM은 필요할 때 한 번만 로드한다
트랜스코딩 엔진은 수십 MB 크기의 WASM 바이너리를 쓴다. 모든 사용자가 음성 기능을 쓰지는 않으므로, 페이지에 들어오자마자 다운로드하는 것은 낭비였다. 그래서 변환이 필요한 순간에만 엔진을 불러오도록 지연 로딩을 적용했다.
동시에 여러 요청이 들어오는 상황도 고려해야 했다. 같은 바이너리를 여러 번 내려받지 않도록 초기화된 인스턴스를 재사용하고, 로딩 중에는 하나의 Promise를 공유하도록 구성했다. 로딩이 실패하면 상태를 초기화해 다음 요청이 처음부터 다시 시작하도록 했다.
변환 속도보다 중요한 것은 언제, 몇 번 로드하는지였다.
16kHz는 품질을 낮추는 것이 아니다
48kHz 스테레오를 16kHz 모노로 변환하면 음질을 희생하는 것처럼 보인다. 하지만 음성합성 입력에서는 오히려 API가 기대하는 형식에 맞추는 과정이다.
사람의 음성 정보 대부분은 8kHz 이하 대역에 담기므로 16kHz 샘플레이트만으로도 충분하다. 파일 크기도 크게 줄어든다. 16kHz Mono 16-bit PCM WAV는 60초 기준 약 1.9MB 정도로 API 제한 안에 들어간다.
API에 보내기 전에 먼저 검사한다
API는 3MB를 넘는 파일을 413 오류로 거절한다. 사용자가 변환과 업로드를 다 기다린 뒤에야 실패를 보는 경험은 좋지 않았다. 그래서 두 번 검사했다.
녹음 길이로 예상 파일 크기를 먼저 계산하고, 변환이 끝난 뒤 실제 WAV 크기를 다시 확인했다. 목표는 API 오류를 줄이는 것이 아니라, 사용자가 API 오류를 아예 보지 않게 만드는 것이었다.
통합에서 더 어려웠던 것은 변환이 아니라 응답이었다
오디오 변환 자체는 입력 형식만 맞추면 비교적 단순했다. 오히려 더 까다로웠던 쪽은 외부 API의 응답이었다.
같은 API가 요청 옵션에 따라 JSON, 바이너리 오디오, URL 등 서로 다른 형태를 반환했고, 음성 목록 API도 버전마다 구조가 달랐다. 그래서 응답을 그대로 쓰는 대신 하나의 내부 스키마로 정규화하는 계층을 만들었다. API가 바뀌더라도 화면 코드는 같은 구조만 바라보도록 분리한 것이다.
브라우저와 API 사이를 연결하는 계층
이번 작업의 핵심은 음성 합성 기술 자체가 아니라 브라우저와 API 사이의 차이를 연결하는 일이었다. 포맷과 용량, 로딩 방식, 응답 구조의 차이를 하나의 계층에서 흡수하자 화면 코드는 하나의 내부 스키마만 바라보면 됐다. 사용자에게는 "녹음하고 합성한다"는 단순한 흐름만 보였지만, 그 뒤에서는 서로 다른 규칙을 가진 시스템들이 하나의 인터페이스로 연결되어 있었다.
다음에 읽을 글

긴 영상을 한 번에 렌더하지 않은 이유
서버리스 렌더에는 약 10분의 실행 시간 제한이 있다. 긴 영상은 프레임을 청크로 나눠 병렬로 렌더하고 하나로 결합했다. 총 시간은 가장 느린 청크 수준으로 줄었고, 이 병렬성은 렌더를 프레임 번호의 결정적 함수로 설계해 둔 데서 공짜로 얻었다.

외부 API 응답은 왜 정규화 계층이 필요한가
외부 API의 응답 형태는 우리가 정하지 못한다. Content-Type을 진실로 삼아 응답을 세 갈래로 가르고, 봉투 네 종과 별칭 일곱 종을 하나의 스키마로 정규화하고, 상태코드는 사람이 행동할 수 있는 말로 번역했다. 통합의 품질은 어긋날 때 드러난다.

수십 MB WASM을 한 번만 받게 만들기
수십 MB WASM 엔진은 언제 받느냐와 몇 번 받느냐가 체감을 가른다. 변환이 필요한 순간에만 받고, 싱글톤과 Promise 공유로 동시 요청에도 다운로드는 한 번으로 막았다. 실패하면 공유 상태를 비워 다음 시도가 처음부터 다시 시작하게 했다.