본문 바로가기
AI Project/Edge AI Agent - 음성처리(연구,분석,검증))

[비교/검증-6] #8. Edge Agent AI 음성처리 : Vosk + Whisper.cpp 하이브리드 파이프라인 구축 및 X-Vector 기반 화자 식별 알고리즘 검증

by 으노으뇨 2026. 5. 4.
728x90
SMALL

제 포스팅은 AI가 작성한게 아닌 한글자 한글자 타자를 쳐서 작성한 포스팅입니다. 구독 좋아요 댓글은 힘이됩니다.

안녕하세요~~~!!! 

지난 포스팅에서는 조금 알려진 STT +화자분리 되는 무료 엔진, 모델을 한번 모바일/ARM/Termux 환경에서 구동해보고 

직접 비교해봤습니다. 정말 냉장고나 어디 모뎀에 쓰일것같은 후진 CPU가 어디까지 커버할수있나 고생이 많군요..

그런데 그 과정중 한번 직접 화자분리를 할수있지않을까...? 하는 생각이 떠올라서 포스팅을 작성하게 되었습니다.


하이브리드 아키텍처 도입 배경

이전 연구에서 확인하였듯, Vosk 엔진은 모바일 환경에서 화자의 음향적 특징을 추출하는 데는 우수하나, 언어 모델의 한계로 인해 텍스트 변환 정확도가 크게 떨어집니다.

반면 Whisper 모델은 텍스트 변환 정확도는 매우 높으나, 화자 분리 기능을 네이티브로 지원하지 않습니다...

이번 포스팅에서는 이 두 모델의 장점만을 결합한 하이브리드 파이프라인을 설계하였습니다.

 

무거운 머신러닝 프레임워크 를 배제하고, 순수 Python 내장 함수만으로 코사인 유사도와 K-Means 클러스터링, 최단거리 매핑을 연산하여 메모리 오버헤드를 극소화하는 접근법을 취했습니다!!!


핵심 로직 및 수학적 모델링

1. 코사인 유사도

128차원 공간에서 두 벡터가 이루는 각도의 코사인 값을 계산하여 방향적 유사성을 측정합니다.

사람의 얼굴 생김새(눈, 코, 입의 비율)를 수치화할 수 있다면, A라는 사람과 B라는 사람의 얼굴이 얼마나 닮았는지 0점에서 100점 사이로 점수를 매길 수 있겠죠?

마찬가지로, 코사인 유사도는 오디오에서 뽑아낸 목소리의 특징 두 개가 얼마나 닮았는지 비교하여 점수(0.0 ~ 1.0)를 매겨주는 계산기입니다.

1.0에 가까울수록 어? 이거 아까 그 사람 목소리랑 완전 똑같네! 라고 판단하는 것입니다.

2. K-Means 클러스터링

주어진 데이터 세트를 $K$개의 군집으로 묶는 비지도 학습 알고리즘으로, 코사인 유사도를 바탕으로 중심점을 지속적으로 업데이트하여 군집 간의 분산도를 최적화합니다.

방바닥에 빨간색, 주황색, 파란색, 남색 구슬이 마구 섞여 있다고 상상해 봅시다!

처음에는 방을 대충 '붉은 계열 방(A)'과 '푸른 계열 방(B)' 두 구역으로 나눕니다.  <<< 초기 중심점 설정

구슬을 하나씩 집어 들고, A 구역과 B 구역 중 어디에 더 어울리는지 색깔을 비교해서 내려놓습니다. <<< 코사인 유사도 비교

구슬을 다 나눴으면, A 구역에 모인 구슬들의 '평균적인 붉은색'과 B 구역의 평균적인 푸른색을 다시 계산해서 방의 기준색을 업데이트합니다. <<<< 중심점 갱신
이 과정을 반복하면, 목소리들도 자기들끼리 비슷한 톤을 가진 사람 1, 사람 2로 완벽하게 나뉘게 됩니다. 

--speakers 3 옵션을 주면 방을 3개로 나누어 3명의 목소리를 구분하는 식입니다.

3. 최단 거리 매핑

두 이기종 모델 간의 타임스탬프 오프셋 불일치로 인한 사각지대를 방지하기 위해, 절대 오차가 가장 작은 인접 데이터를 맵핑하는 폴백 알고리즘입니다.

Whisper(글씨 쓰는 애)와 Vosk(목소리 듣는 애)는 일하는 속도가 미세하게 다릅니다. Whisper는 1시 5분에 안녕이라고 했어라고 적었는데, Vosk는 1시 6분에 누군가 말했어라고 기록할 수 있죠. 시간이 정확히 일치하지 않는다고 데이터를 버리면 구멍이 숭숭 뚫립니다. 그래서 이 로직은

시간이 딱 안 맞아? 그럼 앞뒤로 뒤져서 시간이 제일 가까운 애를 최단 거리로 하여 무조건 네 짝꿍으로 맺어줄게!

라고 강제로 엮어주어 텍스트가 유실되는 것을 막아줍니다.


4. 스크립트 코드 분석

더보기
import sys
# 안드로이드(Termux) 환경에서의 OS 플랫폼 검증 우회
sys.platform = 'linux'

import os
import wave
import json
import math
import subprocess
import time
import argparse
import random
from vosk import Model, SpkModel, KaldiRecognizer
import matplotlib.pyplot as plt
import matplotlib.patches as patches

# ==========================================
# 1. 시스템 환경 설정 (Configuration)
# ==========================================
# 1) 오디오 파일 (stt_benchmark 폴더에 있음)
AUDIO_FILE = "/data/data/com.termux/files/home/projects/stt_benchmark/samples/test_cut_2.wav"

# 2) Whisper 실행 파일 (build/bin 경로 반영 완료!)
WHISPER_CMD = "/data/data/com.termux/files/home/projects/whisper.cpp/build/bin/whisper-cli"

# 3) Whisper 모델 파일
WHISPER_MODEL_SMALL = "/data/data/com.termux/files/home/projects/whisper.cpp/models/ggml-small.bin"
WHISPER_MODEL_MEDIUM = "/data/data/com.termux/files/home/projects/whisper.cpp/models/ggml-medium.bin"
# ==========================================
# 2. 순수 파이썬 기반 수학 연산 (Vector Operations)
# ==========================================
def cosine_similarity(v1, v2):
    dot_product = sum(a * b for a, b in zip(v1, v2))
    mag1 = math.sqrt(sum(a * a for a in v1))
    mag2 = math.sqrt(sum(b * b for b in v2))
    if mag1 == 0 or mag2 == 0: return 0.0
    return dot_product / (mag1 * mag2)

def compute_mean_vector(vectors):
    return [sum(col) / len(col) for col in zip(*vectors)]

def kmeans_clustering_k(vectors, k=2, max_iter=10):
    if not vectors: 
        return []
    num_vectors = len(vectors)
    
    if num_vectors <= k:
        return list(range(num_vectors))

    centroids = random.sample(vectors, k)
    labels = []
    
    for _ in range(max_iter):
        labels = []
        clusters = {i: [] for i in range(k)}
        
        for v in vectors:
            similarities = [cosine_similarity(v, c) for c in centroids]
            best_cluster_idx = similarities.index(max(similarities))
            
            labels.append(best_cluster_idx)
            clusters[best_cluster_idx].append(v)
            
        for i in range(k):
            if clusters[i]:
                centroids[i] = compute_mean_vector(clusters[i])
                
    return labels


def save_visualization(whisper_segments, vosk_speakers, output_prefix="ameva_result"):
    # Build speaker id list (exclude unknown -1)
    speaker_ids = sorted({v.get('speaker_id', -1) for v in vosk_speakers if v.get('speaker_id', -1) != -1})
    if not speaker_ids:
        speaker_ids = [0]

    id_to_y = {sid: i for i, sid in enumerate(speaker_ids)}
    unknown_y = len(speaker_ids)

    fig, ax = plt.subplots(figsize=(12, 2 + len(speaker_ids)))
    cmap = plt.get_cmap('tab10')

    # Plot Vosk speaker segments as horizontal bars
    for v in vosk_speakers:
        sid = v.get('speaker_id', -1)
        y = id_to_y.get(sid, unknown_y)
        start = v.get('start', 0)
        end = v.get('end', start)
        width = max(0.001, end - start)
        color = cmap(sid % 10) if sid != -1 else 'gray'
        rect = patches.Rectangle((start, y - 0.3), width, 0.6, facecolor=color, alpha=0.6, edgecolor='k')
        ax.add_patch(rect)

    # Plot Whisper segments as text labels positioned at matched speaker row
    for w in whisper_segments:
        sid = w.get('speaker_id', -1)
        y = id_to_y.get(sid, unknown_y)
        x = (w['start'] + w['end']) / 2.0
        txt = w.get('text', '')
        ax.text(x, y, txt if len(txt) < 120 else txt[:117] + '...', ha='center', va='center', fontsize=8, wrap=True)

    # Configure axes
    max_time = 0.0
    if vosk_speakers:
        max_time = max(max_time, max(v.get('end', 0) for v in vosk_speakers))
    if whisper_segments:
        max_time = max(max_time, max(w.get('end', 0) for w in whisper_segments))

    ax.set_xlim(0, max_time + 0.5)
    ax.set_ylim(-1, len(speaker_ids) + 0.5)
    y_ticks = list(range(len(speaker_ids)))
    ax.set_yticks(y_ticks + [unknown_y])
    ax.set_yticklabels([f"Speaker {s}" for s in speaker_ids] + ["Unknown"])
    ax.set_xlabel('Time (s)')
    ax.set_title('Speaker Diarization and Transcript Mapping')
    plt.tight_layout()

    jpg_path = f"{output_prefix}.jpg"
    fig.savefig(jpg_path, dpi=150)
    plt.close(fig)

    # Save JSON result for inspection
    json_path = f"{output_prefix}.json"
    with open(json_path, 'w', encoding='utf-8') as jf:
        json.dump({"whisper_segments": whisper_segments, "vosk_speakers": vosk_speakers}, jf, ensure_ascii=False, indent=2)

    print(f"[OUTPUT] Saved visualization: {jpg_path}")
    print(f"[OUTPUT] Saved JSON result: {json_path}")

# ==========================================
# 3. 메인 프로세스 (Main Execution)
# ==========================================
def main():
    # CLI 파라미터(Args) 설정
    parser = argparse.ArgumentParser(description="AMEVA Hybrid STT Engine (Whisper + Vosk)")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--small", action="store_true", help="Small 모델 사용 (기본값)")
    group.add_argument("--medium", action="store_true", help="Medium 모델 사용")
    
    # [핵심] 사용자가 입력 안 하면 알아서 기본값(default) 적용
    parser.add_argument("--speakers", type=int, default=2, help="오디오 내 예상 화자 수 지정 (기본값: 2)")
    parser.add_argument("--max_offset", type=float, default=3.0, help="매핑 허용 최대 오차 시간(초) (기본값: 3.0)")
    parser.add_argument("--output", type=str, default="ameva_result", help="결과 출력 파일 접두사 (예: ameva_result)")
    parser.add_argument("-ko", "--ko", dest="ko", action="store_true", help="한국어(Korean) 위스퍼 모드 발동 (-l ko)")
    
    args = parser.parse_args()

    # 모델 라우팅 로직
    if args.medium:
        active_whisper_model = WHISPER_MODEL_MEDIUM
        model_name = "Medium"
    else:
        active_whisper_model = WHISPER_MODEL_SMALL
        model_name = "Small"

    print("[SYSTEM] AMEVA Hybrid STT Engine 프로세스 시작")
    print(f"[SYSTEM] 대상 오디오: {AUDIO_FILE}")
    print(f"[SYSTEM] 적용 모델: Whisper {model_name}")
    print(f"[SYSTEM] 설정된 화자 수: {args.speakers}명 / 허용 오차: {args.max_offset}초")
    
    total_start_time = time.time()

    # ------------------------------------------
    # Phase 1: Whisper.cpp Transcription
    # ------------------------------------------
    print("\n[Phase 1] Whisper.cpp 전사 작업 수행 중...")
    phase1_start = time.time()
    
    whisper_args = [
        WHISPER_CMD, 
        "-m", active_whisper_model, 
        "-f", AUDIO_FILE, 
        "-oj", "-nt"
    ]

    # 사용자가 --ko 옵션을 넣었다면 리스트에 추가!
    if args.ko:
        whisper_args.extend(["-l", "ko"])
        print("[SYSTEM] 한국어 강제 인식 모드가 활성화되었습니다.")

    subprocess.run(whisper_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    with open(whisper_json_file, "r", encoding="utf-8") as f:
        whisper_data = json.load(f)

    whisper_segments = []
    for segment in whisper_data.get("transcription", []):
        start_sec = segment["offsets"]["from"] / 1000.0
        end_sec = segment["offsets"]["to"] / 1000.0
        text = segment["text"].strip()
        if text:
            whisper_segments.append({"start": start_sec, "end": end_sec, "text": text})
            
    os.remove(whisper_json_file)
    phase1_end = time.time()
    print(f"[Phase 1] 완료 (소요 시간: {phase1_end - phase1_start:.2f}초)")

    # ------------------------------------------
    # Phase 2: Vosk Speaker Diarization
    # ------------------------------------------
    print("\n[Phase 2] Vosk 화자 임베딩(X-Vector) 추출 중...")
    phase2_start = time.time()
    
    try:
        model = Model("models/ko-model")
        spk_model = SpkModel("models/spk-model")
    except Exception as e:
        print(f"[ERROR] 모델 로드 실패: {e}")
        sys.exit(1)

    try:
        wf = wave.open(AUDIO_FILE, "rb")
    except FileNotFoundError:
        print(f"[ERROR] 오디오 파일 탐색 실패: {AUDIO_FILE}")
        sys.exit(1)
        
    rec = KaldiRecognizer(model, wf.getframerate(), spk_model)
    rec.SetWords(True)

    vosk_speakers = [] 

    while True:
        data = wf.readframes(4000)
        if len(data) == 0: break
        if rec.AcceptWaveform(data):
            res = json.loads(rec.Result())
            if 'spk' in res and 'result' in res and len(res['result']) > 0:
                vosk_speakers.append({
                    "start": res['result'][0]['start'],
                    "end": res['result'][-1]['end'],
                    "vector": res['spk']
                })

    res = json.loads(rec.FinalResult())
    if 'spk' in res and 'result' in res and len(res['result']) > 0:
        vosk_speakers.append({
            "start": res['result'][0]['start'],
            "end": res['result'][-1]['end'],
            "vector": res['spk']
        })
        
    phase2_end = time.time()
    print(f"[Phase 2] 완료 (소요 시간: {phase2_end - phase2_start:.2f}초)")

    # ------------------------------------------
    # Phase 3: Timeline Synchronization & K-Means Clustering
    # ------------------------------------------
    print("\n[Phase 3] 시계열 동기화 및 K-Means 화자 군집화 수행 중...")
    phase3_start = time.time()

    all_vectors = [v['vector'] for v in vosk_speakers]
    cluster_labels = kmeans_clustering_k(all_vectors, k=args.speakers)

    for i, v_seg in enumerate(vosk_speakers):
        v_seg['speaker_id'] = cluster_labels[i]

    print("\n============================================================")
    print("[결과] AMEVA 하이브리드 STT 출력 파이프라인")
    print("============================================================")

    for w_seg in whisper_segments:
        w_mid = (w_seg['start'] + w_seg['end']) / 2.0
        
        matched_speaker_id = -1
        current_min_diff = float('inf')
        
        for v_seg in vosk_speakers:
            v_mid = (v_seg['start'] + v_seg['end']) / 2.0
            time_diff = abs(w_mid - v_mid)
            
            # 조건 1: 시간 구간에 완벽히 포함되는 경우
            if v_seg['start'] <= w_mid <= v_seg['end']:
                matched_speaker_id = v_seg['speaker_id']
                break
                
            # 조건 2: 겹치지는 않지만, args.max_offset 이내에서 가장 가까운 경우
            elif time_diff <= args.max_offset and time_diff < current_min_diff:
                current_min_diff = time_diff
                matched_speaker_id = v_seg['speaker_id']
        
        # 매핑 실패 방어 로직 적용
        w_seg['speaker_id'] = matched_speaker_id
        speaker_label = f"Speaker {matched_speaker_id}" if matched_speaker_id != -1 else "Unknown"
        print(f"[{w_seg['start']:>5.1f}s - {w_seg['end']:>5.1f}s] [{speaker_label}] : {w_seg['text']}")

    print("============================================================")
    
    phase3_end = time.time()
    total_end_time = time.time()
    # 저장: 시각화(JPG) 및 JSON 결과
    try:
        save_visualization(whisper_segments, vosk_speakers, output_prefix=args.output)
    except Exception as e:
        print(f"[WARN] 시각화 저장 중 오류: {e}")
    
    print(f"\n[성능 프로파일링] 프로세스 실행 시간 요약")
    print(f"  - Phase 1 (Whisper ASR) : {phase1_end - phase1_start:.2f} sec")
    print(f"  - Phase 2 (Vosk Spk)    : {phase2_end - phase2_start:.2f} sec")
    print(f"  - Phase 3 (Clustering)  : {phase3_end - phase3_start:.2f} sec")
    print(f"  - 총 소요 시간          : {total_end_time - total_start_time:.2f} sec")

if __name__ == "__main__":
    main()

더 자세한 코드는 https://github.com/uno-km/AMEVA-STT-Agent.git

 

GitHub - uno-km/AMEVA-STT-Agent

Contribute to uno-km/AMEVA-STT-Agent development by creating an account on GitHub.

github.com

계속 고도화를 진행중에 있습니다...

1. 아키텍처 및 강점

1.1. 메모리 안전성을 고려한 순차적 실행 

Whisper와 Vosk를 병렬로 돌리지 않고 철저히 분리하여 순차적으로 실행한 점은 엣지 환경에서 두 모델이 동시에 RAM에 적재될 경우 발생할 수 있는 OOM현상을 방지헀습니다.

1.2. 외부 의존성(Dependencies) 극소화

X-Vector 클러스터링을 위해 numpy나 scikit-learn 같은 무거운 머신러닝 라이브러리를 사용하지 않고, Python 내장 math 모듈만을 활용하여 코사인 유사도를 직접 구현한 것은 모바일 환경에 최적화하도록 했습니다.

2. 단점 및 제한사항

그러나 ... 안정성을 위해 타협한 구조적 비효율성이 존재합니다.

2.1. 비효율적인 디스크 I/O

갤럭시 A35의 가용 RAM 부족(3GB 내외)으로 인해 Whisper를 독립 프로세스로 실행 후 종료하여 램을 확보하고, 결과를 JSON 파일로 임시 저장하여 Vosk가 순차적으로 읽도록 격리함.

왜냐면 Whisper와 Vosk를 메모리에 동시 적재하면 OS가 강제 종료함 ㅠㅠ..

2.2. 중복된 오디오 스캔

C++ 기반(Whisper)과 C/Python 기반(Vosk) 엔진의 메모리 공유 불가하다..

두 엔진의 오디오 처리 방식(윈도우 사이즈 등)이 달라 단일 스트림 공유가 안 됨.

Whisper가 파일을 한 번 스캔하고, Vosk가 별도로 다시 스캔하는 각자도생 방식을 채택....

2.3. 순수 Python 연산의 한계

Numpy, Scikit-learn 등 C-backend 라이브러리는 모바일에서 빌드 오류 유발 빈도가 매우 높은 Termux(모바일 Linux) 환경의 특수성이 존재했다....

연산 속도(GIL 병목)를 희생하더라도, pip install 오류 없이 100% 구동을 보장하는 순수 파이썬 내장 모듈로만 알고리즘을 구현함.

3. 실행 가이드

이 스크립트는 명령중 파라미터를 통해 상황에 맞게 엔진을 유연하게 제어할 수 있습니다.

실행 시 반드시 LD_LIBRARY_PATH=. 를 붙여 로컬 라이브러리(libvosk.so)를 참조하도록 해야 합니다!!!!

1. 기본 실행 (권장 옵션)

아무런 인자를 주지 않으면, 자동으로 Small 모델, 화자 2명, 허용 오차 3.0초로 구동됩니다.

LD_LIBRARY_PATH=. python ameva_hybrid.py

2. 화자 수(Speakers) 지정

등장 인물이 3명인 회의나 팟캐스트를 분석할 때 K-Means 클러스터 수를 늘려줍니다.

LD_LIBRARY_PATH=. python ameva_hybrid.py --speakers 3

3. 정밀 텍스트 추출 (Medium 모델 적용)

더욱 정확한 한글 전사가 필요할 때 사용합니다. (단, Exynos 1380에서는 연산 시간이 크게 증가합니다.)

LD_LIBRARY_PATH=. python ameva_hybrid.py --medium

4. 복합 옵션 및 매핑 기준 강화

Medium 모델을 사용하고, 화자는 4명으로 설정하되, 노이즈가 많아 시간 오차 허용 범위를 1초로 매우 엄격하게 제한하고 싶을 때 사용합니다.

(1초 이상 어긋나면 억지로 매핑하지 않고 'Unknown' 처리

LD_LIBRARY_PATH=. python ameva_hybrid.py --medium --speakers 4 --max_offset 1.0

5. 한국어 인식 모드

주변 잡음이 많거나 Whisper 엔진이 언어를 영어로 착각하여 엉뚱한 번역을 뱉어낼 때, 한국어(-l ko)로 강제 고정하여 인식률을 극대화합니다.

LD_LIBRARY_PATH=. python ameva_hybrid.py --ko

4. 실행!!

wget -O ameva_hybrid.py https://raw.githubusercontent.com/uno-km/AMEVA-STT-Agent/master/ameva_hybrid.py

이렇게 제 깃헙의 파이썬파일만 우선 받아준뒤 실행해보겠습니다.

1. 우선 한글모드로만 동작하겠슨비다. 화자2명, 스몰모델, 최대시간오차 3초로 진행하겠습니다.

LD_LIBRARY_PATH=. python ameva_hybrid.py --ko

결과는 아래와 같이 나왔습니다. 

 

ameva_result_20260504_151637_980_sp2_mo3.0_Small.json
0.02MB

 

1. 하드웨어 퍼포먼스 및 속도

전체 오디오 약 90초(추정) 분량을 처리하는 데 379.05초(약 6.3분)가 소요되었습니다.

  • Phase 1 (Whisper Small): 300.71초 소요. 전체 연산의 79.3%를 차지합니다. 
    실시간 대비 약 3.3배 느린 속도지만, 엑시노스 1380의 NPU가 아닌 CPU(NEON)만 활용하는 상황임을 고려하면 납득할 수 있는 수치입니다.
  • Phase 2 (Vosk Spk): 78.31초 소요. 매우 경량화된 X-Vector 추출 과정을 보여줍니다.
    리소스 최적화: Whisper와 Vosk를 직렬로 실행하여 램(RAM) 점유율을 효율적으로 관리하고 있음을 로그로 확인했습니다.

2. 음성 인식 정확도 (STT Accuracy)

Whisper Small 모델의 한국어 문맥 파악 능력이 매우 탁월함이 입증되었습니다.

  • 고유 명사 처리: "LS증권"(로그에는 에래스증권으로 표기), "정상민 선임 매니저", "멀티 에셋 솔루션팀" 등 금융권 특수 용어와 직함을 오타 없이 정확하게 낚아챘습니다.
  • 한국어 강제 모드(--ko) 효과: 문맥이 끊기지 않고 "구광고주님", "민간이신 분" 등 구어체 표현을 완벽하게 전사했습니다.

3. 화자 분리 및 매핑 분석 (Diarization & Mapping)

이 프로젝트의 핵심인 '하이브리드 매핑' 결과는 절반의 성공과 과제를 동시에 보여줍니다.

3-1. 매핑 성공률: 66.67% (2/3)

  • Speaker 1 (정상민 매니저 등): 코사인 유사도 0.8402로 안정적으로 군집화되었습니다.
  • Speaker 0 (추임새/반복): 0.7323의 신뢰도로 분류되었습니다.

3-2. Unknown 발생 원인 분석:

세 번째 세그먼트(60s-90s)가 Unknown으로 떴습니다.

로그를 보면 time_offset=14.310초입니다. 설정한 허용 오차(--max_offset)가 3.0초인데, Whisper가 인식한 텍스트의 시간대와 Vosk가 감지한 화자 벡터 사이의 거리가 14초나 벌어져서 매핑을 포기한 것입니다.

  • 결론: 오디오 후반부에 Vosk가 화자 특징을 잡지 못할 정도의 소음이 있었거나, Whisper의 타임스탬프가 밀렸을 가능성이 있습니다.

2. 한글모드에 화자3명, 미디움모델, 최대시간오차 3초로 진행하겠습니다. + 코드수정

30초 단위의 거대 세그먼트를 문장 최대길이로 제한하여 정밀하게 분석하도록 시도했습니다. 그래서 이제 20토큰마다 짜르고 분석하도록 수정했습니다.

 

그리고 이제 동작해보겠습니다.

LD_LIBRARY_PATH=. python ameva_hybrid.py --medium --ko --speakers 3 --max_offset 3.0 --whisper_max_len 20

추가옵션들의 설명은 아래와 같습니닷

--medium: 정확도 최우선. 엑시노스 1380에서 가능한 최고 수준의 한국어 인식률을 확보하며, 축약어와 전문 용어 처리에 탁월합니다.
--speakers 3: 화자 분리 기준. K-Means 알고리즘이 목소리 지문(X-Vector)을 3개의 독립된 군집으로 분류하도록 강제합니다.
--max_offset 3: 매핑 허용치. Whisper 문장과 Vosk 화자 정보 사이의 시차가 3초 이내일 때만 화자 ID를 부여합니다.
--whisper_max_len 20: 30초 벽 타파. 문장 길이를 제한하여 텍스트 덩어리를 잘게 쪼개고, 타임라인의 해상도를 높입니다.

 

test_result_20260504_160411_221_sp3_mo3.0_Medium_ko_test_cut_2.wav.json
0.02MB



1. 하드웨어 퍼포먼스 및 속도

전체 오디오 약 90초 분량을 처리하는 데 627.45초(약 10.5분)가 소요되었습니다.

  • Phase 1 (Whisper Medium): 546.43초 소요. 전체 연산의 87.1%를 차지합니다. 모델 체급이 커짐에 따라 연산량이 증가했으나, 엑시노스 1380 환경에서 안정적으로 전사 작업을 완료했습니다.  
  • Phase 2 (Vosk Spk): 80.92초 소요. 세그먼트가 세분화되었음에도 일정한 임베딩 추출 속도를 유지했습니다.  

2. 음성 인식 정확도 (STT Accuracy)

Whisper Medium 모델 도입 및 --whisper_max_len 20 설정을 통해 인식의 디테일과 해상도가 모두 향상되었습니다.

 

  • 고유 명사 및 축약어 처리: "머랫솔팀"(멀티 에셋 솔루션팀의 축약어), "목보검", "티커저님", "글로벌 마켓영업팀 김기현" 등 도메인 특화 용어와 인명을 정확하게 인식했습니다.
  • 세그먼트 세분화: 30초 단위로 뭉치던 이전 결과와 달리, 10~15초 단위로 문장을 분리하여 대화 흐름을 더 정밀하게 포착했습니다.

 

3. 화자 분리 및 매핑 분석 (Diarization & Mapping)

'30초의 벽'을 깨고 타임라인 해상도를 높임으로써 매핑 효율이 유의미하게 개선되었습니다.

3-1. 매핑 성공률: 71.43% (5/7)

  • Speaker 1: 코사인 유사도 0.9057이라는 매우 높은 신뢰도로 군집화 및 매핑에 성공했습니다.
  • Speaker 2: 0.7323의 신뢰도로 분류되었으며, Speaker 1과의 교차 대화 구간을 명확히 구분했습니다.

3-2. Unknown 발생 원인 분석:

후반부 두 개 세그먼트(68.4s-90.0s)가 Unknown으로 판정되었습니다.

time_offset이 각각 12.775초23.570초로 기록되었습니다. 설정된 허용 오차(3.0초)를 크게 벗어나 시스템이 매핑을 거부했습니다.

  • 결론: Whisper는 해당 구간에서 텍스트를 추출했으나, Vosk가 해당 시간대에서 화자를 식별할 수 있는 유효한 음성 특징(X-Vector)을 찾아내지 못해 발생한 '데이터 불일치' 현상입니다.
    해당 구간의 오디오 신호 강도가 낮거나 노이즈 간섭이 있었을 것으로 추정됩니다.

5. 하이브리드 STT 엔진 성능 평가 및 파라미터 최적화 비교 분석 보고서

동일한 오디오(약 90초)를 대상으로 두 실증 결과를 비교 분석해봤습니다.

  • 기본 설정(--ko / Small 모델)
  • 고도화 설정(--medium --ko --speakers 3 --max_offset 3.0 --whisper_max_len 20)

1. 하드웨어 퍼포먼스 및 속도 (비교 분석)

구분 Case 1 (기본 튜닝) Case 2 (고도화 튜닝)
적용 모델 Whisper Small Whisper Medium
추가 파라미터 --ko --ko, --speakers 3, --max_offset 3.0, --whisper_max_len 20, --whisper_split_on_word
총 연산 시간 379.05초 (약 6.3분) 627.45초 (약 10.5분)
Phase 1 (Whisper) 300.71초 546.43초
Phase 2 (Vosk) 78.31초 80.92초
전사 품질 (특수 용어) "LS증권" (에래스증권으로 인식) "멀티 에셋 솔루션팀" → "머랫솔팀" (실제 발음/축약어 반영)
세그먼트 해상도 30초 단위 (0~30s, 30~60s...) 10~15초 단위 (세분화 성공)
화자 설정 수 2명 3명
매핑 성공률 66.67% (2/3 문장 성공) 71.43% (5/7 문장 성공)
최고 신뢰도(conf) 0.8402 (Speaker 1) 0.9057 (Speaker 1)
Unknown 발생 원인 30초 단위 컨텍스트에 따른 시간차 오디오 후반부 Vosk 데이터 부재 (음량/노이즈 등)

연산 시간은 늘어났지만, 세그먼트 해상도가 높아지면서 매핑 성공률이 상승했습니다. 이는 하드웨어 부하를 감수하고라도 파라미터 튜닝을 적용하는 것이 실무적 관점에서 더 유리함을 증명합니다.

그리고 Case 1의 Unknown은 엔진 설정의 문제였다면, Case 2의 Unknown은 입력 데이터(오디오 후반부) 자체의 한계로 성격이 바뀌었습니다. 즉, 시스템 설정은 궤도에 올랐음을 의미합니다.

Case 1 (Small 모델, 기본 설정)

전체 오디오를 처리하는 데 379.05초(약 6.3분)가 소요되었습니다. Phase 1 (Whisper Small)은 300.71초 소요되어 전체 연산의 79.3%를 차지합니다.

Case 2 (Medium 모델, 세부 튜닝)

전체 처리 시간이 627.45초(약 10.5분)로 증가했습니다. Phase 1 (Whisper Medium)이 546.43초 소요되어 전체의 87.1%를 차지합니다.

모델 체급 상승(Small → Medium) 및 --whisper_max_len 20 파라미터 적용으로 잦은 타임스탬프 계산이 발생하여 연산 시간이 약 1.6배 증가했습니다.

그러나 엑시노스 1380의 CPU만 활용하는 열악한 환경에서도 OOM현상 없이 직렬 실행 방식으로 리소스 최적화에 성공한 유의미한 수치입니다. Phase 2(Vosk Spk)는 두 케이스 모두 80초 내외로 일정한 속도를 보여주었습니다.

2. 음성 인식 정확도 (STT Accuracy)

Case 1 (Small 모델)

"LS증권"(에래스증권), "멀티 에셋 솔루션팀" 등 고유 명사와 "구광고주님" 같은 구어체를 오타 없이 정확하게 전사했습니다. 하지만 Whisper 특유의 30초 단위 컨텍스트 윈도우로 인해 대화가 크게 3덩어리로 뭉쳐 출력되었습니다.

Case 2 (Medium 모델)

한국어 문맥 파악 능력이 더욱 고도화되어, "멀티 에셋 솔루션팀"을 실제 발음에 가까운 "머랫솔팀"이라는 축약어로 정밀하게 포착했습니다.

특히 문장 길이 제한 옵션으로 인해 30초 단위로 뭉치던 세그먼트가 10~15초 단위(예: 30~44s, 44~56.5s)로 세분화되어 대화 흐름의 해상도가 대폭 상승했습니다.

3. 화자 분리 및 매핑 분석 (Diarization & Mapping)

이 프로젝트의 핵심인 '하이브리드 매핑' 결과는 파라미터 튜닝을 통해 개선된 성공률을 보여줍니다.

3-1. 매핑 성공률 상승 (66.67% → 71.43%)

Case 1은 3개 문장 중 2개를 매핑했습니다 (66.67%).

Case 2는 세그먼트가 7개로 쪼개진 상황에서 5개 문장을 정확히 주인을 찾아주었습니다 (71.43%). 화자 수를 3명으로 늘렸음에도, 코사인 유사도 0.9057(Speaker 1), 0.7323(Speaker 2)의 높은 신뢰도로 교차 대화 구간을 안정적으로 군집화했습니다.

3-2. Unknown 발생 원인 분석

두 케이스 모두 오디오 후반부(60s-90s 구간)에서 Unknown이 발생했습니다.

로그를 보면 Case 2의 time_offset이 12.775초, 23.570초로 나타납니다. 텍스트 분할 해상도는 높아졌으나, Whisper가 인식한 시간대 주변에서 Vosk가 화자 벡터를 전혀 감지하지 못했습니다.

결론: 오디오 후반부에 Vosk가 목소리 지문을 추출하지 못할 정도의 배경 소음이 있었거나, 해당 구간의 발성 볼륨이 낮아 Vosk의 자체 VAD(음성 활동 감지) 문턱을 넘지 못한 데이터 불일치 현상으로 추정됩니다...

 

4. 총평 및 연구 성과....

클라우드 독립형 모바일 엣지 STT 구현

무거운 머신러닝 라이브러리(Scikit-learn, Numpy 등)를 배제하고 순수 Python 내장 함수(math)만으로 코사인 유사도, K-Means 클러스터링을 구현해냈습니다.

이를 통해 모바일/Termux 환경에서 의존성 충돌이나 메모리 오버헤드를 극소화하는 접근법을 성공적으로 입증했습니다.


파라미터 제어를 통한 타임라인 동기화

Whisper 엔진의 치명적 단점인 30초 거대 세그먼트 출력을 --whisper_max_len 20 옵션으로 강제 타파했습니다. 결과적으로 이기종 엔진 간의 타임스탬프 사각지대를 줄여 화자 매핑 해상도를 실무적 수준으로 끌어올렸습니다.

5. 아쉬운 점 및 한계

Vosk VAD 감도의 사각지대

하이브리드 엔진의 맹점이 후반부 Unknown 구간에서 드러났습니다. Whisper는 작은 소리도 문맥을 유추해 텍스트로 살려내지만, Vosk는 명확한 음향 신호가 없으면 임베딩 추출 자체를 포기합니다.

한쪽 엔진에만 데이터가 존재하는 경우 발생하는 영구적인 매핑 실패를 해결할 추가적인 폴백(Fallback) 알고리즘이 필요합니다.

디스크 I/O 병목 및 중복 스캔

가용 RAM 한계(3GB 내외)를 피하기 위해 두 엔진을 순차 실행하고 임시 JSON 파일로 데이터를 주고받는 설계는 시스템 안정성을 보장하지만 연산 시간 증가를 초래합니다.

C++과 C/Python 기반 엔진 간의 메모리 공유 불가로 인한 오디오 이중 스캔 역시 향후 모바일 AP 최적화를 위해 극복해야 할 구조적 비효율성입니다.


이것으로 엑시노스 1380이라는 혹독한 환경에서 진행된 'AMEVA 하이브리드 STT 엔진' 고도화 삽질기(?)를 마치겠습니다. 

완전 오프라인 모바일 STT라는 목표에 한 걸음 더 다가간 것 같아 뿌듯하면서도, 여전히 남은 과제들을 보면 또 며칠 밤을 새워야 할지 눈앞이 캄캄하네요......

부족한 점도 많지만, 모바일 엣지 AI에 관심 있는 분들께 조금이나마 유의미한 레퍼런스가 되길 바라며...

긴 글 읽어주셔서 정말 감사합니다! 다음번엔 더 똑똑해진 엔진으로 돌아오겠습니다!

 

제 포스팅은 AI가 작성한게 아닌 한글자 한글자 타자를 쳐서 작성한 포스팅입니다. 구독 좋아요 댓글은 힘이됩니다.

728x90
LIST

댓글