8.2 음성 인식(STT) 파이프라인 - 오디오 분할, 자막 변환, 정확도 측정
긴 오디오를 무음 지점에서 나누는 청크 계획, 청크 결과를 파일 시간으로 되돌려 SRT로 만드는 변환, 띄어쓰기 영향이 큰 한국어에서 CER을 함께 보는 이유를 실행 결과와 함께 설명합니다.
#파이프라인에서 모델이 차지하는 자리
오디오 추출(16kHz 모노) → 청크 분할 → [STT 모델] → 청크별 (시작, 끝, 텍스트)
→ 시간 되돌리기(청크 시작 시각 더하기) → 후처리(맞춤법, 고유명사) → 저장 + 자막(SRT/VTT) + 검색 색인
모델은 가운데 한 칸뿐입니다. 나머지는 모두 사람이 설계하는 부분이고, 실제 장애는 대부분 여기서 납니다.
#왜 오디오를 나눌까
긴 오디오를 한 번에 모델에 넣으면 메모리와 처리 시간이 커지고, 실패했을 때 처음부터 다시 해야 합니다. 일정 길이(예: 30초 안팎)로 나누면 병렬 처리와 재시도가 쉬워집니다. 문제는 자르는 위치입니다. 단어 한가운데를 자르면 그 단어는 두 청크에서 모두 틀리게 인식됩니다.
그래서 무음 구간의 한가운데에서 자릅니다. FFmpeg의 silencedetect로 무음 구간을 찾고, 길이 한도를 넘지 않는 가장 마지막 무음에서 자르는 계획 함수입니다.
import re
import subprocess
def silences(path, db=-35, d=0.5):
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", path, "-af", f"silencedetect=noise={db}dB:d={d}", "-f", "null", "-"],
capture_output=True, text=True)
s = [float(x) for x in re.findall(r"silence_start: ([\d.]+)", r.stderr)]
e = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", r.stderr)]
return list(zip(s, e))
def plan_chunks(sil, total, max_len=30.0, min_len=5.0):
cuts = [(a + b) / 2 for a, b in sil] # 무음 구간의 한가운데에서 자른다
out, start = [], 0.0
while total - start > max_len:
cand = [c for c in cuts if start + min_len < c <= start + max_len]
cut = cand[-1] if cand else start + max_len # 무음이 없으면 어쩔 수 없이 강제 분할
out.append((start, cut))
start = cut
out.append((start, total))
return out
대사 대신 잡음 구간(9초, 11초, 8초, 14초, 12초)과 무음(1.2초, 0.8초, 1.5초, 1.0초)을 번갈아 이어 붙인 58.5초 합성 오디오로 실행했습니다.
silences [(9.0, 10.2), (21.2, 22.0), (30.0, 31.5), (45.5, 46.5)]
chunks [(0.0, 21.6, 21.6), (21.6, 46.0, 24.4), (46.0, 58.5, 12.5)] ← max_len=25
무음 없이 강제 분할: [(0, 25), (25, 50), (50, 58.5)]
- 무음의 한가운데(21.6초, 46.0초)에서 잘려 세 청크 모두 25초 이내입니다.
- 무음이 없는 오디오(배경음악이 계속 깔린 방송, 연속 대화)는 어쩔 수 없이 고정 길이로 자릅니다. 이때는 청크를 1~2초 겹치게 만들고, 겹친 구간의 중복 문장을 합칠 때 제거하는 방식을 씁니다.
-35dB,0.5초는 이 합성 오디오에 맞춘 값입니다. 배경음이 있는 실제 방송에서는 무음 기준(noise)을 올리거나 음성 구간 검출(VAD)을 쓰는 편이 안정적입니다.
#결과를 파일 시간으로 되돌리고 자막으로 만들기
모델이 돌려주는 시간은 청크 안의 시간입니다. 파일 기준 시간으로 바꾸려면 청크 시작 시각을 더해야 합니다. 이 한 줄을 빠뜨려서 두 번째 청크부터 자막이 전부 앞으로 당겨지는 사고가 흔합니다.
def srt_time(sec):
ms = round(sec * 1000)
h, ms = divmod(ms, 3600000); m, ms = divmod(ms, 60000); s, ms = divmod(ms, 1000)
return f"{h:02}:{m:02}:{s:02},{ms:03}"
def to_srt(segs, offset=0.0):
"""segs: [(start, end, text)] - 청크 내부 시간. offset: 청크의 파일 내 시작 시각."""
lines = []
for i, (a, b, t) in enumerate(segs, 1):
lines += [str(i), f"{srt_time(a + offset)} --> {srt_time(b + offset)}", t, ""]
return "\n".join(lines)
두 번째 청크(시작 21.6초)의 인식 결과가 청크 안 시간으로 0.5~3.2초, 3.4~6.0초였다고 가정하고 변환했습니다.
1
00:00:22,100 --> 00:00:24,800
광화문광장에서 가을 축제가 열렸습니다.
2
00:00:25,000 --> 00:00:27,600
시민 3만여 명이 방문했습니다.
0.5 + 21.6 = 22.1초로 정확히 환산됩니다. SRT는 시간 형식이 시:분:초,밀리초(쉼표)라는 점이 WebVTT(점)와 다릅니다. MAM에는 8장 1편의 밀리초 정수 구간으로 저장하고, 자막 파일은 필요할 때 생성하는 쪽이 관리하기 쉽습니다.
#정확도를 재는 법: WER과 CER
음성 인식의 정확도는 정답(사람이 받아 적은 대본)과 비교해 편집 거리로 잽니다.
- WER(Word Error Rate): 단어 단위.
(치환 + 삭제 + 삽입) / 정답 단어 수 - CER(Character Error Rate): 글자 단위
def edit_distance(a, b):
prev = list(range(len(b) + 1))
for i, x in enumerate(a, 1):
cur = [i]
for j, y in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (x != y)))
prev = cur
return prev[-1]
def wer(ref, hyp):
r = ref.split()
return edit_distance(r, hyp.split()) / len(r)
def cer(ref, hyp, ignore_space=True):
if ignore_space:
ref, hyp = ref.replace(" ", ""), hyp.replace(" ", "")
return edit_distance(ref, hyp) / len(ref)
한국어에서는 띄어쓰기 때문에 WER이 비정상적으로 커질 수 있습니다. 같은 문장을 띄어쓰기만 다르게 적은 경우입니다.
정답: 광화문광장 가을 축제가 개막했습니다
인식: 광화문 광장 가을 축제가 개막 했습니다
WER = 1.000 (단어 4개가 모두 틀린 것으로 계산)
CER(공백 무시) = 0.000
CER(공백 포함) = 0.105
글자는 하나도 틀리지 않았는데 WER은 100%입니다. 한국어 평가에서는 공백을 무시한 CER을 주 지표로 쓰고, 띄어쓰기 품질은 별도로 평가하는 편이 실제 품질을 잘 반영합니다. 한 글자만 틀린 문장(개막했습니다 → 계막했습니다)은 WER 0.25, CER 0.062로, 단어 단위 지표가 오류를 크게 부풀립니다.
#방송 소재에서 자주 부딪히는 문제
| 문제 | 증상 | 대응 |
|---|---|---|
| 고유명사·지명·인명 | 비슷한 소리의 다른 단어로 인식 | 사전·용어집으로 후처리 보정, 모델이 지원하면 힌트 단어 제공 |
| 숫자·단위 표기 | 3만, 삼만, 30000이 섞임 |
표기 규칙을 정해 후처리로 통일 |
| 겹치는 발화·배경음악 | 누락이나 엉뚱한 문장 | 분리된 트랙이 있으면 대사 트랙만 입력, 신뢰도 낮은 구간은 검수 큐로 |
| 외국어 섞임 | 영어 단어가 한글 음차로 변환 | 언어 지정·감지 옵션 확인, 후처리 사전 |
| 무음에서의 환각 | 말이 없는 구간에 문장이 생성됨 | 무음 구간은 모델에 넣지 않음(청크 분할 단계에서 제외) |
#정리
- 모델은 파이프라인의 한 칸이고, 분할·시간 환산·정확도 측정이 품질을 좌우합니다.
- 오디오는 무음 구간의 한가운데에서 나누고(길이 한도 안에서), 무음이 없으면 겹치게 자릅니다.
- 청크 안 시간에 청크 시작 시각을 더해 파일 시간으로 되돌립니다.
- 한국어는 띄어쓰기 영향이 커서 WER과 함께 공백을 무시한 CER을 보고, 정답 대본으로 자기 소재에서 직접 측정합니다.
#참고 자료
링크는 2026-10-28에 열람이 되는 것을 확인했습니다. 규격과 도구 문서는 개정될 수 있으므로 적용하기 전에 최신 판을 확인하세요.