저는 'Transcribe Video to Text'라는 Chrome 확장 프로그램을 만들었습니다. 이 프로그램은 재생 중인 모든 비디오(YouTube, 강의 플랫폼, 웨비나, Chrome에서 열린 로컬 파일 등)를 텍스트로 변환합니다. 인터뷰나 통화의 경우 누가 말하는지 레이블링(labeling)까지 해줍니다.
지난주에 이 확장 프로그램에 대한 YouTube 영상을 위해 짧은 데모 인터뷰를 녹화했습니다. 가상의 목소리 두 개, 즉 호스트인 Maya가 질문하고 게스트인 Daniel이 답변하는 방식이었습니다. 저는 전사본을 Word 파일로 내보냈는데, 호스트의 질문들이 게스트의 레이블 아래에 위치해 있었습니다. 단 한 번도 아니라, 계속해서 그랬습니다.
이는 서버 측 버그와 사이드 패널(side panel)의 버그 두 가지가 결합된 문제였으며, 제가 해결할 수 없었던 라이브 디아리제이션(live diarization)의 제한 사항이 있었습니다. 여기서는 Manifest V3에서 탭 오디오를 캡처하는 과정부터 레이블링까지 전체 과정을 설명합니다.
MV3에서 탭 오디오가 음성 모델에 도달하는 방법
MV3에는 백그라운드 페이지(background page)가 없으며, 서비스 워커(service worker)는 미디어 스트림을 유지할 수 없습니다. 따라서 캡처 과정은 세 곳에 걸쳐 분산됩니다.
1. 서비스 워커가 스트림 ID를 받습니다. 사용자가 사이드 패널에서 Transcribe를 클릭하면, 워커는 chrome.tabCapture.getMediaStreamId({ targetTabId })를 호출합니다. 이것은 사용자가 실제로 확장 프로그램을 실행한 탭에 대해서만 작동합니다. 만약 Chrome이 거부할 경우, 패널에는 원본 오류가 표시되지 않습니다. 대신 다음과 같은 메시지가 나타납니다: "이 탭을 전사하려면 상단 도구 모음의 Transcribe 아이콘을 클릭하세요. 이는 현재 탭에 오디오 접근 권한을 부여합니다."
2. 오프스크린 문서(offscreen document)가 ID를 오디오로 변환합니다. 워커는 chrome.offscreen.createDocument({ reasons: ['USER_MEDIA'], ... })를 사용하여 문서를 생성하고 스트림 ID를 전달합니다. 이 오프스크린 페이지는 chromeMediaSource: 'tab'과 해당 ID를 사용하여 getUserMedia를 호출합니다.
테스트 첫 분 동안 알아차릴 수 있는 한 가지 점은 탭을 캡처하는 과정에서 사용자에게 음소거(mute)가 걸린다는 것입니다. 이 문제를 해결하기 위해서는 캡처된 스트림을 스피커로 다시 라우팅해야 합니다:
audioCtx = new AudioContext();
audioCtx.createMediaStreamSource(mediaStream).connect(audioCtx.destination);
3. 미디어 레코더는 WebSocket을 통해 작은 청크를 전송합니다. Opus 코덱이 WebM 형식으로 32 kbps에서, 250ms마다 하나의 청크가 전송됩니다. 레코더는 제가 서버로부터 ready 응답을 받은 후에만 시작되는데, 이는 이미 음성 모델과의 연결이 열려 있다는 의미이므로 첫 단어들이 들어갈 곳이 생기는 것입니다.
여기에는 작은 함정이 있습니다. 첫 번째 청크는 약 220바이트 크기의 WebM 헤더이며, 태스크가 완전히 침묵하고 있을 때도 도착합니다. 저는 '첫 청크 수신'을 기준으로 '소리가 들어오고 있음' 표시등을 켜도록 구현했는데, 이로 인해 소리가 전혀 없는 비디오에서도 불이 켜지는 문제가 발생했습니다. 해당 세션에서 서버는 37초 동안 233바이트를 받았습니다. 이제 첫 번째 청크는 무시되며, 표시등은 실제로 오디오를 담고 있는 다음 청크를 기다립니다.
4. 서버가 Deepgram으로 오디오를 중계합니다. nova-3 모델을 사용하며, interim_results, smart_format, 그리고 diarize: true 설정을 통해 라이브 연결이 이루어집니다. Deepgram은 텍스트, 발화 끝의 is_final 플래그, 또 다른 발화 종료 시점의 speech_final 플래그, 그리고 단어 목록을 포함하는 메시지를 되돌려 보냅니다. Diarization(화자 분리) 기능이 활성화되면, 모든 단어에는 speaker 번호가 함께 전송됩니다.
버그 1: 전체 메시지에 하나의 레이블만 적용되는 문제
화자 정보는 메시지 단위가 아니라 각 '단어'에 존재한다는 점에 유의해야 합니다. 제 서버는 화자 정보를 가진 첫 번째 단어의 화자를 가져와서 그 레이블을 전체 메시지에 적용했습니다:
const speaker = alt.words?.find(w => w.speaker != null)?.speaker ?? null;
대부분의 경우 최종 메시지는 한 사람의 발화였기 때문에 이 방식은 괜찮아 보였습니다. 하지만 호스트가 짧은 질문을 하고 게스트가 즉시 답변하는 상황에서는, 하나의 최종 메시지에 두 화자의 목소리가 모두 담길 수 있습니다. 그 결과 전체 내용이 가장 먼저 말한 사람의 레이블로 통일되어 전송되었습니다.
해결책은 모든 최종 메시지를 동일한 화자가 연속적으로 발화한 단어들의 묶음(runs)으로 분할하는 것입니다. 화자 번호가 없는 단어는 현재 묶음에 합쳐집니다:
function splitBySpeaker(words) {
const runs = [];
for (const w of words || []) {
...
만약 여러 개의 묶음이 있다면, 각 묶음은 고유한 시작 시간, 지속 시간 및 화자 정보와 함께 클라이언트로 독립적인 최종 세그먼트로 전송됩니다. 중간 메시지(Interim messages)는 그대로 유지되며, 어차피 잠시 후 다른 내용으로 대체되기 때문입니다.
테스트는 다섯 가지 케이스로 구성되어 있습니다. 메시지 하나에 질문과 답변이 포함된 경우, 단일 화자, 임시 메시지(interim message), 화자 번호가 없는 단어, 그리고 아예 다이아라이제이션(diarization)이 적용되지 않은 응답입니다. 두 화자 케이스는 이전 코드에서는 실패했지만, 새로운 코드에서는 다섯 가지 모두 통과했습니다. 서버 수정 사항은 10월 18일에 라이브되었기 때문에, 웹 계정에 저장된 전사 기록들은 즉시 정확한 레이블을 받게 되었습니다.
버그 2: 사이드 패널이 단락을 너무 늦게 종료하는 문제
패널은 모든 최종 청크(final chunk)를 별도의 줄로 출력하지 않습니다. 대신, 청크들을 버퍼에 모으고 다음 조건 중 하나가 충족될 때 단락을 종료합니다. 텍스트가 문장을 끝내면서 길이가 80자를 초과하는 경우, 또는 250자를 초과하여 증가하는 경우, 또는 4개의 청크를 포함하는 경우, 혹은 화자가 변경되는 경우입니다. 이 단락은 첫 번째 청크의 레이블을 받습니다.
이 버그는 작업 순서(order of operations)에 있었습니다. 새로운 청크가 먼저 버퍼에 밀어 넣어지고(pushed), 그 후에야 코드가 화자 변경 여부를 확인했습니다. 따라서 화자가 바뀔 때, 게스트의 첫 문장이 이미 호스트의 버퍼 안에 들어와 있었고, 호스트의 레이블 아래에서 호스트 단락의 일부로 빠져나갔습니다. Word 내보내기 기능은 패널을 복사하므로, 문서 역시 같은 실수를 했습니다.
수정 사항은 지루하지만 그것이 핵심입니다. 화자가 변경되었다면, 먼저 이전 화자의 단락을 종료한 다음 새로운 청크를 밀어 넣어야 합니다.
speakerChanged && deepgramChunkBuffer.length && flushBuffer();
deepgramChunkBuffer.push({ text, start: start || 0, speaker: speaker ?? null });
// ...이후 일반적인 길이 및 문장 검사
저는 브라우저 테스트에서 데모 인터뷰를 재현했습니다. 사이드 패널 페이지를 로드하고, 동일한 순서의 전사 메시지를 리스너에 밀어 넣은 다음, DOM에서 단락들을 읽었습니다. 이전 코드에서는 두 가지 검사가 실패했고, 이는 제가 사용하던 Word 파일과 정확히 같은 방식이었습니다. 새로운 코드에서는 이들이 통과했습니다. 이 부분은 10월 21일 버전 1.3.5에 배포되었습니다.
수정 사항 확인 중 발견된 보너스 버그
TED 인터뷰에서 레이블을 확인하던 중, 서버가 speech_final이 포함된 메시지만 저장한다는 것을 알게 되었습니다. 긴 답변의 경우 발화(utterance)가 끝날 때까지 여러 개의 is_final 메시지가 생성될 수 있으며, 마지막 것을 제외한 모든 내용은 저장된 전사 기록에서 손실되었습니다. 한 녹음에서는 0:29부터 0:54까지의 구간이 단일 단어로 저장되었습니다. 라이브 패널에서는 정상적으로 표시되었기 때문에 7월 이후로 이 문제는 눈에 띄지 않았습니다.
이제 서버는 is_final 텍스트를 버퍼(buffer)에 유지하고, speech_final 시점이나 화자 변경 시, 또는 연결이 끊어질 때 이를 출력합니다.
해결할 수 없었던 한계: 라이브 디아리제이션(diarization)의 불안정성
같은 TED 인터뷰에서 또 다른 불만이 제기되었습니다. 전체 첫 1분이 단일 화자로 레이블링되었고, 두 번째 레이블은 문장 중간인 1:01에 나타났다는 것입니다. 이번에는 제 코드가 문제가 아니었습니다. Deepgram 자체가 60.6초까지 모든 구간을 speaker 0으로 보냈습니다.
저는 동일한 SDK, nova-3를 사용하여 디아리제이션(diarization) 기능을 활성화하고 영어로 설정한 후, 해당 강연의 80초 클립에 대해 작은 실험을 진행했습니다:
- 라이브 스트림 실행 1회: 화자 변경이 12.8초, 27.3초, 37.0초, 57.9초, 78.0초에서 발생함. 정확함.
- 라이브 스트림 실행 2회: 동일 파일, 동일 파라미터 사용 시: 12.8초부터 78.0초까지 계속 speaker 0으로 표시됨. 잘못되었으며, 사용자가 본 것과 정확히 일치함.
- 전체 파일을 사전 녹화 요청(prerecorded request)으로 전송: 정확했으며, 라이브 실행 두 번 모두 놓쳤던 17.3초의 짧은 답변까지 포착해냈습니다.
따라서 비슷한 목소리를 가진 두 화자의 경우, 레이블이 한 번의 실행에서는 완벽할 수 있지만 다음 실행에서는 단일 화자로 무너질 수 있습니다. 제가 보는 해결책은 세션 오디오를 서버 메모리에 유지하고, 사용자가 '정지(Stop)' 버튼을 누른 후에 디아리제이션 기능을 적용하여 전체 파일로 다시 전송한 뒤 저장된 세그먼트를 재레이블링하는 것입니다. 이는 세션당 음성 모델 분량을 대략 두 배로 늘리기 때문에 아직 구현되지 않았습니다.
제가 얻은 교훈
화자 분리(diarization)를 사용할 경우, 화자는 메시지 전체가 아니라 단어에 속합니다. 첫 단어만으로 전체 메시지를 레이블링하는 것은 빠르고 주고받는 대화(quick back-and-forth)에서 잘못된 레이블을 생성할 것입니다.
가독성을 위한 버퍼링에는 작업 순서(order of operations)가 있습니다. 다음 내용을 추가하기 전에 경계(boundary)를 닫아야 합니다.
합성된 두 목소리 인터뷰는 화자 레이블을 테스트하는 저렴하고 반복 가능한 방법입니다. 이 방법은 하나의 녹음에서 두 가지 버그를 모두 잡아냈습니다.
잘못된 레이블 때문에 코드를 탓하기 전에, 모델이 실제로 무엇을 전송했는지 기록(log)하세요. 제가 보고한 두 가지 "레이블 버그" 중 하나는 제 코드 문제가 아니라 라이브 모델 자체였습니다.
만약 직접 인터뷰나 강의에 적용해보고 싶다면, 확장 프로그램은 여기에 있습니다: Transcribe Video to Text. 자막이 있는 YouTube 동영상은 무료이며 무제한으로 사용할 수 있고, 다른 동영상의 AI 전사(AI transcription)는 월별 무료 할당량(free monthly allowance)을 제공합니다.