🗨️

faster-whisper(Whisper-Large-V3)で字幕(srt)をいい感じに作る

2024/10/13に公開

この記事で、faster-whisperを使って。Whisper-Large-V3で音声文字起こしをして。事前に指定した用語変換やフィラーワードの削除等をした上でセグメントをいい感じに再構築してSRTファイルとして出力するPythonスクリプトを紹介します

目次

  1. はじめに
  2. 環境構築・使用方法
  3. 各処理の解説
    1. 用語辞書の読み込み、モデルの初期化
    2. 文字起こし( transcribe_audio() )
    3. 用語の置換処理( replace_terms() )
    4. srt化( generate_srt_segments() ) & 保存
  4. 結論

1 はじめに

背景

Youtubeの字幕用にWhisper V3を使おうと思ったのですが、デフォルトのWhisperの出力だと「フィラーワード( あーええと など )を含む」「セグメントの粒度が大きい(画面に納まりきらない)」「専門用語を間違える」等が発生し使い勝手が悪かったので結果に対する後処理でこれらを修正できるようにしました

処理内容

以下の手順で処理を行います

  1. Whisperによる文字起こし実施
  2. 用語置換(フィラーワード除去を含む)
    • CSVで置換辞書を定義しておきます
    • wordをまたぐ場合にも対応しています
  3. srt化&ファイル保存
    • 指定した文字数に納まるようセグメントを区切りなおします

リポジトリ・実装コード全体

https://github.com/mossan-hoshi/audio2srt?tab=readme-ov-file

コード全体
from faster_whisper import WhisperModel
from pathlib import Path
import srt
from datetime import timedelta
from typing import Dict, List
import csv
from dataclasses import dataclass


@dataclass
class Word:
    word: str
    start: float
    end: float


def load_replace_terms(file_path: str = "./replace_terms.csv") -> Dict[str, str]:
    """置換用語をCSVファイルから読み込む"""
    replace_terms_dict = {}
    try:
        with open(file_path, "r", encoding="utf-8") as csvfile:
            reader = csv.reader(csvfile)
            replace_terms_dict = {row[0]: row[1] for row in reader if len(row) >= 2}
    except FileNotFoundError:
        print(f"警告: {file_path} が見つかりません。空の置換辞書を使用します。")
    return replace_terms_dict


DEFAULT_REPLACE_TERMS = load_replace_terms()


def transcribe_audio(model: WhisperModel, audio_file_path: Path, language: str = None) -> List[Word]:
    """音声ファイルを文字起こしし、単語リストを返す"""
    transcribe_args = {
        "audio": audio_file_path,
        "beam_size": 5,
        "word_timestamps": True,
    }
    if language:
        transcribe_args["language"] = language

    segments, info = model.transcribe(**transcribe_args)
    print(f"検出された言語: '{info.language}' (確率: {info.language_probability})")
    return [
        Word(word=word.word, start=word.start, end=word.end)
        for segment in segments
        for word in segment.words
    ]


def replace_terms(
    words: List[Word], term_replace_dict: Dict[str, str] = {}
) -> List[Word]:
    """単語リスト内の特定の用語を置換する"""
    # 全ての単語を連結した文字列を作成
    concat_str = "".join(word.word for word in words)
    # 各単語の開始インデックスを計算
    word_start_indices = [
        sum(len(words[i].word) for i in range(j)) for j in range(len(words))
    ]

    # 置換辞書の各エントリに対して処理を行う
    for term_to_search, term_to_replace in term_replace_dict.items():
        start_index = 0
        while True:
            # 検索語を連結文字列内で探す
            found_index = concat_str.find(term_to_search, start_index)
            if found_index == -1:
                break

            # 検索語の終了インデックスを計算
            end_index = found_index + len(term_to_search)
            # 影響を受ける単語のインデックスを特定
            affected_word_indices = [
                i
                for i, word_start in enumerate(word_start_indices)
                if (word_start <= found_index < word_start + len(words[i].word))
                or (word_start < end_index <= word_start + len(words[i].word))
                or (found_index <= word_start < end_index)
            ]

            # 置換処理を実行
            replace_term(words, word_start_indices, term_to_replace, found_index, end_index, affected_word_indices)

            # 連結文字列を更新
            concat_str = (
                concat_str[:found_index] + term_to_replace + concat_str[end_index:]
            )
            # 単語の開始インデックスを調整
            length_diff = len(term_to_replace) - (end_index - found_index)
            word_start_indices = [
                index + length_diff if index > found_index else index
                for index in word_start_indices
            ]
            # 次の検索開始位置を設定
            start_index = found_index + len(term_to_replace)

    return words

def replace_term(words, word_start_indices, replace_term, found_index, end_index, affected_word_indices):
    """単語リスト内の特定の用語を置換する補助関数"""
    for idx, i in enumerate(affected_word_indices):
        word = words[i]
        word_start = word_start_indices[i]
        # 置換対象の相対的な開始位置を計算
        relative_start = max(0, found_index - word_start)
        # 置換対象の相対的な終了位置を計算
        relative_end = min(len(word.word), end_index - word_start)

        if idx == 0:
            # 最初の影響を受ける単語の場合、置換を行う
            word.word = (
                        word.word[:relative_start]
                        + replace_term
                        + word.word[relative_end:]
                    )
        else:
            # それ以外の影響を受ける単語の場合、該当部分を削除
            word.word = word.word[:relative_start] + word.word[relative_end:]


def create_subtitle(index: int, start: float, end: float, content: str) -> srt.Subtitle:
    """字幕オブジェクトを作成する"""
    return srt.Subtitle(
        index=index,
        start=timedelta(seconds=start),
        end=timedelta(seconds=end),
        content=content.strip(),
    )


def generate_srt_segments(
    words: List[Word],
    char_num: int = 48,
    max_line_str_num: int = 24,
    gap_seconds_threshold: int = 3,
) -> List[srt.Subtitle]:
    """単語リストからSRT形式の字幕セグメントを生成する"""
    srt_segments = []  # 生成されたSRT字幕セグメントのリスト
    current_segment = ""  # 現在処理中の字幕セグメントのテキスト
    segment_start = None  # 現在の字幕セグメントの開始時間
    segment_end = None  # 現在の字幕セグメントの終了時間
    segment_index = 1  # 字幕セグメントのインデックス

    for word in words:
        if segment_start is None:
            segment_start = word.start  # 最初の単語の場合、セグメント開始時間を設定
        elif (
            word.start - segment_end > gap_seconds_threshold
            or len(current_segment) + len(word.word) > char_num
        ):
            # 新しいセグメントを開始する条件:
            # 1. 前の単語との間隔が閾値を超える
            # 2. 現在のセグメントの文字数が上限を超える
            srt_segments.append(
                create_subtitle(
                    segment_index,
                    segment_start,
                    segment_end,
                    "\n".join(
                        [
                            current_segment[i : i + max_line_str_num]
                            for i in range(0, len(current_segment), max_line_str_num)
                        ]
                    ),
                )
            )
            segment_index += 1  # 次のセグメントのインデックスを増やす
            current_segment = ""  # 新しいセグメントのテキストをリセット
            segment_start = word.start  # 新しいセグメントの開始時間を設定

        current_segment += word.word  # 現在の単語をセグメントに追加
        segment_end = word.end  # セグメントの終了時間を更新

        print(f"  [{word.start:.2f}s -> {word.end:.2f}s] {word.word}")  # デバッグ用出力

    if current_segment:
        # 最後のセグメントを追加
        srt_segments.append(
            create_subtitle(segment_index, segment_start, segment_end, current_segment)
        )

    return srt_segments
def main(
    audio_file_path: Path,
    model: WhisperModel,
    term_replace_dict: Dict[str, str],
    char_num: int = 48,
    max_line_str_num: int = 24,
    gap_seconds_threshold: int = 3,
    language: str = None,
):
    """メイン処理関数"""
    # 音声ファイルを文字起こし
    words = transcribe_audio(model, audio_file_path, language)
    
    # 特定の用語を置換
    words = replace_terms(words, term_replace_dict)
    
    # SRT形式のセグメントを生成
    srt_segments = generate_srt_segments(
        words, char_num, max_line_str_num, gap_seconds_threshold
    )

    # SRTセグメントを文字列に変換
    srt_content = srt.compose(srt_segments)

    # SRTファイル保存
    output_file = audio_file_path.with_suffix(".srt")
    with open(output_file, "w", encoding="utf-8") as srt_file:
        srt_file.write(srt_content)

    print(f"SRTファイルを保存しました: {output_file}")


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        description="Whisperモデルを使用して音声を文字起こしする"
    )
    parser.add_argument("audio_file", help="文字起こしする音声ファイルのパス")
    parser.add_argument(
        "--model",
        default="large-v3",
        help="使用するWhisperモデル (デフォルト: large-v3)",
    )
    parser.add_argument(
        "--device", default="cuda", help="計算に使用するデバイス (デフォルト: cuda)"
    )
    parser.add_argument(
        "--compute_type",
        default="float16",
        help="モデルの計算タイプ (デフォルト: float16)",
    )
    parser.add_argument(
        "--language",
        default=None,
        help="文字起こしに使用する言語 (デフォルト: 自動検出)",
    )
    parser.add_argument(
        "--replace_terms_path",
        default="./replace_terms.csv",
        help="置換用語のCSVファイルパス (デフォルト: ./replace_terms.csv)",
    )
    parser.add_argument(
        "--char_num",
        type=int,
        default=48,
        help="1行あたりの最大文字数 (デフォルト: 48)",
    )
    parser.add_argument(
        "--max_line_str_num",
        type=int,
        default=24,
        help="最大行数 (デフォルト: 24)",
    )
    parser.add_argument(
        "--gap_seconds_threshold",
        type=int,
        default=3,
        help="セグメント間の最大間隔 (デフォルト: 3秒)",
    )

    args = parser.parse_args()

    audio_file_path = Path(args.audio_file)

    model = WhisperModel(args.model, device=args.device, compute_type=args.compute_type)

    term_replace_dict = load_replace_terms(args.replace_terms)

    main(audio_file_path, model, term_replace_dict, args.char_num, args.max_line_str_num, args.gap_seconds_threshold, language=args.language)

2 環境構築・使用方法

  1. Pythonのインストール: 今回は3.11.3を使いましたが、faster_whisperが使えればバージョンは問いません
  • poetry をいれて置いてください pip install poetry
  1. リポジトリンクローン
gh repo clone mossan-hoshi/audio2srt

or

git clone https://github.com/mossan-hoshi/audio2srt.git
  1. 仮想環境の作成
  • Window
poetry install
  1. CSVファイルの準備: 用語設定のためのCSVファイルreplace_terms.csvを必要に応じて編集します。以下のフォーマットで作成してください。
  • 1列目:検出文字列
  • 2列目:置換後文字列(削除したい場合は空を指定)

[用語設定CSVファイルサンプル]

ブレンダー,Blender
ちょっと,
はいえーと,
あー,
えーと,
はい,
うん,
なんか,
ですね,
普通に,
ご視聴ありがとうございました,
サイクル図,Cycles
EV,EeVee
スクラプティング,スカルプティング
チャットGPD,ChatGPT
  1. スクリプトの実行: 最後に、以下のコマンドを実行してスクリプトを実行します。
usage: whisperv3.py [-h] [--model MODEL] [--device DEVICE]
                    [--compute_type COMPUTE_TYPE]
                    [--language LANGUAGE]
                    [--replace_terms_path REPLACE_TERMS_PATH]        
                    [--char_num CHAR_NUM]
                    [--max_line_str_num MAX_LINE_STR_NUM]
                    [--gap_seconds_threshold GAP_SECONDS_THRESHOLD]  
                    audio_file
オプション名 内容 デフォルト値
audio_file 文字起こしする音声ファイルのパス
--model MODEL 使用するWhisperモデル large-v3
--device DEVICE 計算に使用するデバイス cuda
--compute_type COMPUTE_TYPE モデルの計算タイプ float16
--language LANGUAGE 文字起こしに使用する言語 自動検出
--replace_terms_path REPLACE_TERMS_PATH 置換用語のCSVファイルパス ./replace_terms.csv
--char_num CHAR_NUM 1行あたりの最大文字数 48
--max_line_str_num MAX_LINE_STR_NUM 最大行数 24
--gap_seconds_threshold GAP_SECONDS_THRESHOLD セグメント間の最大間隔秒数 3

3 各処理の解説

3.1 用語辞書の読み込み、モデルの初期化

用語辞書は、音声文字起こしの際に特定の用語を適切に置換するために使用されます。これにより、より正確な文字起こしが可能になります。

def load_replace_terms(file_path: str = "./replace_terms.csv") -> Dict[str, str]:
    """置換用語をCSVファイルから読み込む"""
    replace_terms_dict = {}
    try:
        with open(file_path, "r", encoding="utf-8") as csvfile:
            reader = csv.reader(csvfile)
            replace_terms_dict = {row[0]: row[1] for row in reader if len(row) >= 2}
    except FileNotFoundError:
        print(f"警告: {file_path} が見つかりません。空の置換辞書を使用します。")
    return replace_terms_dict

# Whisperモデルの初期化
model = WhisperModel(args.model, device=args.device, compute_type=args.compute_type)

解説

  1. 用語辞書の読み込み:

    • load_replace_terms 関数は、指定されたCSVファイルから置換用語を読み込みます。デフォルトでは、"./replace_terms.csv" というファイルを参照します。
    • CSVファイルは、1列目に検出文字列、2列目に置換後の文字列を持つ形式で作成されている必要があります。
      ファイルが見つからない場合は、警告メッセージを表示し、空の辞書を返します。
  2. Whisperモデルの初期化:

    • WhisperModel クラスのインスタンスを作成します。モデルの種類、計算に使用するデバイス(CPUまたはGPU)、および計算タイプ(例: float16)を指定します。
    • このモデルは、音声ファイルを文字起こしするために使用されます。
    • このようにして、用語辞書を読み込み、Whisperモデルを初期化することで、次の処理に進む準備が整います。これにより、音声ファイルの文字起こしがより正確に行えるようになります。

3.2 文字起こし (transcribe_audio())

transcribe_audio() 関数は、指定された音声ファイルを文字起こしし、単語のリストを返す役割を果たします。この関数は、Whisperモデルを使用して音声データを解析し、各単語の開始時間と終了時間を記録します。

以下が transcribe_audio() 関数のコードです。

def transcribe_audio(model: WhisperModel, audio_file_path: Path, language: str = None) -> List[Word]:
    """音声ファイルを文字起こしし、単語リストを返す"""
    transcribe_args = {
        "audio": audio_file_path,
        "beam_size": 5,
        "word_timestamps": True,
    }
    if language:
        transcribe_args["language"] = language

    segments, info = model.transcribe(**transcribe_args)
    print(f"検出された言語: '{info.language}' (確率: {info.language_probability})")
    return [
        Word(word=word.word, start=word.start, end=word.end)
        for segment in segments
        for word in segment.words
    ]

解説

  1. 関数の引数:

    • model: WhisperModelのインスタンス。音声を文字起こしするために使用します。
    • audio_file_path: 文字起こし対象の音声ファイルのパス。
    • language: 文字起こしに使用する言語。指定しない場合は自動検出されます。
  2. 文字起こしの引数設定:

    • transcribe_args 辞書には、音声ファイルのパス、ビームサイズ(探索の幅)、および単語のタイムスタンプを取得するためのフラグが含まれています。
  3. 言語の指定:

    • language が指定されている場合、transcribe_args に追加されます。これにより、特定の言語での文字起こしが可能になります。
  4. 文字起こしの実行:

    • model.transcribe(**transcribe_args) を呼び出すことで、音声ファイルの文字起こしが行われます。このメソッドは、セグメントと情報を返します。
  5. 言語の出力:

    • 検出された言語とその確率をコンソールに出力します。これにより、どの言語が認識されたかを確認できます。
  6. 単語リストの生成:

    • 各セグメント内の単語をループ処理し、Word クラスのインスタンスを生成してリストに追加します。このリストは、文字起こし結果として返されます。

この関数を使用することで、音声ファイルから効率的に文字起こしを行い、後続の処理に必要な単語情報を取得することができます。

3.3 用語の置換処理 (replace_terms())

replace_terms() 関数は、音声文字起こしの結果として得られた単語リスト内の特定の用語を、事前に用意した置換辞書に基づいて置換する役割を果たします。この処理により、特定の用語が適切に表現され、より自然な字幕が生成されます。

以下が replace_terms() 関数のコードです。

def replace_terms(
    words: List[Word], term_replace_dict: Dict[str, str] = {}
) -> List[Word]:
    """単語リスト内の特定の用語を置換する"""
    # 全ての単語を連結した文字列を作成
    concat_str = "".join(word.word for word in words)
    # 各単語の開始インデックスを計算
    word_start_indices = [
        sum(len(words[i].word) for i in range(j)) for j in range(len(words))
    ]

    # 置換辞書の各エントリに対して処理を行う
    for term_to_search, term_to_replace in term_replace_dict.items():
        start_index = 0
        while True:
            # 検索語を連結文字列内で探す
            found_index = concat_str.find(term_to_search, start_index)
            if found_index == -1:
                break

            # 検索語の終了インデックスを計算
            end_index = found_index + len(term_to_search)
            # 影響を受ける単語のインデックスを特定
            affected_word_indices = [
                i
                for i, word_start in enumerate(word_start_indices)
                if (word_start <= found_index < word_start + len(words[i].word))
                or (word_start < end_index <= word_start + len(words[i].word))
                or (found_index <= word_start < end_index)
            ]

            # 置換処理を実行
            replace_term(words, word_start_indices, term_to_replace, found_index, end_index, affected_word_indices)

            # 連結文字列を更新
            concat_str = (
                concat_str[:found_index] + term_to_replace + concat_str[end_index:]
            )
            # 単語の開始インデックスを調整
            length_diff = len(term_to_replace) - (end_index - found_index)
            word_start_indices = [
                index + length_diff if index > found_index else index
                for index in word_start_indices
            ]
            # 次の検索開始位置を設定
            start_index = found_index + len(term_to_replace)

    return words

解説

  1. 関数の引数:

    • words: 置換対象の単語リスト。Word クラスのインスタンスのリストです。
    • term_replace_dict: 置換用語の辞書。キーが検索する用語、値が置換後の用語です。
  2. 連結文字列の作成:

    • concat_str 変数には、全ての単語を連結した文字列が格納されます。これにより、検索処理が効率的に行えます。
  3. 単語の開始インデックスの計算:

    • word_start_indices には、各単語の開始位置を計算して格納します。これにより、後の置換処理で単語の位置を特定できます。
  4. 置換処理の実行:

    • 置換辞書の各エントリに対して、concat_str 内で検索語を探します。見つかった場合は、そのインデックスを基に影響を受ける単語のインデックスを特定します。
  5. 置換関数の呼び出し:

    • replace_term() 関数を呼び出し、実際の置換処理を行います。この関数は、影響を受ける単語のリストを受け取り、指定された範囲内で単語を置換します。
  6. 連結文字列の更新:

    • 置換後、concat_str を更新し、次の検索開始位置を設定します。これにより、同じ用語が複数回出現する場合でも、正しく処理されます。
  7. 結果の返却:

    • 最終的に、置換処理が完了した単語リストを返します。このリストは、次の処理であるSRT形式の字幕セグメント生成に使用されます。

この関数を使用することで、音声文字起こしの結果をより自然で理解しやすい形に整えることができます。特に、特定の用語やフレーズが頻繁に使用される場合に効果的です。

3.4 srt化( generate_srt_segments() ) & 保存

generate_srt_segments() 関数は、音声文字起こしの結果として得られた単語リストを基に、SRT形式の字幕セグメントを生成する役割を果たします。この関数では、単語の開始時間と終了時間を考慮しながら、適切なタイミングで字幕を分割し、最終的にSRT形式の字幕を作成します。

以下が generate_srt_segments() 関数のコードです。

def generate_srt_segments(
    words: List[Word],
    char_num: int = 48,
    max_line_str_num: int = 24,
    gap_seconds_threshold: int = 3,
) -> List[srt.Subtitle]:
    """単語リストからSRT形式の字幕セグメントを生成する"""
    srt_segments = []  # 生成されたSRT字幕セグメントのリスト
    current_segment = ""  # 現在処理中の字幕セグメントのテキスト
    segment_start = None  # 現在の字幕セグメントの開始時間
    segment_end = None  # 現在の字幕セグメントの終了時間
    segment_index = 1  # 字幕セグメントのインデックス

    for word in words:
        if segment_start is None:
            segment_start = word.start  # 最初の単語の場合、セグメント開始時間を設定
        elif (
            word.start - segment_end > gap_seconds_threshold
            or len(current_segment) + len(word.word) > char_num
        ):
            # 新しいセグメントを開始する条件:
            # 1. 前の単語との間隔が閾値を超える
            # 2. 現在のセグメントの文字数が上限を超える
            srt_segments.append(
                create_subtitle(
                    segment_index,
                    segment_start,
                    segment_end,
                    "\n".join(
                        [
                            current_segment[i : i + max_line_str_num]
                            for i in range(0, len(current_segment), max_line_str_num)
                        ]
                    ),
                )
            )
            segment_index += 1  # 次のセグメントのインデックスを増やす
            current_segment = ""  # 新しいセグメントのテキストをリセット
            segment_start = word.start  # 新しいセグメントの開始時間を設定

        current_segment += word.word  # 現在の単語をセグメントに追加
        segment_end = word.end  # セグメントの終了時間を更新

        print(f"  [{word.start:.2f}s -> {word.end:.2f}s] {word.word}")  # デバッグ用出力

    if current_segment:
        # 最後のセグメントを追加
        srt_segments.append(
            create_subtitle(segment_index, segment_start, segment_end, current_segment)
        )

    return srt_segments

解説

  1. 関数の引数:

    • words: 生成対象の単語リスト。Word クラスのインスタンスのリストです。
    • char_num: 1行あたりの最大文字数。デフォルトは48文字です。
    • max_line_str_num: 最大行数。デフォルトは24行です。
    • gap_seconds_threshold: セグメント間の最大間隔(秒)。デフォルトは3秒です。
  2. 初期化:

    • srt_segments: 生成されたSRT字幕セグメントを格納するリストです。
    • current_segment: 現在処理中の字幕セグメントのテキストを保持します。
    • segment_startsegment_end: 現在の字幕セグメントの開始時間と終了時間を保持します。
    • segment_index: 字幕セグメントのインデックスを管理します。
  3. 単語のループ処理:

    • 各単語に対して、セグメントの開始時間を設定します。最初の単語の場合、segment_start にその単語の開始時間を設定します。
    • 次の単語が前の単語との間隔が閾値を超えた場合、または現在のセグメントの文字数が上限を超えた場合、新しいセグメントを開始します。
  4. セグメントの生成:

    • 新しいセグメントを開始する条件が満たされた場合、create_subtitle() 関数を呼び出して新しい字幕セグメントを生成し、srt_segments リストに追加します。
    • 現在の単語を current_segment に追加し、segment_end を更新します。
  5. デバッグ出力:

    • 各単語の開始時間と終了時間、単語自体をコンソールに出力します。これにより、処理の進行状況を確認できます。
  6. 最後のセグメントの追加:

    • ループが終了した後、current_segment に内容が残っている場合、最後のセグメントを追加します。
  7. 結果の返却:

    • 最終的に、生成されたSRT形式の字幕セグメントのリストを返します。このリストは、SRTファイルとして保存するために使用されます。

この関数を使用することで、音声文字起こしの結果を基に、適切なタイミングで字幕を分割し、視聴者にとって見やすい形式で表示することができます。

SRTファイルの保存

SRT形式の字幕セグメントが生成された後、main() 関数内でそれをファイルに保存します。以下がその部分のコードです。

Discussion