avatar
Octoping Blog

우리 팀이 자동으로 자막을 생성하기까지

Cotuber에 도입된 AI 자막 생성 기능이 어떤 과정을 거쳐 만들어졌는지 공유합니다.
SW마에스트로PythonAIWhisperBackend
a month ago
·
31 min read

들어가기 전에

인프런이 자동으로 자막을 생성하기까지 (AI/인프라 편)
인프런에 도입된 AI 자동 자막 생성 기능이 어떤 과정을 거쳐 만들어졌는지 공유합니다.
https://tech.inflab.com/20231031-inflearn-subtitle

먼저.. 저희 프로젝트의 성과와 이번 글의 내용은 전적으로 인프런의 이 글에 영향을 크게 받았음을 명시하려고 합니다.

SW마에스트로에서 15기 연수생으로 활동하면서 유튜버를 위한 숏폼 자동 생성 서비스를 만들었는데, 숏폼을 위해 자막을 생성하는 기능을 구현하며 있었던 일들을 공유하고자 합니다.

프로젝트 소개

기능

저희 프로젝트는 다음과 같은 단순한 기능을 제공합니다.

  1. 유튜브 URL을 넣으면 간단히 shorts를 만들 수 있는 초안을 제공한다

  2. 마음에 들지 않을 경우, 생성된 초안을 유저가 수정할 수 있다

  3. 최종적으로 shorts 영상을 생성하고, 이를 유튜브에 자동으로 업로드한다

초안이 무엇이냐고 하면..

  1. 풀 영상의 자막을 자동으로 생성해준다

  2. AI를 통해 재미있을 부분의 시작/끝 지점을 추천해준다

  3. 해당 지점의 상단에 뜰 제목을 추천해준다

2261

이런 식이에요.

아키텍처

2260

프로젝트의 아키텍처는 이렇습니다.

API 서버를 두어 전반적인 인증/인가, 비즈니스 로직 등이 담깁니다. 그리고 쇼츠 생성에 필요한 세부적인 단계들은 각각 마이크로하게 서버를 분리해두었습니다.

쇼츠 생성 단계

  1. URL을 통해 유튜브 동영상을 다운로드한 후, S3에 업로드한다

  2. 해당 영상의 오디오를 추출한 후 (FFMPEG), 인공지능으로 자막을 생성한다

  3. 자막을 바탕으로 재미있을 부분을 찾는다

  4. 유저의 후편집 시간

  5. 후편집이 끝났을 경우 자막을 입혀shorts를 렌더링한다

쇼츠를 만드는 데 드는 각각의 단계가 컴퓨팅 리소스도 크게 필요하고, 시간도 오래 걸리는 작업들이었기 때문에 비동기로 구성하고 싶었고, 일감의 순차적 처리를 위해 메시지 큐 기반의 Event-Driven-Architecture를 구성했습니다.

또한 리소스가 많이 드므로 인스턴스의 가격이 비쌀 수 밖에 없는데, 언제 요청이 올지 모르므로 비용 절감을 위해 각 세부 로직 서버는 서버리스로 구축하였고, 동시에 오토스케일링 가능하도록 구성했습니다.

무튼, 저희는 이 중 자막 생성 서버에서의 이야기를 해보고자 합니다.

OpenAI Whisper

2262
GitHub - openai/whisper: Robust Speech Recognition via Large-Scale Weak Supervision
Robust Speech Recognition via Large-Scale Weak Supervision - openai/whisper
https://github.com/openai/whisper

자막 생성에 있어서는 사실 OpenAI의 Whisper를 따라올 친구가 없는 것 같습니다.

어마어마한 성능을 보여주고 있고, 특히 한국어의 경우 다른 언어들에 비해서 월등한 성능을 보여주고 있습니다. 왜인지는 모르겠지만요 ㅋㅋ

심지어는 위에 올린, 공식 레포지토리의 설명 이미지에도 한글이 당당히 올라가 있죠

2263

(한국어가 어마어마한 상위권을 기록 중)

그래서 저희는 짧은 SW마에스트로 기간이니, 빠르게 Whisper를 사용해보았습니다.

단어별 타임스탬프가 필요해

자막 생성은 손쉽고 간단하게 이루어졌습니다. 하지만, 저희는 shorts를 만드는 서비스이기 때문에 한번에 보여줄 수 있는 자막의 길이가 길 수 없습니다.

최대 20글자 정도가 한계인데요. 딱 지금 이 문장, 최대 20글자 정도가 한계인데요. 이게 18글자입니다.

하지만 Whisper에서 생성해주는 자막 한 문장의 길이는 길어도 너무 길었습니다.

2264

적당히 해야지, 너무 길다보니 화면을 다 가리는 현상이 발생했습니다.

이 점은 위에서 언급한, 인프런에서도 똑같이 발생한 문제였습니다.

자막을 어떻게 생성할 것인가는 음성 데이터로부터 텍스트를 추출하는 것에서 끝나지 않았습니다.

whisper.cpp, Whisper, WhisperX 모두 --max_line_width 와 같은 한 줄의 길이를 제한하는 옵션이 있었는데요, 문제는 이 옵션이 동작하지 않는다는 것이었습니다. (…)
한국어라서 동작하지 않는 것이라고 추정하고 있습니다.

그래서 모델이 뽑아준 자막 데이터를 보면 줄마다 길이가 들쭉날쭉하고 문장이 끝날 때 줄이 넘어가는 경우가 거의 없었으며 그로 인해 가독성이 꽝인 상태였습니다.

인프런에서는 이를 해결하기 위해, 생성된 자막을 해체하고 분석하여 다시 재조립하는 과정을 거쳤습니다. 그리고 이를 가능하게 해주었던게 단어 별 타임스탬프였죠.

그리고 이거는 Whisper가 아닌, WhisperX에서 제공합니다.

WhisperX

GitHub - m-bain/whisperX: WhisperX: Automatic Speech Recognition with Word-level Timestamps (& Diarization)
WhisperX: Automatic Speech Recognition with Word-level Timestamps (& Diarization) - m-bain/whisperX
https://github.com/m-bain/whisperX

WhisperX: Automatic Speech Recognition with Word-level Timestamps (& Diarization)

WhisperX: 단어 수준 타임스탬프를 통한 자동 음성 인식(및 발음)

본격적으로 이것에 대해 알아봅시다. 복잡한 개념은 저도 잘 모르니, 빠르게 사용해볼까요.

의존성 설치

pip install git+https://github.com/m-bain/whisperx.git

먼저, pip install을 해서 의존성을 설치해줍시다. 사실 파이썬 생태계에 익숙하지 않다보니 (Java, Node 쪽만 자주 써봄) github url로 의존성을 설치할 수 있다는 점은 신기했네요.

꽤 용량이 큽니다. 설치 시켜두고 잠깐 쉬다 오세요.

영상에서 오디오 추출 (필요하다면)

제가 영상 얘기를 잔뜩 했지만, WhisperX는 영상이 아닌 오디오로 음성을 추출합니다.

그러니 FFMPEG 등을 활용해서 오디오 파일만 추출합시다.

def convert_audio(video_id: int, video_path: str) -> str:
    audio_path = f"{os.environ.get("audio_path")}/{video_id}.mp3"

    ffmpeg.input(video_path)\
        .output(audio_path, y='-y')\
        .run()

    return audio_path

저는 ffmpeg-python를 사용했습니다.

자막 추출하기 전, 비즈니스 로직 수호하기

WhisperX를 사용하는 코드에 대해 얘기할 순서지만, 저는 Cotuber 프로젝트에서의 용례를 얘기하고 있으니 딴 얘기 좀 하겠습니다.

자막을 생성하는 것은 비즈니스 로직이지만, 이거에 WhisperX를 사용한다는 것은 세부사항이거든요.

Whisper에서 WhisperX로 라이브러리를 변경하고 있는 상황이지만, 그렇다고 비즈니스 로직이 수정될 수는 없죠.

객체지향 애호가로써 인터페이스로 추상화할 수 밖에 없다는 거에요

from abc import *
from dataclasses import dataclass


@dataclass
class Subtitle:
    start: float
    end: float
    subtitle: str


@dataclass
class SubtitleResult:
    subtitles: list[Subtitle]


class SubtitleGenerator:
    @abstractmethod
    def generate_subtitle(self, audio_path: str) -> SubtitleResult:
        pass

하지만 파이썬은 interface가 없습니다. 통탄을 금치 못하겠네요. 추상 클래스로 빼줍시다.

WhisperX 진짜 써보기

from subtitle import SubtitleGenerator, SubtitleResult, Subtitle
import whisperx


class WhisperXSubtitleGenerator(SubtitleGenerator):
    # 옵션은 자유롭게 설정하세요~
    __device = "cpu"
    __whisper_arch = "small"
    __batch_size = 16  # reduce if low on GPU mem
    __compute_type = "int8"  # change to "int8" if low on GPU mem (may reduce accuracy)

    def generate_subtitle(self, audio_path: str) -> SubtitleResult:
        audio, result = self.__transcribe_with_original_whisper(audio_path)
        result = self.__align_whisper_output(audio, result)

        words = sum(list(map(lambda x: x["words"], result["segments"])), [])
        words = self.__fill_no_start_and_end(words)

        return SubtitleResult(self.__map_to_subtitle_list(words))

세부 설명을 하기 전, 간단히 로직을 나타내면 이렇습니다.

  1. Whisper를 통해 자막을 추출합니다.

  2. 생성된 자막을 WhisperX를 통해 단어별 타임스탬프를 뽑습니다.

  3. 영어가 섞이면 타임스탬프를 못 뽑는데, 이를 보간해줍니다.

  4. DTO로 변환 후 return 합니다.

참고로 위에 준 옵션은, pytorch의 그것들과 같습니다. gpu를 활용할 것이라면 __devicecuda로 바꿔주세요.

whisper_arch는 모델 타입을 지정하는 것인데, 저희는 개발 단계다보니 속도와 모델 용량을 위해 small로 두었습니다.

Whisper를 통해 자막 추출

def __transcribe_with_original_whisper(self, audio_path: str):
    model_dir = os.environ.get("model_path")

    model = whisperx.load_model(
        whisper_arch=self.__whisper_arch,
        device=self.__device,
        compute_type=self.__compute_type,
        download_root=model_dir # 이걸 안 넣으면, 해당 함수 실행 시 자동으로 모델 다운
    )

    audio = whisperx.load_audio(audio_path)

    result = model.transcribe(
        audio=audio,
        language='ko',
        batch_size=self.__batch_size
    )

    print(result["segments"]) # 결과 확인해보기
    return audio, result

자막을 뽑는 부분입니다.

whisperx.load_model 을 통해 모델을 불러옵니다. 처음 시도하는 분들이라면 당연히 모델이 다운받아져 있지 않을텐데, download_root 옵션을 빼고 실행할 경우 요청할 때마다 자동으로 모델의 다운을 시작합니다.

매번 그러면 시간이 너무 오래 걸리니, 미리 한번 다운해두고 해당 옵션에 모델의 주소를 넣어줍시다.

그 후 model.transcribe를 통해 자막을 추출합니다.

language 옵션을 넣지 않으면 음성을 기반으로 자동으로 언어를 인식하는데, 시간이 조금 걸리기도 하고, 저희는 한국어 영상만 등록할 수 있다는 가설을 세웠으므로 시원하게 ko를 넣었습니다.

단어별 타임스탬프

def __align_whisper_output(self, audio, result):
    model_a, metadata = whisperx.load_align_model(
        language_code=result["language"],
        device=self.__device
    )

    result = whisperx.align(
        transcript=result["segments"],
        model=model_a,
        align_model_metadata=metadata,
        audio=audio,
        device=self.__device,
        return_char_alignments=False
    )

    print(result["segments"])  # 결과 확인해보기
    return result

그 후에는 audio와 자막 생성 결과를 통해 단어 별 타임스탬프를 뽑습니다.

다음은 결과값입니다. (js가 보기 편해서 js로 포맷팅 했습니다)

const result = [
  {
    start: 3953.985,
    end: 3975.916,
    text: " 저의 축사는 이만 줄이도록 하겠습니다 그럼 오늘도 좋은 행사 잘 보내시고 행사 준비하시고 또 발표하시는 분들 모두 수고하셨습니다 행사 잘 마무리 하시기 바랍니다 감사합니다 뮤스콘23에 참여하신 개발자분들 반갑습니다 저는 k-pop 글로벌",
    words: [
      { word: "저의", start: 3953.985, end: 3954.425, score: 0.002 },
      { word: "축사는", start: 3954.525, end: 3954.905, score: 0.049 },
      { word: "이만", start: 3955.005, end: 3955.206, score: 0.0 },
      { word: "줄이도록", start: 3955.986, end: 3956.346, score: 0.148 },
      { word: "하겠습니다", start: 3956.446, end: 3957.587, score: 0.123 },
      { word: "그럼", start: 3957.707, end: 3957.947, score: 0.006 },
      { word: "오늘도", start: 3958.307, end: 3958.987, score: 0.083 },
      { word: "좋은", start: 3959.188, end: 3959.328, score: 0.29 },
      { word: "행사", start: 3959.368, end: 3959.788, score: 0.087 },
      { word: "잘", start: 3959.928, end: 3960.008, score: 0.06 },
      { word: "보내시고", start: 3960.088, end: 3960.508, score: 0.211 },
      { word: "행사", start: 3961.229, end: 3961.449, score: 0.124 },
      { word: "준비하시고", start: 3961.549, end: 3962.109, score: 0.168 },
      { word: "또", start: 3962.229, end: 3962.309, score: 0.004 },
      { word: "발표하시는", start: 3962.389, end: 3963.109, score: 0.105 },
      { word: "분들", start: 3963.21, end: 3963.41, score: 0.063 },
      { word: "모두", start: 3963.49, end: 3963.77, score: 0.073 },
      { word: "수고하셨습니다", start: 3965.09, end: 3965.911, score: 0.09 },
      { word: "행사", start: 3966.631, end: 3966.771, score: 0.174 },
      { word: "잘", start: 3966.831, end: 3966.931, score: 0.0 },
      { word: "마무리", start: 3966.971, end: 3967.131, score: 0.033 },
      { word: "하시기", start: 3967.211, end: 3967.452, score: 0.123 },
      { word: "바랍니다", start: 3967.512, end: 3968.652, score: 0.174 },
      { word: "감사합니다", start: 3968.672, end: 3971.053, score: 0.02 },
      { word: "뮤스콘23에", start: 3971.273, end: 3972.314, score: 0.05 },
      { word: "참여하신", start: 3972.434, end: 3972.934, score: 0.086 },
      { word: "개발자분들", start: 3973.014, end: 3973.595, score: 0.01 },
      { word: "반갑습니다", start: 3973.675, end: 3974.215, score: 0.232 },
      { word: "저는", start: 3975.255, end: 3975.516, score: 0.074 },
      { word: "k-pop" },
      { word: "글로벌", start: 3975.656, end: 3975.916, score: 0.0 },
    ],
  },
];

text에는 원래 Whisper가 뽑은 자막이 들어가구요, words에 해당 문장의 단어 스탬프가 들어갑니다.

그런데 맨 밑을 보면, k-pop이라는 자막에는 start와 end 값이 없는 것이 보이시나요?

시간 추적에 실패한 값 보간하기

wav2vec 한국어 모델 기반이어서 한글이 아니면 음절 시간 추적을 못 하는 문제가 있었습니다.

-인프런 기술 블로그

WhisperX는 한국어가 아니면 음절 시간의 추적을 못하는 이슈가 있었습니다. 그래서 영어 단어 같은 것들이 들어갈 경우 이후의 로직에서 에러를 발생하는 문제가 있어, 이를 보간해주었습니다.

WhisperX가 시간 추적에 실패한 음절의 시간 값은 시간 추적에 성공한 가장 가까운 전, 후 음절의 시간 또는 문장의 시작 및 끝 시간으로부터 선형 보간한다.

-인프런 기술 블로그

인프런에서는 추적에 성공한 가장 가까운 전, 후 음절의 시간으로 선형보간하는 방식으로 이를 채워넣었습니다.

그런데 저희는 선형보간까지는 하지 않았고 그냥 가장 가까운 후 음절의 start를 end로, 전 음절의 end를 start로 해두었습니다.

words = sum(list(map(lambda x: x["words"], result["segments"])), [])
words = self.__fill_no_start_and_end(words)

일단 위에서 나온 단어 스탬프들에서 원본 문장의 정보는 불필요하므로, 이를 flatmap해서 단어 타임스탬프와 관련된 데이터만 추출했습니다.

[
      { word: "저의", start: 3953.985, end: 3954.425, score: 0.002 },
      { word: "축사는", start: 3954.525, end: 3954.905, score: 0.049 },
      { word: "이만", start: 3955.005, end: 3955.206, score: 0.0 },
      { word: "줄이도록", start: 3955.986, end: 3956.346, score: 0.148 },
      { word: "하겠습니다", start: 3956.446, end: 3957.587, score: 0.123 },
      { word: "그럼", start: 3957.707, end: 3957.947, score: 0.006 },
      { word: "오늘도", start: 3958.307, end: 3958.987, score: 0.083 },
      { word: "좋은", start: 3959.188, end: 3959.328, score: 0.29 },
      { word: "행사", start: 3959.368, end: 3959.788, score: 0.087 },
      { word: "잘", start: 3959.928, end: 3960.008, score: 0.06 },
      { word: "보내시고", start: 3960.088, end: 3960.508, score: 0.211 },
      { word: "행사", start: 3961.229, end: 3961.449, score: 0.124 },
      { word: "준비하시고", start: 3961.549, end: 3962.109, score: 0.168 },
      { word: "또", start: 3962.229, end: 3962.309, score: 0.004 },
      { word: "발표하시는", start: 3962.389, end: 3963.109, score: 0.105 },
      { word: "분들", start: 3963.21, end: 3963.41, score: 0.063 },
      { word: "모두", start: 3963.49, end: 3963.77, score: 0.073 },
      { word: "수고하셨습니다", start: 3965.09, end: 3965.911, score: 0.09 },
      { word: "행사", start: 3966.631, end: 3966.771, score: 0.174 },
      { word: "잘", start: 3966.831, end: 3966.931, score: 0.0 },
      { word: "마무리", start: 3966.971, end: 3967.131, score: 0.033 },
      { word: "하시기", start: 3967.211, end: 3967.452, score: 0.123 },
      { word: "바랍니다", start: 3967.512, end: 3968.652, score: 0.174 },
      { word: "감사합니다", start: 3968.672, end: 3971.053, score: 0.02 },
      { word: "뮤스콘23에", start: 3971.273, end: 3972.314, score: 0.05 },
      { word: "참여하신", start: 3972.434, end: 3972.934, score: 0.086 },
      { word: "개발자분들", start: 3973.014, end: 3973.595, score: 0.01 },
      { word: "반갑습니다", start: 3973.675, end: 3974.215, score: 0.232 },
      { word: "저는", start: 3975.255, end: 3975.516, score: 0.074 },
      { word: "k-pop" },
      { word: "글로벌", start: 3975.656, end: 3975.916, score: 0.0 },
    ]

이런 형태겠죠?

def __fill_no_start_and_end(self, words):
    for i in range(len(words)):
        if "start" not in words[i]:
            if i == 0:
                words[i]["start"] = 0
            else:
                words[i]["start"] = words[i - 1]["end"]
        if "end" not in words[i]:
            if i == len(words) - 1:
                words[i]["end"] = words[i]["start"] + 1
            else:
                for j in range(i + 1, len(words)):
                    if "start" in words[j]:
                        words[i]["end"] = words[j]["start"]
                        break
                else:
                    words[i]["end"] = words[i]["start"] + 1

    return words

짧은 코드인데, 워낙 if-else의 중첩이 많아 잘 이해가 되실지 모르겠습니다.

  1. start가 없으면, 전의 음절의 end를 가져다 쓴다.

    1. 이게 첫번째 음절이면 시작 시간은 0이 된다

    2. 참고로 전의 음절은 반드시 end가존재합니다 (2의 로직에서 end를 다 채워넣어주기 때문)

  2. end가 없으면, 후의 음절 중 가장 가까운 start를 가져다 쓴다.

    1. 후의 음절이 전부 start가 없으면 그냥 start에 1초 더한 값을 end로 쓴다

DTO 매핑하기

subtitles: list[Subtitle] = self.__map_to_subtitle_list(words)

return SubtitleResult(subtitles)

마지막은 DTO 매핑한 후 리턴이구요, 이렇게 단어스탬프를 전부 뽑은 뒤 리턴해주면 WhisperX의 로직이 끝이 납니다.

자막 해체 분석 및 재조립하기

단어별 타임스탬프를 뽑았으니, 쇼츠에 맞는 적절한 문장의 길이와 시간으로 자막을 구성할 차례입니다.

  • 직전 단어까지 한 줄이 0.5초 미만이면 다른 어떤 조건을 만족하더라도 줄을 구분하지 않는다.

  • 직전 단어까지 한 줄이 10초를 넘어가면 이번 단어부터 줄을 구분한다.

  • 이번 단어로 한 줄이 10글자를 초과하고 형태소 분석 결과 문장 수에 변화가 일어나면 이번 단어부터 줄을 구분한다.

  • 이번 단어로 한 줄이 50글자를 초과하면 이번 단어부터 줄을 구분한다.

  • 문장의 시작이 직전 문장의 끝에서 0.5초 이내인 경우 문장의 시작으로 직전 문장의 끝 시간을 대신 사용한다.

-인프런 기술블로그

인프런에서는 다음과 같은 비즈니스 로직을 통해 문장을 재조립하고 있습니다. 저희도 이 정도 비즈니스 로직은 짤 수 있죠

두괄식으로, 전체 코드를 먼저 올려보겠습니다.

from kiwi_sentence_end_checker import sentence_end_checker
from sentence_end_checker import SentenceEndChecker
from subtitle import Subtitle, SubtitleResult


class SubtitleReconstructor:
    MAX_LINE_LENGTH = 50
    MIN_FULL_SENTENCE_LENGTH = 10
    DURATION_MIN_THRESHOLD = 0.5
    DURATION_MAX_THRESHOLD = 7

    sentence_end_checker: SentenceEndChecker

    def __init__(self, sentence_end_checker: SentenceEndChecker):
        self.sentence_end_checker = sentence_end_checker

    def reconstruct(self, words: list[Subtitle]) -> SubtitleResult:
        subtitles: list[Subtitle] = []

        current_line: str = ""
        start_time: float = 0
        end_time: float = 0

        for word_data in words:
            current_duration = end_time - start_time

            if self._shouldSplit(current_line, current_duration):
                subtitles.append(Subtitle(
                    subtitle=current_line.strip(),
                    start=start_time,
                    end=end_time
                ))
                
                current_line = ""
                start_time = word_data.start

            current_line += word_data.subtitle + " "

            if start_time == 0:
                start_time = word_data.start

            end_time = word_data.end

        if current_line:
            subtitles.append(Subtitle(
                subtitle=current_line.strip(),
                start=start_time,
                end=end_time
            ))

        return SubtitleResult(subtitles)

    def _shouldSplit(self, line: str, duration: float):
        if self._isTooShortDuration(duration):
            return False

        return self._isTooLongDuration(duration) \
            or self._isTooLongLine(line) \
            or self._isSentenceEndedWithProperLength(line)

    def _isTooShortDuration(self, duration: float) -> bool:
        return duration < self.DURATION_MIN_THRESHOLD

    def _isTooLongDuration(self, duration: float) -> bool:
        return duration > self.DURATION_MAX_THRESHOLD

    def _isTooLongLine(self, line: str) -> bool:
        return len(line) > self.MAX_LINE_LENGTH

    def _isSentenceEndedWithProperLength(self, line: str) -> bool:
        return self.sentence_end_checker.is_sentence_ended(line) \
            and len(line) >= self.MIN_FULL_SENTENCE_LENGTH

코드가 기니까 차근차근 단계 별로 진행해봅시다.

메인 로직

def reconstruct(self, words: list[Subtitle]) -> SubtitleResult:
    subtitles: list[Subtitle] = []

    current_line: str = ""
    start_time: float = 0
    end_time: float = 0

    for word_data in words:
        current_duration = end_time - start_time

        if self._shouldSplit(current_line, current_duration):
            subtitles.append(Subtitle(
                subtitle=current_line.strip(),
                start=start_time,
                end=end_time
            ))

            current_line = ""
            start_time = word_data.start

        current_line += word_data.subtitle + " "

        if start_time == 0:
            start_time = word_data.start

        end_time = word_data.end

    if current_line:
        subtitles.append(Subtitle(
            subtitle=current_line.strip(),
            start=start_time,
            end=end_time
        ))

    return SubtitleResult(subtitles)

메소드 분리를 해서 좀 깔끔하게 만들어보고 싶었는데, 어려웠습니다. 로직을 설명해볼게요.

  1. 현재까지 생성된 문장을, flush하고 새 문장으로 시작해야하는지 체크한다

    1. 그러할 경우, subtitles 배열에 자막을 추가한다.

    2. 그 후 현재 문장을 빈 문자열로 바꾸고, 자막 시작 시간을 이번 단어 시작 시간으로 변경한다

  2. 문장에 이번 단어를 추가한 후, 자막 끝 시간을 이번 단어로 반영한다

  3. 단어를 다 돌았는데 문장이 남아있으면, 남은 문장을 자막에 flush하고 종료한다

자막을 언제 자를 것인가

class SubtitleReconstructor:
    MAX_LINE_LENGTH = 20
    MIN_FULL_SENTENCE_LENGTH = 10
    DURATION_MIN_THRESHOLD = 0.5
    DURATION_MAX_THRESHOLD = 7

    # ... 로직들

    def _shouldSplit(self, line: str, duration: float):
        if self._isTooShortDuration(duration):
            return False

        return self._isTooLongDuration(duration) \
            or self._isTooLongLine(line) \
            or self._isSentenceEndedWithProperLength(line)

    def _isTooShortDuration(self, duration: float) -> bool:
        return duration < self.DURATION_MIN_THRESHOLD

    def _isTooLongDuration(self, duration: float) -> bool:
        return duration > self.DURATION_MAX_THRESHOLD

    def _isTooLongLine(self, line: str) -> bool:
        return len(line) > self.MAX_LINE_LENGTH

    def _isSentenceEndedWithProperLength(self, line: str) -> bool:
        return self.sentence_end_checker.is_sentence_ended(line) \
            and len(line) >= self.MIN_FULL_SENTENCE_LENGTH

인프런의 로직을 많이 채용해보았어요.

  • 직전 단어까지 한 줄이 0.5초 미만이면 다른 어떤 조건을 만족하더라도 줄을 구분하지 않는다.

  • 직전 단어까지 한 줄이 10초를 넘어가면 이번 단어부터 줄을 구분한다.

  • 이번 단어로 한 줄이 10글자를 초과하고 형태소 분석 결과 문장 수에 변화가 일어나면 이번 단어부터 줄을 구분한다.

  • 이번 단어로 한 줄이 50글자를 초과하면 이번 단어부터 줄을 구분한다.

  • 문장의 시작이 직전 문장의 끝에서 0.5초 이내인 경우 문장의 시작으로 직전 문장의 끝 시간을 대신 사용한다.

-인프런 기술블로그

아까 소개한 인프런의 로직인데요, 인프런은 강의 영상을 올리는 사이트고 휴대폰이 아닌 데스크탑으로 보통 보기 때문에 자막이 좀 문장 단위여야 보기 편합니다.

그래서 저희 서비스에 알맞게, 옵션은 적절히 수정해보았어요. 최대 길이는 20글자. 최대 한 줄 시간은 7초가 적당하다 판단했습니다.

이 점을 고려하고 코드를 살펴본다면, 충분히 가독성 있는 로직을 작성했다 자신할 수 있어요. 다만 여기서 추가적으로 고려한 부분이 있었습니다.

형태소 분석

자막이라는게, 적당한 길이와 시간도 물론 중요하지만, 문장 단위로 끊겨야 보기가 편하거든요. 인프런에서는 이를 고민하였고 형태소 분석 라이브러리인 Kiwi를 사용해서 문장 단위로 자막을 끊기 위해 노력하셨어요.

저희도 이 점을 공감하여 따라가보았습니다.

GitHub - bab2min/Kiwi: Kiwi(지능형 한국어 형태소 분석기)
Kiwi(지능형 한국어 형태소 분석기). Contribute to bab2min/Kiwi development by creating an account on GitHub.
https://github.com/bab2min/Kiwi
GitHub - bab2min/kiwipiepy: Python API for Kiwi
Python API for Kiwi. Contribute to bab2min/kiwipiepy development by creating an account on GitHub.
https://github.com/bab2min/kiwipiepy

저희도 키위를 사용하였고, 파이썬으로 래핑된 Kiwipiepy를 활용했습니다.

from abc import abstractmethod


class SentenceEndChecker:
    def __init__(self):
        pass

    @abstractmethod
    def is_sentence_ended(self, sentence):
        pass

아까도 언급했죠. 형태소 분석을 한다는 건 비즈니스 로직이고, 뭘 쓰는지는 세부사항이라고. 이번에도 추상클래스로 추상화하고, 자막 해체분석 클래스에서 생성자 기반 DI를 통해 주입받도록 구성했습니다.

class SubtitleReconstructor:
    MAX_LINE_LENGTH = 50
    MIN_FULL_SENTENCE_LENGTH = 10
    DURATION_MIN_THRESHOLD = 0.5
    DURATION_MAX_THRESHOLD = 7

    sentence_end_checker: SentenceEndChecker

    def __init__(self, sentence_end_checker: SentenceEndChecker):
        self.sentence_end_checker = sentence_end_checker

    # ...

👍

from sentence_end_checker import SentenceEndChecker

from kiwipiepy import Kiwi


SENTENCE_END_TAG = ['SF', 'EF']


class KiwiSentenceEndChecker(SentenceEndChecker):
    kiwi = Kiwi()

    def is_sentence_ended(self, sentence):
        tokens = self.kiwi.tokenize(sentence)
        return tokens[-1].tag in SENTENCE_END_TAG
GitHub - bab2min/kiwipiepy: Python API for Kiwi
Python API for Kiwi. Contribute to bab2min/kiwipiepy development by creating an account on GitHub.
https://github.com/bab2min/kiwipiepy#%ED%92%88%EC%82%AC-%ED%83%9C%EA%B7%B8

해당 URL의 품사태그 에서 여러 형태소들의 속성을 확인할 수 있는데, 이 중 종결 부호, 종결 어미 를 마주했을 때만 문장이 종료되었다고 판단했습니다.

결론

2265

이렇게 길었던, 자막 생성의 로직이 완료되었습니다.

결과적으로 자막도 깔끔하고, 길이도 적절하게 구성되었어요.

훌륭하고 성능 좋은 모델인 Whisper를 사용할 수 있게 해준 OpenAI와 WhisperX의 메인테이너 분들, 그리고 힘들었을 사고 과정을 공유해주신 인프런에 무한한 감사를 보내며, 글을 마무리하고 싶습니다.

감사합니다!


- 컬렉션 아티클






반갑습니다 😄