07_음성분석 처리 과정 및 결과
들어가며
음성을 글자 변환 (Speech to Text) 하는 과정을 구체적으로 명시한 글이다.
json 으로 정형화 하는 과정을 보여준다.
음성 분석 과정을 가장 쉽게 요약하면 아래와 같다.
- 잡음 제거 (Denoise)
- 사람 말하는 구간 탐색 (VAD)
- 화자 분리 (Pyannotate)
- 어떤 언어인지 탐지 (LID)
- 음성 인식 (ASR)
최적화를 위한 여러 라이브러리가 존재하는 것을 파악하였고, 최종 Flow를 아래와 같이 하였다.
# worker-prep_stt/main.py
run()
├ 0) _load_audio 오디오 로드 및 잡음 제거 (denoise)
├ 1) vad.detect 발화 구간 (Silero VAD)
├ 1b) speaker.diarize 화자 구분 (pyannote)
├ 2) _classify_languages 구간별 언어 판정 (LID)
├ 3) _transcribe_batched ◀── 여기가 진짜 음성→텍스트 (ASR)
├ 4) _assemble_segments 단어→문장 재조립 + 화자 매핑
└ 5) json 저장
1. 잡음 제거 (DeepFilterNet v3)
- BGM/효과음/군중 잡음 제거 → ASR 환각 감소. 길이는 변하지 않음
DEEPFILTER_MODEL = "deepfilternet3"
ATTEN_LIM_DB = -30 # -30=음성 보존 잡음 감쇠 상한(dB). None=full power,
CHUNK_SEC = 30 # 긴 오디오 청크 길이 (DF 스펙트로그램 VRAM 한계)
## lib/audio/denoise.py
def process(audio_np: np.ndarray, sr: int) -> tuple[np.ndarray, int]:
...
audio = torch.from_numpy(audio_np)
if audio.ndim > 1:
audio = audio.mean(dim=1) # → mono
audio = audio.unsqueeze(0) # [1, T]
if sr != df_sr:
audio = F.resample(audio, sr, df_sr)
total = audio.shape[-1]
chunk = CHUNK_SEC * df_sr
# === 30초를 청크 시간 설정 뒤 진행 ===
if total <= chunk:
# 실제 잡음 제거 실행 함수 <- 이 부분에서 잡음 제거 진행
enhanced = enhance(model=_model, df_state=_df_state, audio=audio,
pad=True, atten_lim_db=ATTEN_LIM_DB)
else:
n = (total + chunk - 1) // chunk
log.info(f"denoise chunked: {total / df_sr:.1f}s → {n} chunks of {CHUNK_SEC}s")
parts = []
for s in range(0, total, chunk):
parts.append(enhance(
model=_model, df_state=_df_state, audio=audio[..., s:s + chunk],
pad=True, atten_lim_db=ATTEN_LIM_DB,
))
enhanced = torch.cat(parts, dim=-1)
return enhanced.squeeze(0).cpu().numpy().astype(np.float32), df_sr
-
DF (Deepfilternet) 가 원하는 sr (sampling rate) 48 k 이기에, 임시로 16k → 48k 변환.
-
추후 호출 시 16k 다운 샘플 진행 필요
-
ASR (transcribe) 시에만 쓰임. LID , VAD 구간에서 쓰면 안됨.
-
-
오디오가 길면 스펙트로그램이 커져서 GPU OOM 가능.
-
CNUNK_SEC = 30 설정 (한 조각의 길이가 최대 30초)
-
chunk = 30 x 48000 (30초를 샘플 수로 환산한 값)
-
결론
-
값을 키우면 조각당 처리량은 오르나, VRAM 이 위험하다
-
값을 줄이면 조각당 초리량은 내리나, VRAM 이 안전하다
-
-
-
예시 입출력
입력: [·♪♪·말♪♪♪·♪·말말·♪♪♪·] ← 잡음(♪) 섞인 소리 배열
출력: [···말·····말말······] ← 잡음만 걷어낸 소리 배열
2. 발화 구간 추출 (VAD)
- 사람이 말하는 부분만 추출
model="silero_vad"
# lib/audio/vad.py
def detect(audio_np: np.ndarray, sr: int = 16000) -> list[tuple[float, float]]:
"""발화 구간 타임스탬프 추출.
audio_np : float32 1D numpy (16kHz 권장)
sr : 샘플레이트 (Silero VAD 는 16k / 8k 만 공식 지원)
Returns: [(start_sec, end_sec), ...] — 발화 구간 (정렬됨)
"""
audio_t = torch.from_numpy(audio_np).float()
ts_list = _get_speech_timestamps(
audio_t, _model, sampling_rate=sr,
min_speech_duration_ms=int(MIN_SPEECH_S * 1000),
max_speech_duration_s=MAX_SPEECH_S,
min_silence_duration_ms=int(MIN_SILENCE_S * 1000),
)
return [(t["start"] / sr, t["end"] / sr) for t in ts_list]
- 사람이 말하는 구간을 찾아내고, 샘플링 레이트를 16kHz 로 설정
- 예시 입출력
입력: [침묵····말·······침묵··말말····침묵] ← 소리 배열 # np.ndarray (float32 1D)
출력: [(2.1,5.8), (40.3,47.2), ...] ← 말하는 구간 시각만 # list[tuple[float, float]]
3. 화자 분리
- 영상에는 다양한 사람이 나오고, 그 사람들의 목소리는 당연히 다르다.
- 이번엔 화자를 구분 시키는 부분 이다. 오픈소스 라이브러리. pyannotate 도입하였다.
PYANNOTE_DIARIZE = "pyannote-diarization" # 화자 구분 (community-1, self-contained)
# lib/audio/speaker.py
def diarize(audio_np: np.ndarray, sr: int = 16000) -> list[tuple[float, float, str]]:
"""화자 턴 타임라인 추출.
audio_np : float32 1D numpy (16k mono)
Returns : [(start_s, end_s, speaker_label), ...] (시간순)
"""
#...
waveform = torch.from_numpy(audio_np).unsqueeze(0).to(_device)
out = _pipeline({"waveform": waveform, "sample_rate": sr})
# pyannote 4.x: DiarizeOutput.speaker_diarization 이 Annotation
turns = [
(turn.start, turn.end, speaker)
for turn, _, speaker in out.speaker_diarization.itertracks(yield_label=True)
]
log.info(f"diarization: {len(turns)} turns, {len(set(t[2] for t in turns))} speakers")
return turns
추출 예시 결과

입출력 형태
입력: 오디오 전체, 16kHz Float 배열
출력: 화자 구분
[
(0.0, 5.8, "SPEAKER_00"), # 0~5.8초: 화자0
(5.8, 12.3, "SPEAKER_01"), # 5.8~12.3초: 화자1
(12.3, 15.0, "SPEAKER_00"), # 다시 화자0
(40.3, 47.2, "SPEAKER_02"), # 화자2
...
]
Whisper-large-v3는 OpenAI에서 공개한 최신 음성 인식 모델로,
다국어 음성 인식, 자동 자막 생성, 오디오 텍스트 변환 등의 작업에서 뛰어난 성능을 발휘하여, 도입하게 되었다.
4. 언어 판정 (LID)
- 어떤 언어인지를 식별하는 과정이다.
MODEL : "whisper-large-v3"
COMPUTE_TYPE : "float16" # GPU 서버이기에 COMPUTE_TYPE 은 float16 선정
# lib/audio/whisper.py
def detect_language(chunk: np.ndarray) -> tuple[str, float]:
"""raw chunk → (lang_code, prob). LID 는 raw 오디오에서 (PoC Strategy 2)."""
lang_code, prob, _all_probs = _model.detect_language(chunk)
return lang_code, prob
-
wav 음성 파일 LID 과정.
- ko=1.00 → 가장 높은 확률로 ko 로 인식한다는 뜻이다.
-
두 모델 (Whisper LID / VoxLingua107 LID) 비교 하였고, PoC 뒤 Whisper 선정
2026-05-28 18:35:21 [INFO] lid_bench: DeepFilterNet3 loaded (sr=48000, device=cuda:0)
2026-05-28 18:35:21 [INFO] lid_bench: Loading Silero VAD...
2026-05-28 18:35:21 [INFO] lid_bench: Loading Whisper LID: mobiuslabsgmbh/faster-whisper-large-v3-turbo
2026-05-28 18:35:22 [INFO] lid_bench: Loading VoxLingua107 LID (savedir=/stg/models/voxlingua107)
2026-05-28 18:35:22 [INFO] lid_bench: === models loaded ===
2026-05-28 18:35:22 [INFO] lid_bench: === bench start: /stg/vod/scenemaker/sound_full/docu.wav ===
2026-05-28 18:35:23 [INFO] lid_bench: denoise chunked: 3520.7s → 118 chunks of 30s
2026-05-28 18:35:33 [INFO] lid_bench: denoise saved: output/denoise/docu.wav
2026-05-28 18:35:43 [INFO] lid_bench: audio 3520.7s → VAD 383 speech segments
2026-05-28 18:35:44 [INFO] lid_bench: [ 11.5~ 12.7s] W-raw=ko=1.00 V-raw=ko=0.99 W-den=ko=1.00 V-den=ko=0.80
2026-05-28 18:35:44 [INFO] lid_bench: [ 13.1~ 15.3s] W-raw=ko=1.00 V-raw=ko=1.00 W-den=ko=1.00 V-den=ko=1.00
...
026-05-28 18:35:45 [INFO] lid_bench: [ 278.4~ 281.1s] W-raw=ko=1.00 V-raw=ko=1.00 W-den=ko=1.00 V-den=ko=1.00
2026-05-28 18:35:45 [INFO] lid_bench: [ 282.6~ 284.3s] W-raw=en=0.30 V-raw=it=0.79 W-den=en=0.46 V-den=pt=0.32
5. 음성 인식 (ASR)
MODEL : "whisper-large-v3"
COMPUTE_TYPE : "float16"
BATCH_SIZE=16
# lib/audio/whisper.py
def transcribe_batched(audio: np.ndarray, language: str) -> list[dict]:
"""오디오를 지정 언어로 배치 transcribe (내부 VAD 로 발화 구간만, 30s 윈도우 병렬).
audio 는 보통 "해당 언어 외 구간을 묵음 처리한 전체 길이 스트림" → 내부 VAD 가
묵음을 건너뛰므로 그 언어 발화만 인식되고, timestamp 는 원본 시각 그대로.
word_timestamps 로 단어 단위 반환 → 호출측(stt_service)이 화자 턴/문장부호 기준으로
재분할 (배치는 segment 가 30s 로 뭉치므로 세밀도/화자 정확도 복원).
Returns: [{"start", "end", "word", "seg_logprob"}, ...] (단어 단위, 절대 시각)
seg_logprob = 그 단어가 속한 segment 의 avg_logprob (환각 필터용)
"""
if _model is None:
load_model()
segments_gen, _info = _batched.transcribe(
audio,
language=language,
batch_size=BATCH_SIZE, # 동시에 GPU 올리는 3-s 윈도우 수, 16 으로 설정함
beam_size=5,
no_speech_threshold=0.6,
log_prob_threshold=-1.0,
compression_ratio_threshold=2.4,
condition_on_previous_text=False,
repetition_penalty=1.2,
no_repeat_ngram_size=3,
vad_filter=True, # 묵음 구간 건너뛰기 (묵음 스트림 처리의 핵심)
word_timestamps=True, # 단어 단위 타임스탬프
)
words = []
for s in segments_gen:
for w in (s.words or []):
words.append({
"start": float(w.start), "end": float(w.end),
"word": w.word, "seg_logprob": s.avg_logprob,
})
return words
- 동작 과정 기록만 되어 있는 상태.
2026/06/25 17:15:31 INFO [stt_service.py:_transcribe_batched:204] - batched [zh]: 81 words from 7 ranges
2026/06/25 17:15:31 INFO [transcribe.py:transcribe:390] - Processing audio with duration 58:40.749
2026/06/25 17:15:34 INFO [transcribe.py:transcribe:458] - VAD filter removed 58:38.445 of audio
2026/06/25 17:15:34 INFO [stt_service.py:_transcribe_batched:204] - batched [vi]: 11 words from 1 ranges
2026/06/25 17:15:34 INFO [stt_service.py:_assemble_segments:252] - assemble: 314 segments from 2477 words
2026/06/25 17:15:34 INFO [util.py:write_json:26] - wrote json → output/2/result.json
...
- json 으로 결과 확인 가능하다.
{
"idx": 0,
"start": "00:00:07.2",
"end": "00:00:09.9",
"text": "스포일러 엄청 커 저번이랑",
"lang": "ko",
"speaker": "S003"
},
마무리
목표였던 STT 및 정형화 과정이 완료되었다.
추출 내용 결과, 자막에 문제가 있는 것을 확인되었다.
추후 재보정 (refine) 진행할 예정이다.
참고 링크)
- https://www.saytowords.com/ko/blogs/Faster-Whisper-Guide/
- https://elice.io/ko/resources/blog/whisper-large-benchmark
- https://kjh1337.tistory.com/3
본 글은 과학기술정보통신부·정보통신산업진흥원 「2026년 오픈소스 AI·SW 개발·활용 지원사업」의 지원으로 수행된 연구 결과입니다.