로컬 음성 전사 프로그램 만들기 (1)
의뢰인과 상담한 녹음파일을 글로 풀어주는 프로그램을 만들어보기로 했다.
녹취 자체는 이미 어려운 일이 아니다. 네이버 클로바노트 같은 서비스에 음성파일을 올리면 화자를 구분하고 시간까지 붙인 결과를 내준다. 문제는 올리는 음성이다. 상담 녹음에는 의뢰인의 이름, 사건 내용, 상대방과 나눈 대화가 그대로 들어 있다.
그래서 목표를 이렇게 잡았다.
브라우저에서 음성파일을 올리면 DGX Spark가 로컬에서 전사하고, 결과를 화면에서 확인하거나 내려받을 수 있게 한다.
이번에 만든 것은 그 화면 뒤에서 실제로 음성을 글로 바꾸는 부품이다. Docker 안에서 모델을 띄우고, WAV 파일 하나를 넣어 TXT와 JSON을 받는 데까지가 목표다. 이 부품이 돌아가야 업로드 버튼도 결과 화면도 의미가 있다.
Whisper, Raon-Speech, Qwen 중 무엇부터 돌릴까
후보는 Whisper large-v3, Raon-Speech-9B, Qwen3-ASR-1.7B 셋이었다.
Whisper는 음성인식 모델의 기준점처럼 널리 쓰이고 자료도 많다. Raon-Speech-9B는 한국어에 초점을 맞춘 모델이라 나중에 같은 자료로 비교해볼 만하다. 첫 시험에는 Qwen3-ASR-1.7B를 골랐다.
Qwen부터 고른 이유는 이렇다. 한국어를 공식 지원하고, 한 번에 최대 1,200초까지 처리하며, 1.7B 규모라 9B 모델보다 첫 구현의 부담이 작다. 로컬 실행용 공식 코드와 사용 예시도 공개되어 있다.
눈에 들어온 기능이 하나 더 있었다. Qwen3-ASR은 전사할 때 이름이나 전문용어 같은 어휘를 미리 건넬 수 있다. 흔히 ‘핫워드’라고 부르는 기능으로, Qwen 문서에서는 prompt를 이용한 context biasing으로 설명한다.
prompt = "Vocabulary: 포케, 퀴노아, 듀럼밀, 연두부."
이 문장을 함께 주면 해당 단어를 반드시 쓰게 되는 것은 아니지만, 비슷한 소리를 들었을 때 그 어휘를 우선 고려하게 된다. 법률 녹음이라면 의뢰인 이름, 회사명, 사건번호, 법률용어를 넣어두는 식이다. 모델을 다시 학습시키지 않고 사건별 용어를 건넬 수 있다는 점이 실용적으로 보였다.
다만 이번 첫 전사에는 이 기능을 넣지 않았다. 뒤에서 보겠지만 포케가 폭행으로, 퀴노아가 피노로 바뀌었다. 지금 결과는 용어 힌트 없는 기본값의 성적이고, 용어 힌트를 넣었을 때 얼마나 달라지는지는 다음에 같은 파일로 비교한다.
이번 단계에서 보는 것은 순수한 받아쓰기다. 누가 말했는지 구분하는 화자 분리와 몇 초에 말했는지 표시하는 타임스탬프는 아직 구현하지 않는다.
Docker로 전사 부품부터 만들었다
Qwen3-ASR은 파이썬에서 PyTorch로 모델을 직접 불러 실행한다. DGX Spark의 기존 환경과 분리하기 위해 엔비디아 PyTorch 컨테이너 nvcr.io/nvidia/pytorch:25.11-py3를 썼다.
구조는 단순하다.
- WAV 파일을 입력 폴더에 둔다.
- Docker 컨테이너가 Qwen3-ASR을 불러와 GPU로 전사한다.
- 결과를 TXT와 JSON으로 저장한다.
- 모델과 결과 폴더는 컨테이너 밖에 연결해 다음 실행에서도 그대로 쓴다.
웹페이지도, 업로드 버튼도, 진행률 표시도 아직 없다. 명령 하나로 파일 하나를 처리하는 단계다.
전사 코드에서 나중에 문제가 된 설정은 이것이다.
kwargs = {
"dtype": torch.bfloat16,
"device_map": "cuda:0",
"max_inference_batch_size": 8,
"max_new_tokens": 2048,
}
마지막 줄의 max_new_tokens는 모델이 만들어낼 수 있는 최대 출력 길이다. 이때는 2,048이면 충분해 보였다.
시험할 음성과 기준 전사문을 구하다
모델이 돌아간다고 내 상담 녹음부터 넣을 수는 없다. 결과가 맞는지 보려면 음성과 함께 기준이 되는 전사문이 있어야 한다.
AI Hub의 「감정이 태깅된 자유대화(성인)」에서 두 사람이 일상 대화를 나눈 한국어 음성을 받았다. 그중 약 19분짜리 파일 하나를 골랐다. 두 여성이 단백질 섭취량, 포케, 파스타, 칼로리 같은 이야기를 쉼 없이 주고받는다. 재생시간은 19분 3초였다.
음성 옆에는 같은 이름의 JSON 파일이 붙어 있다. 단순한 정답지인 줄 알았는데 열어보니 각 발화의 시작·종료 시각, 화자, 발화문, 녹음 환경에 더해 감정의 종류와 대상, 강도까지 들어 있었다. 이번에는 발화 내용과 시간만 쓴다.
첫 실행, 19분을 넣었는데 8분만 나왔다
첫 실행에서는 Docker 이미지와 ASR 모델을 내려받은 뒤 전사가 시작됐다. 결과의 전체 흐름은 알아볼 수 있었다. 하루 단백질 70~80그램, 600칼로리 미만, 무설탕 두유와 단백질 파우더 같은 내용이 잡혀 있었다.
잘못 들은 단어도 눈에 띄었다.
포케 → 폭행
퀴노아 → 피노
포만감 → 풍만감
루틴 → 루트
연어 포케가 연어 폭행이 되면서 문장이 갑자기 험악해졌다. 그런데 문제는 단어 몇 개가 아니었다. 결과의 끝이 이랬다.
어 그럼 나자 내가 근데 나는 진
문장이 끝난 것이 아니라 중간에서 잘렸다. 기준 JSON과 맞춰보니 8분 1초 지점이었다. 출력은 3,111자였고 오류 메시지는 없었다. 프로그램은 정상 종료했고 결과 파일도 만들었다.
코드를 다시 보자 유력한 원인이 하나 있었다.
"max_new_tokens": 2048
이 값은 음성을 몇 분까지 들을지 정하는 것이 아니다. 모델이 받아쓴 결과를 몇 토큰까지 내보낼지 정한다. 음성은 남아 있었지만 출력이 2,048토큰에서 끝난 것이다.
끝까지 못 갔어도 같은 구간끼리는 비교할 수 있다
Qwen의 첫 결과가 8분에서 잘렸으니 잘린 만큼만 놓고 비교하기로 했다. AI Hub 기준 전사문, Qwen, 클로바노트 세 가지에 모두 있는 앞부분만 맞췄다.
- AI Hub 기준 전사문: 1번부터 132번 발화까지(종료 시각 8분 1.09초)
- Qwen: 마지막의 불완전한
근데 나는 진제외 - 클로바노트: 7분 59초 블록까지 포함, 8분 1초 블록부터 제외
평가에는 CER(Character Error Rate, 문자 오류율)을 썼다. 기준 전사문을 다른 전사문으로 바꾸려면 문자를 몇 번 교체하고, 지우고, 넣어야 하는지 세는 방식이다.
CER = (교체 + 삭제 + 삽입 문자 수) / 기준 전사문의 문자 수
낮을수록 기준 전사문에 가깝다. 띄어쓰기와 문장부호 차이로 점수가 흔들리지 않도록 유니코드를 정규화하고 영문은 소문자로 통일한 뒤 공백과 문장부호를 제거했다. 한글·영문·숫자만 남긴 것이다.
| 전사 결과 | 기준문 문자 수 | 편집 거리 | 공백·문장부호 제거 CER |
|---|---|---|---|
| Qwen3-ASR-1.7B, 2,048토큰 | 2,417자 | 771자 | 31.90% |
| 네이버 클로바노트 | 2,417자 | 274자 | 11.34% |
이 구간에서는 클로바노트가 기준 전사문에 훨씬 가까웠다. Qwen의 CER이 약 20.6%포인트 높다.
다만 100에서 CER을 뺀 값을 곧바로 ‘정확도’라고 부르기는 어렵다. 기준문의 칠십을 70으로 적으면 뜻은 맞아도 오류로 세고, 기준문 자체의 오기도 점수에 들어간다. 같은 조건에서 두 결과가 기준문과 얼마나 떨어져 있는지 비교한 값으로 보는 편이 맞다.
점수 밖의 차이도 있었다. Qwen은 대화의 큰 주제는 따라갔지만 낯선 소리를 엉뚱한 단어로 바꾸거나 문장 일부를 흐트러뜨렸다. 클로바노트도 포케를 포켓, 점심 물가를 점심 물감, 낫토를 낙도로 적었지만 문장의 뼈대와 대화 흐름은 상대적으로 잘 지켰다. 화자와 시간 정보까지 붙여준다는 차이도 있는데, 이번 CER은 그 부분을 재지 않는다.
토큰을 네 배로 늘렸더니 해결됐을까
max_new_tokens를 2,048에서 8,192로 늘려 다시 전사했다.
두 번째 전사는 모델 로딩과 오디오 변환을 포함해 586.04초, 약 9분 46초가 걸렸다. 결과 파일은 11,684자, 28,116바이트로 첫 결과보다 훨씬 길었다.
처음에는 성공한 줄 알았다. 그런데 뒤쪽에 같은 문장이 이어졌다.
야 그거 꿀꿀이족 아니야
야 그거 꿀꿀이족 아니야
야 그거 꿀꿀이족 아니야
...
이 구절만 30번 반복됐다. 마지막은 다시 문장 중간이었다.
그 예민해진 애는 그냥 재료 보
결과 본문은 8,181토큰이었고 제어 토큰까지 더하면 8,192 한도에 닿은 것으로 보인다. 실행 오류도, GPU 메모리 부족도 없었다. 프로그램 입장에서는 이번에도 정상적으로 결과를 만들어 저장했다. 내용이 망가졌다는 사실만 모른다.
한도를 늘려서 반복이 생겼다고 단정할 수는 없다. 긴 음성을 한 번에 생성하던 모델이 어느 지점에서 반복에 빠졌고, 넓어진 출력 한도가 그 반복을 더 오래 이어갈 공간을 줬다고 보는 편이 맞다. 한도를 더 키운다고 전체 전사가 나온다는 보장은 없고, 같은 문장이 더 길게 이어질 수도 있다.
출력 한도는 모델의 정확도나 긴 음성을 버티는 능력을 늘려주는 값이 아니다. 결과를 쓸 수 있는 최대 길이만 늘려준다. 첫 실행에서는 그 길이가 모자라 문장이 잘렸고, 두 번째 실행에서는 길이는 넉넉했지만 모델이 같은 문장을 되풀이하며 그 길이를 다 써버렸다.
GPT와 Codex에는 이렇게 시켰다
여기까지의 과정에서 모델 비교는 GPT와 대화하며 했고, 실제 작업 폴더를 다루는 일은 Codex에 맡겼다. 어떤 지시를 어떤 순서로 줬는지 남겨둔다.
처음부터 세부 사양을 정해 한 번에 넣은 것은 아니다. 첫 요청은 단순했다.
DGX Spark에서 Qwen3-ASR-1.7B로
로컬 음성 전사 프로그램을 만들어보자.
본체 환경은 건드리지 않도록 Docker로 구성해줘.
Codex가 기본 구조를 만든 뒤 무엇이 생겼는지 확인했다. 음성파일을 넣는 폴더, 전사 스크립트, Docker 실행 설정, 결과 폴더가 만들어져 있었다. 실제 사용 방식을 생각하며 조건을 하나씩 붙였다.
WAV 파일 하나를 넣으면 전사 결과를 TXT와 JSON으로 저장해줘.
모델은 처음 한 번만 내려받고 다음부터는 다시 쓰게 해줘.
원본과 결과를 실수로 덮어쓰지 않도록도 고쳤다.
원본 음성과 기존 결과는 덮어쓰지 않게 해줘.
같은 이름의 결과가 있으면 새 이름으로 보존해줘.
뼈대가 만들어진 뒤에 시험 자료를 넣었다.
AI Hub에서 받은 WAV와 JSON 한 쌍으로 시험해보자.
우선 음성파일 하나만 전사하고,
걸린 시간과 결과 파일의 위치와 크기도 알려줘.
여기서 앞서 본 8분짜리 결과가 나왔다. 이번에는 수정을 맡기기 전에 원인부터 물었다.
결과가 문장 중간에서 끝났어.
출력 토큰 제한인지, 오디오 길이 제한인지,
실행 오류인지 코드를 보고 원인부터 확인해줘.
아직 파일은 수정하지 마.
원인이 max_new_tokens=2048로 좁혀진 뒤에야 한도를 어디까지 늘릴 수 있는지 물었고, 8,192로 바꿔 기존 결과를 남긴 채 다시 실행시켰다. 그 결과가 30번 반복된 두 번째 전사다.
max_new_tokens를 8192로 바꾸고 다시 전사해줘.
기존 결과는 남겨두고 새 파일로 저장해줘.
완성된 명령을 알고 시작한 것이 아니라 결과를 하나 보고 다음 조건을 붙이는 식이었다. 기존 결과를 덮어쓰지 않게 해둔 덕분에 2,048토큰의 결과와 8,192토큰의 결과가 모두 남아 비교할 수 있었다.
오늘은 여기까지 만들었다
음성파일 하나를 Qwen3-ASR에 넣어 TXT와 JSON으로 저장하는 로컬 전사 부품을 만들었다. AI Hub의 19분짜리 음성과 기준 전사문으로 시험했고, 같은 구간의 클로바노트 결과와 CER을 비교했다. 출력 토큰이 부족하면 멀쩡한 문장도 중간에서 잘리고, 토큰만 늘린다고 긴 전사가 안정되지는 않는다는 것도 확인했다.
남은 일은 19분을 한 번에 견디게 만드는 대신 음성을 3~5분 단위로 나누는 것이다. 앞뒤를 몇 초씩 겹쳐 자르고, 조각별로 전사한 뒤 겹친 부분을 제거해 합친다. 한 조각이 출력 한도에 닿거나 같은 문장을 반복하면 그 조각만 다시 처리하도록 감지 장치도 넣어야 한다. 분할 전사가 안정되면 포케, 퀴노아 같은 용어 힌트의 효과를 같은 파일로 비교하고, 그다음에 파일을 올리고 진행 상황을 보는 웹 화면을 얹는다.