로컬 음성 전사 프로그램 만들기 (2)
지난 글에서는 19분짜리 음성을 Qwen3-ASR에 한 번에 넣었다가 두 번 실패했다.
처음에는 출력 한도가 2,048토큰이라 8분쯤에서 문장이 잘렸다. 한도를 8,192토큰으로 늘리자 이번에는 같은 문장을 서른 번 반복한 뒤 다시 중간에서 끝났다. 출력 칸을 넓혀준다고 모델이 긴 음성을 더 잘 이해하는 것은 아니었다.
그래서 방향을 바꿨다.
19분을 한 번에 버티게 하지 말고, 짧고 안정적인 조각으로 나눠 전사한 뒤 다시 합치자.
그렇게 시작한 작업은 분할 전사에서 끝나지 않았다. 시간 정보가 붙었고, 목소리별로 화자가 나뉘었고, 원음을 들으며 결과를 고치는 웹 화면이 생겼다. 마지막에는 로컬 LLM이 문장부호와 화자를 보정하고, 전사문을 요약하거나 그 내용에 관해 대화하는 기능까지 들어갔다.
이번 글은 명령줄에서 WAV 하나를 처리하던 부품이 실제로 쓸 만한 로컬 웹앱이 된 과정이다.
완성된 첫 화면은 이렇게 생겼다.

오른쪽 상자에 녹음 파일을 올리면 전사, 타임스탬프, 화자 구분을 차례로 처리한다. 아래에는 이전 작업이 전사 기록으로 남는다. 처음 목표였던 “브라우저에서 파일을 올리고 결과를 확인한다”는 문장이 실제 화면이 됐다.
3분씩 자르고 5초를 겹쳤다
긴 음성을 나눌 때 가장 먼저 정해야 하는 것은 조각의 길이다. 너무 길면 처음과 같은 반복 문제가 생길 수 있고, 너무 짧으면 모델을 호출하는 횟수가 늘고 문맥이 자주 끊긴다.
이번에는 한 조각을 3분으로 정했다. 19분 3초짜리 시험 파일은 모두 7조각이 됐다.
조각을 정확히 3분 경계에서 자르기만 하면 문제가 하나 생긴다. 사람이 2분 59초부터 3분 2초까지 말한 문장은 중간에서 잘린다. 그래서 각 조각의 앞뒤를 5초씩 겹쳤다.
1번 조각: 00:00 ~ 03:00
2번 조각: 02:55 ~ 05:55
3번 조각: 05:50 ~ 08:50
...
겹친 구간은 양쪽 조각에 두 번 들어간다. 조각별 결과를 그대로 이어 붙이면 같은 말이 반복되므로, 인접한 결과의 끝과 시작에서 일치하는 단어열을 찾아 한쪽을 제거했다.
이 방식의 장점은 단순하다. 한 조각에서 문제가 생겨도 19분 전체를 다시 추론할 필요가 없다. 모델이 감당해야 하는 출력 길이도 짧아져, 긴 생성 도중 같은 문장에 빠지는 현상이 줄었다.
max_new_tokens=8192는 그대로 두었다. 이제 이 값은 19분 전체를 담는 그릇이 아니라 3분짜리 조각 하나에 충분히 넉넉한 상한선이 됐다.
전사는 끝났지만 누가 언제 말했는지는 몰랐다
분할 전사로 음성 마지막 부분까지 글이 나왔다. 다음 문제는 시간과 화자였다.
Qwen3-ASR이 만든 본문만으로는 특정 문장이 음성의 몇 분 몇 초에 있었는지 알 수 없다. 그래서 Qwen3 ForcedAligner를 붙였다. 이미 전사된 문장과 원음을 비교해 각 단어가 시작하고 끝난 시각을 맞추는 모델이다.
시험 결과에는 단어 2,539개의 시작·종료 시각이 붙었다. 이제 화면에서 02:06을 누르면 “연어 포케”를 말한 부분으로 바로 이동할 수 있다. SRT 자막을 만들 수 있는 것도 이 시간 정보 덕분이다.
화자 구분에는 NVIDIA Sortformer를 사용했다. 여기서 중요한 점은 화자 분리가 대화의 내용을 읽고 “이 말은 의뢰인이 했을 것 같다”고 판단하는 작업이 아니라는 것이다. 목소리의 높이, 음색, 발음 습관 같은 음향 특징을 비교해 비슷한 목소리를 같은 사람으로 묶는다.
흐름은 다음과 같다.
- Qwen3-ASR이 음성을 글로 바꾼다.
- ForcedAligner가 단어별 시간을 붙인다.
- Sortformer가 시간대별 목소리를
SPEAKER_00,SPEAKER_01처럼 나눈다. - 단어 시각과 화자 구간을 겹쳐 각 발화의 화자를 정한다.
화자가 바뀌는 바로 그 순간에는 두 사람의 말이 겹치거나 짧은 맞장구가 끼어 있어 애매할 수 있다. 그런 발화는 억지로 정하지 않고 UNKNOWN으로 남겼다. 중요한 상담이라면 틀린 화자를 자신 있게 붙이는 것보다 미지정 상태로 검수를 요청하는 편이 낫다.
같은 8분 구간의 CER은 31.90%에서 15.02%로 내려갔다
전편과 같은 AI Hub 기준 전사문으로 최종 분할 결과의 CER을 다시 계산했다. 유니코드를 정규화하고 영문을 소문자로 바꾼 뒤 공백과 문장부호를 제거하는 조건도 같게 맞췄다. 공정한 비교를 위해 두 결과에 모두 존재하는 앞 8분 1초 구간만 사용했다.
| 전사 결과 | 공백·문장부호 제거 CER |
|---|---|
| 19분을 한 번에 처리, 2,048토큰 | 31.90% |
| 3분 분할·5초 중첩 전사 | 15.02% |
같은 구간의 CER이 절반 이하로 줄었다. 단순히 끝까지 나온 것뿐 아니라 전편에서 비교했던 앞부분도 기준문에 더 가까워졌다. 참고로 19분 전체 최종 결과의 CER은 15.94%였다.
물론 15.94%를 그대로 “정확도 84.06%”라고 부르기는 어렵다. 기준문이 칠십이라고 쓴 것을 모델이 70이라고 적으면 뜻은 같아도 오류로 계산된다. 화자 구분의 정확도도 이 숫자에는 들어 있지 않다. 그래도 같은 음성과 같은 정규화 조건에서 비교했으므로, 긴 음성을 한 번에 생성하는 것보다 분할 전사가 훨씬 안정적이었다는 사실은 확인할 수 있다.
재미있는 변화도 있었다. 처음에 폭행으로 들었던 포케가 최종 결과에서는 제대로 포케로 나왔다. 반면 말하기를이 마라기를로 적히는 식의 오류는 남았다. 음향 모델의 결과는 좋아졌지만 완벽해진 것은 아니었다.
명령줄 도구를 웹앱으로 바꾸기 시작했다
전사가 안정되자 다음 질문은 “결과를 어떻게 검수할 것인가”였다.
TXT를 열고 틀린 문장을 발견해도 원음의 위치를 다시 찾으려면 불편하다. 화자 이름을 바꾸거나 발화 두 개를 합치려면 JSON을 직접 수정해야 한다. 결국 전사 정확도만큼 중요한 것은 검수 화면이었다.
그래서 로컬 웹페이지를 만들었다.

화면 왼쪽에는 원음 재생과 검수 도구를 두고, 오른쪽에는 시간순 발화를 배치했다. 각 발화에서 할 수 있는 일은 다음과 같다.
- 타임스탬프나 재생 버튼을 눌러 해당 원음 듣기
- 발화문 직접 수정
- 드롭다운으로 화자 변경
- 화자 이름을 한 번 바꿔 전체 발화에 반영
- 인접한 발화 합치기와 커서 위치에서 나누기
- UNKNOWN 발화만 모아 검수
- 검색과 바꾸기
- TXT, SRT, JSON으로 내보내기
업로드와 전사 기록
처음에는 WAV 하나만 받았지만 웹앱에서는 아이폰과 갤럭시 녹음도 바로 올릴 수 있게 했다. WAV 외에 M4A, MP3, FLAC, AAC, OGG, OPUS와 음성이 들어 있는 MP4를 받는다. 서버에서는 FFmpeg로 16kHz·모노 WAV로 통일한 뒤 같은 전사 파이프라인에 넣는다.
파일을 여러 개 선택하거나 한꺼번에 끌어다 놓을 수도 있다. 1번 파일이 끝나면 2번, 그다음 3번을 처리하며 각각 독립된 전사 기록으로 남는다. 파일마다 최대 4GB까지 받지만, GPU 충돌을 피하기 위해 실제 추론은 한 번에 하나씩 진행한다.
업로드할 때 이름, 전문용어, 사건번호를 쉼표로 적는 인식 참고 용어 칸도 만들었다.
홍길동, 형사소송법, 피의자, 2026고단1234
이는 모델을 다시 학습시키는 기능이 아니다. 비슷한 소리를 들었을 때 이 단어를 우선 고려하도록 Qwen3-ASR의 context biasing에 힌트를 주는 방식이다.
완료·진행·실패한 작업은 첫 화면의 전사 기록에 남는다. 완료된 작업은 다시 열 수 있고, 원본 음성으로 재실행하거나 필요 없는 기록을 삭제할 수 있다. 홍길동, 회의, 형사사건처럼 태그를 붙이면 태그별로 기록을 걸러볼 수도 있다. 태그는 첫 화면뿐 아니라 편집 화면과 결과 활용 화면에서도 고칠 수 있다.
원음을 들으며 고치는 도구
오디오 전체를 재생하는 것 외에도 5초 앞뒤 이동, 0.75배부터 2배까지의 배속, 선택한 발화 반복을 넣었다. 재생 위치 따라가기를 켜면 현재 들리는 발화가 강조되고 화면도 그 위치를 따라간다. 타임스탬프를 누르면 긴 녹음에서 해당 문장만 바로 확인할 수 있다.
화자 이름을 바꾸면 그 화자의 모든 발화에 반영된다. 모델이 세 사람 중 두 사람만 찾았다면 사용자가 화자를 추가하고 개별 발화의 드롭다운에서 새 화자를 고를 수 있다. 사용자가 추가한 화자가 필요 없어지면 삭제할 수도 있다. 반대로 UNKNOWN은 한꺼번에 이름을 바꾸지 않고 발화별로 배정한다.
검색·바꾸기는 긴 전사문에서 반복된 오인식을 손으로 고칠 때 유용하다. 다음 결과로 이동해 하나씩 바꾸거나 전체를 한 번에 바꿀 수 있다. 합치기는 현재 발화와 바로 다음 발화를 묶고 화자는 위 발화를 따른다. 나누기는 문장 안의 커서 위치를 기준으로 하나의 발화를 둘로 분리한다.
처음에는 합치기와 나누기 버튼을 화면 위에 뒀다. 그런데 대화록은 아래로 길게 내려가는데 버튼을 누르려면 매번 위로 올라가야 했다. 기능은 있지만 쓸 수 없는 배치였다. 결국 각 발화 오른쪽에 ▶, +, ÷ 버튼을 항상 표시했다.
사소해 보이는 부분도 실제로 써보니 계속 걸렸다. 다크 모드에서 흰 카드가 눈부셨고, 라이트 모드에서는 화자별 색이 잘 구분되지 않았다. 안내문이 많으면 화면이 난잡했고, 버튼에 한글을 모두 적으면 대화보다 도구가 더 눈에 띄었다. 기능을 넣는 것보다 덜어내고 재배치하는 시간이 더 길었다.
편집 내용은 마지막 입력 후 1.5초가 지나면 자동 저장된다. 원본 result.json은 그대로 두고 사용자가 고친 내용은 edit.json에 따로 저장한다. 실행취소와 되돌리기는 최근 수정 이력을 오가며, 실수로 AI 보정을 눌러도 직전 상태로 돌아갈 수 있게 했다.
저장 상태는 저장 대기, 저장 중…, 자동 저장됨, 저장 실패로 표시한다. Ctrl+S로 즉시 저장하고, 입력창 밖에서는 Ctrl+Z와 Ctrl+Shift+Z로 실행취소와 되돌리기를 할 수 있다. 합치기와 나누기에도 Alt+M, Alt+Enter 단축키를 붙였다.
내보낼 파일 이름은 원본 녹음 이름에 고정하지 않고 사용자가 바꿀 수 있게 했다. 전사문은 TXT, 자막은 SRT, 시간·화자 정보를 포함한 구조화 결과는 JSON으로 받는다. AI 보정을 실행했다면 무엇이 달라졌는지 적은 보정 내역 MD도 같은 메뉴에 나타난다.
라이트 모드와 다크 모드를 모두 만들고 선택은 브라우저에 저장했다. 특히 화자 색은 장식이 아니라 검수 도구이므로 두 모드에서 서로 충분히 구분되도록 배경, 왼쪽 선, 시간과 화자 선택창에 같은 색 계열을 반복했다. UNKNOWN은 어느 화자 색과도 혼동되지 않는 회색 계열로 따로 표시했다.
문장부호 보정과 UNKNOWN 배정에는 로컬 LLM을 썼다
음성인식 모델은 들리는 소리를 글자로 바꾸는 데 집중한다. 대화체의 띄어쓰기와 문장부호는 어색할 수 있고, 아주 짧은 맞장구는 목소리만으로 화자를 정하기 어렵다.
이 부분에는 호스트에서 이미 실행하던 Ollama의 qwen3.8:27b를 연결했다. 버튼은 세 개로 나눴다.
- 문장부호·띄어쓰기 보정: 발화 의미와 순서를 유지하면서 읽기 좋게 다듬는다.
- UNKNOWN 보정: 앞뒤 대화와 이미 정해진 화자를 참고해 미지정 발화를 배정한다.
- 올인원 보정: UNKNOWN 배정 후 문장부호와 띄어쓰기를 함께 고친다.
여기서 UNKNOWN 보정은 처음의 음향 기반 화자 분리와 원리가 다르다. Sortformer는 목소리를 분석하고, LLM 보정은 대화 맥락을 읽는다. 예컨대 질문 다음의 답변이나 자기 경험을 이어 말하는 흐름이 명확한 경우에는 문맥만으로도 짧은 음, 어의 화자를 추정할 수 있다.
그렇다고 LLM에게 전사 오류까지 마음껏 고치게 하지는 않았다. 마라기를이 문맥상 말하기를처럼 보여도, 문장을 자연스럽게 만들라는 지시를 넓게 주면 고유명사나 숫자, 법률적 표현까지 바꿀 위험이 있다. 그래서 기본 보정의 범위는 문장부호와 띄어쓰기로 제한했다.
보정 후에는 무엇이 바뀌었는지 Markdown 보고서로 내려받을 수 있다.
## 00:31 · 가윤
- 이전: 그런데 자주 먹는 사람 들이 ...
- 이후: 그런데 자주 먹는 사람들이 ...
UNKNOWN을 누구에게 배정했는지도 시간, 변경 전후 화자, 발화문과 함께 남긴다. AI가 조용히 원문을 바꾸게 하지 않고 사람이 나중에 검토할 흔적을 만드는 것이 중요했다.
처음에는 “보정된 발화가 몇 개”라는 숫자만 보여주면 충분할 줄 알았다. 하지만 20개가 바뀌었다는 사실보다 어디가 어떻게 바뀌었는지가 더 중요했다. 화면에는 완료 메시지만 짧게 띄우고, 상세 비교는 Markdown 파일로 분리한 이유다. 기능을 계속 한 화면에 쌓아 복잡하게 만드는 대신 필요한 사람만 보고서를 열게 했다.
전사문을 읽는 데서 끝내지 않고 결과로 만들었다
상담 녹음을 전사하는 목적은 대화록 자체를 감상하기 위해서가 아니다. 무슨 이야기를 했는지 파악하고, 해야 할 일과 일정을 뽑아 실제 업무에 쓰기 위해서다.
결과 활용 화면에서는 전사문을 로컬 LLM에 보내 다음 항목으로 정리한다.
- 핵심 요약
- 주요 내용
- 할 일
- 일정
- 확인 필요 항목
정리 유형은 일반 통화, 법률 상담, 회의 중에서 직접 고를 수 있고, AI 자동 판별도 가능하게 했다. 매번 사용자가 카테고리를 정하게 하는 것보다 우선 전체 대화를 읽고 유형을 판단하게 하는 방향이 편했다.

요약문 옆에는 작은 근거 표시가 붙는다. [02:06]을 누르면 그 내용을 말한 원음으로 이동한다. 요약이 그럴듯하다는 이유만으로 믿는 대신, 필요한 부분을 바로 확인할 수 있게 한 것이다.
오른쪽에는 해당 전사만을 대상으로 하는 AI 대화창을 넣었다.
가현이의 의지를 느낄 수 있는 부분을 알려줘.
핵심 요약을 한 줄로 줄여줘.
누락된 일정을 추가해줘.
AI는 전사문과 현재 정리문을 함께 참고해 답한다. 단순한 질문뿐 아니라 정리문을 수정하거나 재구성해달라고 요청할 수도 있다. 다만 대화에서 제안된 내용이 자동으로 최종 문서를 덮어쓰지는 않는다. 사용자가 확인한 뒤 반영 버튼을 눌러야 최종 정리가 바뀐다.
최종 문서는 Markdown과 TXT로 받을 수 있고, 전사문과 정리를 합친 문서나 전체 AI 대화 기록도 Markdown으로 내보낼 수 있다.
정리문 자체도 자동 저장되며 직접 Markdown으로 고칠 수 있다. AI 답변은 복사할 수 있고, 새 대화 버튼으로 해당 전사의 대화 기록을 비울 수 있다. 요약문과 대화 내용을 화면에서 함께 보는 이유는, 처음 생성된 정리를 대화를 통해 계속 다듬은 뒤 최종본으로 확정하기 위해서다.
모든 처리는 여전히 DGX Spark 안에서 끝난다
기능이 늘었지만 처음 세운 원칙은 바뀌지 않았다.
상담 녹음과 전사 결과를 외부 서비스에 보내지 않는다.
음성 전사, 타임스탬프, 화자 구분은 Docker 컨테이너 안의 GPU 모델이 처리한다. 문장 보정, UNKNOWN 추정, 요약과 AI 대화는 같은 장비에서 실행되는 Ollama가 맡는다. 원본 음성, 미리듣기 파일, 전사 결과, 수정본, 요약과 대화 기록도 프로젝트의 data/ 아래에 저장된다.
Docker를 사용한 이유도 여기에 있다. DGX Spark의 NVIDIA 드라이버, CUDA, 시스템 Python, 기존 Ollama와 Open WebUI 설정을 바꾸지 않고 프로젝트에 필요한 패키지만 별도 이미지에 넣었다.
다만 “로컬”이 곧 “보안 완성”을 뜻하지는 않는다. 현재 웹앱에는 자체 로그인과 HTTPS가 없다. 8090 포트를 인터넷에 그대로 열면 안 되고, 신뢰할 수 있는 내부 네트워크나 별도의 VPN·인증 프록시 안에서 써야 한다. 공개 저장소에서도 이 제한을 분명히 적었다.
이번에도 Codex와 대화하면서 만들었다
이번 작업 역시 완성된 설계서를 한 번에 준 것이 아니다. 실제 화면을 쓰면서 불편한 점을 하나씩 말했다.
3분으로 하고 경계 겹치는 건 5초 정도로 해서
분할해서 전사하는 걸 만들어줘.
분할 전사가 끝나자 시간과 화자가 필요해졌다.
타임스탬프도 만들어줘.
그다음 발화자 구분도 구현해보자.
웹 화면을 만든 뒤에는 기능보다 사용 과정에 관한 요청이 많아졌다.
결과 글을 클릭하면 그 부분 소리가 재생되게 해줘.
합치기 버튼이 위에 있으면 계속 화면을 왕복해야 해.
각 발화에 인라인 버튼으로 넣어줘.
새 전사를 해도 기존 결과가 기록에 남게 해줘.
AI 기능도 같은 식으로 자랐다.
문장부호와 띄어쓰기 보정 버튼을 넣어줘.
UNKNOWN 화자를 문맥으로 추정하는 버튼도 만들자.
둘을 한 번에 하는 올인원 버튼도 있으면 좋겠어.
전사마다 요약 결과와 AI 대화를 같이 볼 수 있게 해줘.
스크린샷을 보여주며 “다크 모드인데 흰 부분이 눈부시다”, “화자 색 차이가 잘 안 보인다”, “대화창 아래가 잘렸다”고 말하면 Codex가 CSS와 구조를 찾아 고치고 Docker를 다시 올렸다. 기능 명세보다 실제 사용 장면을 말하는 편이 결과가 더 빨리 좋아졌다.
GitHub에도 공개했다
프로젝트는 rocklassic/local-transcriber-stage1에 공개했다.
공개하기 전에는 저장소에 들어갈 파일을 다시 점검했다. 약 6.6GB의 모델과 실제 음성·전사 결과는 .gitignore에서 제외했다. .env, 키 파일, 로컬 작업 기록도 올라가지 않게 했다. 공개된 것은 소스 코드, Docker 설정, 테스트, 설명서와 AI Hub 공개 데이터로 만든 예시 화면뿐이다.
README에는 설치 방법만 쓰지 않고 AI 보정과 결과 활용의 동작, 현재 한계, 로컬 네트워크에서만 사용해야 하는 이유도 적었다. Docker 이미지 빌드, Python과 JavaScript 문법 검사, 12개 웹 테스트를 통과한 뒤 첫 커밋을 올렸다.
이제 남은 것은 여러 전사를 함께 묻는 기능이다
현재 AI 대화는 전사 하나를 대상으로 한다. 특정 상담을 열고 “상대방이 약속한 날짜가 언제였지?”라고 묻는 데에는 충분하다.
다음 단계는 여러 전사를 한꺼번에 찾는 기능이다. 의뢰인 이름이나 사건 태그로 기록을 묶고, 필요한 부분만 검색해 LLM에 근거로 넣는 RAG를 붙일 생각이다. 그러면 다음과 같은 질문이 가능해진다.
홍길동 의뢰인과 통화한 내용을 날짜순으로 정리해줘.
이번 주에 내가 하기로 한 일을 모든 통화에서 찾아줘.
같은 사건에서 상대방의 입장이 바뀐 부분을 비교해줘.
여기에 갤럭시 통화 녹음이나 일반 녹음 폴더를 정해진 시간에 자동 업로드하는 흐름까지 붙이면, 녹음하고 잊어버린 대화가 검색 가능한 개인 기록으로 바뀐다.
일이 처음 생각한 것보다 커졌다. 하지만 음성파일 하나를 TXT로 바꾸던 명령에서 시작해, 이제는 화자와 시간을 나누고 원음을 들으며 고치고 요약과 질문까지 하는 웹앱이 됐다.
다음에는 이 전사 기록들을 SQLite에 저장하고 태그와 검색을 이용해 RAG로 묶는 과정을 정리해보려 한다.