본문 바로가기
AI에서 살아남기(to. Android Developer)/나도 AI 개발자 되어보기?

[나도 AI 개발자] 자막 추출 AI 개발해보기 (1)

by 개발자 인민군 2026. 2. 2.

또 우리 팀에 이사님이 아래의 사진과 같이 하나의 캡쳐사진을 웍스 단체 DM에 보내며 자막 추출 AI를 만들라 해본다...

뭐 당연히 우리 팀에서는 나한테 시키면서 이번에도 해보라고 한다.

솔직히 뭔지도 모르고 아는건 없지만 저번에 해봤던 ComfyUI를 설치하고 아웃페인팅을 해봤던 것때문에 이전처럼 당황하지 않았다.

뭐 우리 듬직한 클로드와 함께 공부해가면서 구축해보면 될일이지 뭐~

뭐 일단 Qwen3-ASR이 뭔지 부터 찾아보자.

Qwne3-ASR은 알리바바에서 만든 30개 언어 + 중국 방언 22개 의 언어를 Automatic Speech Recognition (자동 음성 인식) 하는 AI 오픈소스 모델이라고 한다. 모델은 총 두개의 모델을 제공해주는데 0.6B 의 가볍고 빠른 모델과 성능이 좋은 1.7B 를 제공한다.

뭐 사실 정확한 모델의 안의 코드는 나중에 시간될때 이해해볼거고 지금은 우리 AI 서버에 이 라이브러리를 어떻게 깔고 어떻게 테스트 해볼지 생각해보자.

내가 이전에 우리 GPU AI 서버에 Docker Compose 를 사용하여 마이크로서비스 아키텍처 로 구축해놨다.

이유는 각 서비스는 독립적인 컨테이너로 실행시켜 여러 AI 를 독립적으로 사용해 볼 수 있기 때문이였다.

 

이전 ComfyUI 를 설치해본거와 같이 Docker 로 인스턴스 배포를 해보겠다.

일단 초기 아키텍처 를 생각해봤을때 가장 간단하게 Qwen3-ASR 는 영상을 분석하는게 아니라 음원을 분석하는거니 프로세스는 다음과 같다.

  1. 영상의 음원을 추출한다.(이건 그냥 인터넷에 변환기 있으니까 이걸로 뽑기)
  2. input 으로 WAV/MP3 파일 을 AI 서버 로컬에 저장한다.
  3. Qwen3-ASR 모델을 model\. 폴더에 직접 다운받는다. (테스트이니 0.6B 사용)
  4. 결과를 추출한다. (SRT 자막 파일 + 콘솔 출력)
huggingface-cli download Qwen/Qwen3-ASR-0.6B \
  --local-dir ./subtitle-extractor-ai/app/models/Qwen3-ASR-0.6B

구조는 다음과 같다.

AI231 서버
│
├── 루트 시스템 (/root)
├── 데이터 및 모델 (/data)
└──  사용자 홈 (/home)
    │
    ├── aiadmin
    │   └── aiserver/
    │       │
    │       ├── ComfyUI/                       #이미지 생성 AI
    │       ├── docker-compose.yml             #Docker 구성 파일 (Docker 컨테이너들을 정의하고 관리하는 설정 파일)
    │       ├── Dockerfile.comfyui             #ComfyUI Docker 빌드
    │       ├── subtitle-extractor-ai/         #자막 생성 AI
    │       │   ├── app/
    │       │   │   ├── models/
    │       │   │   │    └── Qwen3-ASR-0.6B/   #모델
    │       │   │   ├── result/
    │       │   │   │    └── test1.srt         #결과
    │       │   │   ├── test/
    │       │   │   │    └── test1.wav         #테스트 음원
    │       │   │   ├── _init_.py
    │       │   │   └── transcribe.py          #실행코드
    │       │   ├── Dockerfile                 #도커설계도
    │       │   ├── .dockerignore
    │       │   └── requirements.txt           #패키지 목록
    │       └── README.md
    ├── aiadmin_ [백업 파일]
    ├── aiserver [권한 없음]
    ├── ncadmin [권한 없음]
    └── uaremine [권한 없음]

코드

requirements.txt

# Qwen3-ASR 공식 패키지
qwen-asr==0.0.6

# Utils
numpy>=1.26.0
python-multipart>=0.0.6

Qwen3-ASR은 매우 최신 모델이라서, Transformers 라이브러리가 이 모델 아키텍처를 인식하지 못했다. 해서 Transformers로 직접 모델을 로드하려 하지 말고, Qwen이 제공하는 공식 qwen-asr 패키지를 사용하였다.

requirements.txt 는 python에 필요한 패키지 목록을 명시할때 쓰인다.

해서 아래 명령어시 해당 도커에 해당 패키지 버전을 설치한다.

pip install -r requirements.txt

Dockerfile

# 1. 베이스 이미지
FROM pytorch/pytorch:2.2.2-cuda12.1-cudnn8-runtime

# 2. 환경 설정
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# 3. 시스템 패키지 설치
RUN apt-get update && apt-get install -y \
    git \
    curl \
    ca-certificates \
    ffmpeg \
    libsm6 \
    libxrender1 \
    libxext6 \
    libgl1 \
 && rm -rf /var/lib/apt/lists/*

# 4. 작업 폴더 설정
WORKDIR /app

# 5. 파일 복사
COPY requirements.txt .

# 6. pip 업그레이드
RUN pip install --upgrade pip

# 7. Python 패키지 설치 (requirements.txt 사용)
RUN pip install -r requirements.txt

# 8. 포트 설정
EXPOSE 8000

# 9. 실행 명령
CMD ["python", "transcribe.py"]

Dockerfile 은 실행 과정을 작성해둔다. 이미지를 만드는 법이라고 생각하면 된다.

어떤 환경을 만들것인지 (베이스 이미지 선택, 패키지 설치, 파일 복사, 실행 명령 정의 등) 어떻게 설치할지 차례로 실행을 시키는데 라이브러리 버전은 requirements.txt 을 참조한다. 여러 세팅을 맞추고 이후 transcribe.py 을 실행시킨다.

이 동작은 처음에만 실행되고 이후에는 캐시를 사용한다. 대략 처음은 4분 ~ 5분 정도 걸린다;;

이후에는 docker-compose.yml 파일이 해당 도커 컨테이너를 관리한다.

docker-compose.yml 

    comfyui:
    # image: zeroclue/comfyui:base-torch2.8.0-cu126
    .
    .
    .

 # ════════════════════════════════════════════════════════
 #  자막 추출기 (Qwen3-ASR-0.6B)
 # ════════════════════════════════════════════════════════

subtitle-extractor-ai:
    build:
      context: ./subtitle-extractor-ai              # Docker 빌드 시 기준이 될 폴더 위치
      dockerfile: Dockerfile                        # Dockerfile의 이름 (기본값은 "Dockerfile")
    container_name: subtitle-extractor-ai           # 컨테이너 이름
    restart: unless-stopped
    volumes:
      - /data/hf_cache:/root/.cache/huggingface     # Hugging Face 캐시 공유
      - ./subtitle-extractor-ai/app:/app            # 애플리케이션 코드 마운트
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia                        # GPU 드라이버 지정 (nvidia)
              device_ids: ['0']                     # GPU 설정(현재 0,1 두개의 GPU가 있음)
              capabilities: [gpu]                   # GPU 기능 활성화
    environment:
      - HF_TOKEN=${HF_TOKEN}                        # Hugging Face API 토큰
      - HF_HOME=/root/.cache/huggingface            # Hugging Face 캐시 위치 명시
      - CUDA_VISIBLE_DEVICES=0                      # CUDA GPU 선택 (Dockerfile과 일치해야 함)
      - PYTHONUNBUFFERED=1
    shm_size: "8gb"                                 # 음성 데이터를 메모리에 임시 저장할때 쓰이는 (공유 메모리) 크기 설정
    ipc: host                                       # 컨테이너가 호스트와 메모리 공유

transcribe.py

#!/usr/bin/env python3

"""
자막 추출기 - Qwen3-ASR-0.6B (qwen-asr 공식 패키지 사용)
"""

import sys
import torch
import logging
from pathlib import Path
from qwen_asr import Qwen3ASRModel

# 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# 디렉토리 설정
BASE_DIR = Path(__file__).resolve().parent
MODEL_PATH = BASE_DIR / "models" / "Qwen3-ASR-0.6B"
RESULT_DIR = BASE_DIR / "result"

RESULT_DIR.mkdir(exist_ok=True)


def get_file_type(file_path: Path) -> str:
    """파일 형식 판별"""
    ext = file_path.suffix.lower()
    audio_formats = {'.wav', '.mp3', '.m4a', '.flac', '.aac', '.ogg', '.wma'}
    
    if ext in audio_formats:
        return "audio"
    else:
        return "unknown"


def main():
    """메인 함수"""
    
    if len(sys.argv) < 2:
        print("❌ 사용법: python transcribe.py <파일 경로>")
        print("   python transcribe.py test/audio.wav")
        sys.exit(1)
    
    audio_file = sys.argv[1]
    file_path = Path(audio_file)
    
    if not file_path.is_absolute():
        file_path = BASE_DIR / file_path
    
    logger.info(f"📂 입력 파일: {file_path.name}")
    logger.info(f"📏 파일 경로: {file_path}")
    
    if not file_path.exists():
        logger.error(f"❌ 파일을 찾을 수 없습니다: {file_path}")
        sys.exit(1)
    
    logger.info(f"📏 파일 크기: {file_path.stat().st_size / 1024 / 1024:.2f}MB")
    
    file_type = get_file_type(file_path)
    logger.info(f"📋 파일 형식: {file_type}")
    
    if file_type != "audio":
        logger.error(f"❌ 지원하지 않는 파일 형식입니다")
        sys.exit(1)
    
    try:
        logger.info("="*70)
        logger.info("🎙️ 자막 추출기 (Qwen3-ASR-0.6B)")
        logger.info("="*70)
        
        # GPU 확인
        device = "cuda" if torch.cuda.is_available() else "cpu"
        logger.info(f"📱 디바이스: {device}")
        
        if device == "cuda":
            logger.info(f"🖥️  GPU: {torch.cuda.get_device_name(0)}")
            logger.info(f"💾 GPU 메모리: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.2f}GB")
        
        # 모델 로드
        logger.info("🔄 모델 로드 중...")
        model = Qwen3ASRModel.from_pretrained(
            "Qwen/Qwen3-ASR-0.6B",
            dtype=torch.bfloat16,
            device_map=device,
            max_inference_batch_size=8,
            max_new_tokens=256,
        )
        logger.info("✅ 모델 로드 완료")
        
        # 자막 생성
        logger.info(f"🎵 파일 처리 중: {file_path.name}")
        results = model.transcribe(audio=str(file_path), language=None)
        
        text = results[0].text
        language = results[0].language
        
        logger.info(f"✅ 처리 완료")
        logger.info(f"📝 감지된 언어: {language}")
        logger.info(f"📝 자막 길이: {len(text)}자")
        
        # 결과 출력
        logger.info("\n" + "="*70)
        logger.info("📝 추출된 자막:")
        logger.info("="*70)
        print(text)
        logger.info("="*70)
        
        # 결과 저장
        output_filename = file_path.stem + ".srt"
        output_path = RESULT_DIR / output_filename
        
        srt_content = "1\n00:00:00,000 --> 00:00:59,999\n" + text + "\n"
        with open(output_path, 'w', encoding='utf-8') as f:
            f.write(srt_content)
        
        logger.info(f"💾 저장 완료: {output_path}")
        logger.info(f"\n✅ 모든 작업 완료!")
    
    except Exception as e:
        logger.error(f"\n❌ 작업 실패: {e}")
        import traceback
        logger.error(traceback.format_exc())
        sys.exit(1)


if __name__ == "__main__":
    main()

이제 사용자가 아래와 같이 명령어 입력시 실행한다.

$ docker compose run --rm subtitle-extractor-ai \
  python transcribe.py test/test1.wav

 

 

main() 함수 시작

  1. test/test1.wav 받음
  2. /app/test/test1.wav 의 절대 경로에 저장
  3. 파일 존재 유무 체크
  4. 파일 형식 확인 (.wav 형식의 음성만 가능)
  5. GPU 확인 (NVIDIA H200 NVL)
  6. 모델 로드 (Qwen3-ASR-0.6B)
  7. 음성 처리 (여기서 텍스트로 변환)
  8. 결과 출력 (콘솔에 텍스트 출력)
  9. 결과 저장(result/test1.srt 생성)

종료

해서 대략 38초의 음성을 docker 작동시긴을 제외하고 10초 미만으로 걸렸다. (아마 H200의 힘인가보다.)

나는 유튜브의 영상 중 매미킴 김상욱 영상 쇼츠(Link)이 있어 사용했다. (이유는 김상욱을 응원하기 때문!! 김상욱 UFC 파이팅!!!)

결과

1
00:00:00,000 --> 00:00:59,999
훈련 중 갑자기 눈물을 흘리는 김상욱 선수인데. 저번에 이거 똑같이 할 때는 사우가 형으로보다 잘했어요.
최종 감량을 많이 들어가주고 팀이 예전보다는 없는 것 같아.
한 번도 통화를 할 때 배우고 다시 찍고 가야 될 것 같습니다.
컨디션이 좋지 않은 채로 훈련을 하던 김상욱이.
어디 안 좋나?
어디 안 좋아? 왜 왜 왜.
수학 갑자기 해서 한 번씩이라던 자주 그래? 
아 강력 스텐스 하는 거지. 조수 없어. 하는 거죠.
이렇게 스트레스 받고 잘 안 되고 해야. 시합 때가 가지고.
너무 힘든. 내가 UFC를 그렇게 하면서. 잘 풀린 적이 한 번도 없어.
잘 풀리면은 스트레스야. 내가 도와주는 파트너가 약한 거야.
감독이 강도가 약한 거야. 내가 지금 보는

 

대박...!! 생각보다 잘나온다...!!! (사투리는 좀 이상하게 나온다.)

아마 언어를 한국어로 설정하지 않아서이다.

다음에는 언어를 한국어로 설정해 정확도를 높이고 해당 나온 텍스트를 시간별로 쪼개 보도록 하겠다.

이만...!!


참고