みなさん、こんにちは。万次郎です。
前回の記事「音声の文字起こし、もっと楽に・高精度にできるかも?MFAの活用」では、Montreal Forced Aligner(以下、MFA)のセットアップ方法と基本的な使い方についてご紹介しました。まだご覧になっていない方は、先にそちらを読んでいただくと、今回の内容もスムーズに理解いただけると思いますので、ぜひチェックしてみてください。
さて、今回はその続編として、MFAを実際に使ってみて見えてきた課題点と、それらをどう解決していくかについて掘り下げていきたいと思います。
MFAは非常に強力なツールですが、使っていくうちに「ここ、もう少しどうにかならないかな?」と感じる部分も出てきました。この記事では、そういったポイントを整理し、解決策や回避策を交えながら解説していきます。
記事の最後には、参考となる最終的な出力例もご紹介していますので、ぜひ最後までお付き合いください。
MFAの問題点について
MFAは音声とテキストのアライメントにおいて非常に強力なツールですが、実際の運用においていくつかの課題に直面することがあります。ここでは代表的な問題点とその解決策を、実例を交えながら詳しく解説していきます。
1. 単語ごとの出力形式による制約
MFAの出力は単語単位のタイムスタンプとなります。これは個々の単語の発話タイミングを知るには理想的ですが、人間が読みやすい文章形式としては直接使いづらいという側面があります。
実際のMFAの出力例を見てみましょう:
File type = "ooTextFile" Object class = "TextGrid" xmin = 0 xmax = 411.861338 tiers? <exists> size = 2 item []: item [1]: class = "IntervalTier" name = "words" xmin = 0 xmax = 411.861338 intervals: size = 1144 intervals [1]: xmin = 0.0 xmax = 1.65 text = "おはよう" intervals [2]: xmin = 1.65 xmax = 1.97 text = "と" intervals [3]: xmin = 1.97 xmax = 2.25 text = "言った" ... item [2]: class = "IntervalTier" name = "phones" xmin = 0 xmax = 411.861338 intervals: size = 3404 intervals [1]: xmin = 0.0 xmax = 1.65 text = "" intervals [2]: xmin = 1.65 xmax = 1.78 text = "j" intervals [3]: xmin = 1.78 xmax = 1.82 text = "ɨ" ...
このようなフォーマットでは、各単語がそれぞれ独立したエントリとして記録されるため、文や段落としての自然な区切りが反映されていません。したがって、読みやすい文章に整形するためには追加の処理ステップが必要になります。
2. 記号類(特に「?」「!」「「」」)の除外
MFAのアライメント処理では、感嘆符や疑問符などの記号は自動的に無視されます。
例:
元の文章:
「おはよう」と言った
MFA出力対象:
item [1]: class = "IntervalTier" name = "words" xmin = 0 xmax = 411.861338 intervals: size = 1144 intervals [1]: xmin = 0.0 xmax = 1.65 text = "おはよう" intervals [2]: xmin = 1.65 xmax = 1.97 text = "と" intervals [3]: xmin = 1.97 xmax = 2.25 text = "言った"
このように鉤括弧(「」)が出力から消失するため、会話文の区別や話者の発言意図が不明確になります。これは特に対話を含むコンテンツでは大きな課題となり得ます。
3. 句読点の欠落
MFAは標準的な句読点(。、)も同様に処理対象から除外します。これも発音モデルとのマッピングの都合上、必要な処理ではありますが、結果として文章の構造が失われてしまいます。
例:
元の文章:
こんにちは。私は万次郎です。
MFA出力:
item [1]: class = "IntervalTier" name = "words" xmin = 0 xmax = 411.861338 intervals: size = 1144 intervals [1]: xmin = 0.0 xmax = 1.65 text = "こんにちは" intervals [2]: xmin = 1.65 xmax = 1.97 text = "私" intervals [3]: xmin = 1.97 xmax = 2.25 text = "は" intervals [4]: xmin = 2.25 xmax = 2.45 text = "万次郎" intervals [5]: xmin = 2.45 xmax = 3.01 text = "です"
句読点が除外されることで、文の区切りが曖昧になり、特に長い段落では意味解釈が困難になることがあります。
ただし、特定用途では問題にならないケースも
これらの制約は、人間が読むための文章として使用する場合に特に問題となりますが、技術的な処理目的によっては逆にメリットになることもあります:
- 音声区間の切り出し:余計な記号がない分、音声区間の特定が容易
- 音声認識モデルの評価:単語単位での認識精度検証に適している
- 音声合成の学習データ:クリーンな単語単位データとして活用可能
つまり、用途によっては現状のMFA出力形式がむしろ理想的なこともあるのです。
解決のための効果的アプローチ
前述の課題は、適切な後処理パイプラインを構築することで効果的に解決できます。以下に主要な解決アプローチを詳しく解説します。
1. LLMを活用した単語列の自然文章への再構成
MFAの単語単位出力を読みやすい文章に変換するには、LLM(大規模言語モデル)の文脈理解能力を活用する方法が非常に効率的です。
処理フロー例:
MFAの出力(単語列):
item [1]: class = "IntervalTier" name = "words" xmin = 0 xmax = 411.861338 intervals: size = 1144 intervals [1]: xmin = 0.0 xmax = 1.65 text = "こんにちは" intervals [2]: xmin = 1.65 xmax = 1.97 text = "私" intervals [3]: xmin = 1.97 xmax = 2.25 text = "は" intervals [4]: xmin = 2.25 xmax = 2.45 text = "万次郎" intervals [5]: xmin = 2.45 xmax = 3.01 text = "です"
LLMによる成型後:
こんにちは。私は万次郎です。
LLMの優れた点は、単に句読点を挿入するだけでなく、文脈に基づいて自然な文構造を推論できることです。例えば:
- 自然な文区切りの推定
- 文法的な修正や整形
- 話者の意図に基づく記号(「」や!?など)の挿入
- 言い淀みや繰り返しの適切な処理
利用できるLLMの選択肢としては:
- OpenAI GPT系モデル(ChatGPT)
- Anthropic Claude(特に文脈理解に優れている)
- オープンソースモデル
これらのモデルを使用することで、手作業での編集に比べて何倍もの効率と一貫性を実現できます。特に大量のコンテンツを処理する場合、このアプローチの価値は計り知れません。
2. 原文との照合による記号・句読点の正確な復元
もし元となる原稿やスクリプトがあれば、MFAの出力と原文を照合することで、より正確に文章を再構成できます。
実装ステップ:
- MFAでアライメント情報を取得(単語と時間情報)
- 原文テキストを解析し、句読点や記号の位置を特定
- MFA出力の単語列に対して、原文の情報を参照しながら記号を挿入
- 必要に応じて文構造を調整
例:
原文:
こんにちは。私は万次郎です。
MFA出力(単語列):
item [1]: class = "IntervalTier" name = "words" xmin = 0 xmax = 411.861338 intervals: size = 1144 intervals [1]: xmin = 0.0 xmax = 1.65 text = "こんにちは" intervals [2]: xmin = 1.65 xmax = 1.97 text = "私" intervals [3]: xmin = 1.97 xmax = 2.25 text = "は" intervals [4]: xmin = 2.25 xmax = 2.45 text = "万次郎" intervals [5]: xmin = 2.45 xmax = 3.01 text = "です"
照合・補完後:
こんにちは。私は万次郎です。
この処理には複数のアプローチがあります:
- LLMに原文を参照情報として提供: 「この単語列を、この原文を参考にして自然な文章に整形してください」
- 自動アライメントアルゴリズム: 原文と単語列の間でのトークン単位マッチングを計算
- ルールベースの補完: 文末表現や接続詞の後など、高確率で記号が必要な箇所を特定
この中でも、LLMに原文を参照情報として与える方法は、精度と柔軟性のバランスが最も優れています。複雑な文章構造や話者の意図も適切に反映できるからです。
補足:タイムスタンプの扱いについて
これらの補完処理を行う際に注意したいのが、元のタイムスタンプと整形後の文章をどう同期させるかです。特に字幕や音声カット用途では、「この文章は何秒から何秒まで」という情報が重要になるため、整形後のテキストを元の単語と結びつけるマッピング処理が必要になる場合があります。
これも、LLMをうまく使えば
- 元単語を保持しつつ整形する
- タイムスタンプ情報を残したまま文を再構成する
といったことが可能になります。
MFAの出力はそのままだとやや不便に見えるかもしれませんが、LLMや原文との照合を組み合わせることで、大幅に使いやすく整えることが可能です。
- LLMを使えば自然文への再構成が簡単に
- 原文があれば、句読点や記号の補完も正確に
- 字幕やテキスト同期処理も工夫次第でスムーズに
それでは、どのような手順で推敲を行えばいいか、その一例をご紹介します。今回はLLMを活用した単語列の自然文章への再構成のアプローチを採用します。
字幕ファイルをLLMを使って推敲する場合、1回のリクエストで長すぎるテキストを投入すると、コンテキストサイズを消費して精度が下がります。この問題を解決して、いかに効率よく精度の高い字幕を作成するかがポイントになります。
実現方法手順
1. TextGridをパースして単語レベルのSRTファイルを生成
まず最初のステップでは、MFAが出力するTextGridファイルをパースし、単語単位でタイムスタンプを持ったSRTファイルを作成します。このプロセスにより、各単語の開始時間と終了時間が明確に記録された中間ファイルが生成されます。これは後続の推敲作業の基盤となり、正確なタイミング情報を保持したまま文章構造を改善することを可能にします。
2. SRTを無音区間を基準に分割する
ここが本手法の最も重要なポイントです。
MFAが出力するTextGridファイルには、音声区間の間に必ず無音区間が存在します。この無音区間を「話者が息継ぎをするポイント」と捉え、字幕を自然に分割する基準として活用します。
しかし、全ての無音区間で分割してしまうと、字幕が細かすぎて視聴者が読みづらく、また処理効率も下がってしまいます。そこで無音区間の長さに統計的な基準を設け、意味のある区切りポイントのみを選択します。
具体的には、無音区間の長さを収集し、その分布から95パーセンタイルを超える長さの区間のみを「意味のある区切りポイント」として採用します。これにより:
- 話者の自然な息継ぎに合わせた区切り
- 意味的にもまとまりのある単位での分割
- 視聴者が読みやすい適切な長さの字幕
が実現できます。
3. 分割した字幕をClaudeで推敲
分割された各字幕ブロックは、サイズが適切に管理されているため、LLM(今回はAnthropicのClaude)に順番に投入し、以下の観点から高精度な推敲を行うことが可能になります:
- 文法的な修正(助詞の使い方、句読点の位置など)
- 意味的なまとまりの最適化
- 台詞部分の鉤括弧での適切な区切り
- 読みやすさの向上
各ブロックが適切なサイズに保たれているため、LLMのコンテキストウィンドウを最大限に活用でき、高い精度での推敲が実現します。ただし今回の例では、字幕ファイルという性質上、句読点を補完する点は無視して考えます。その代わり、台詞と考えられる部分には鉤括弧を追加するようにしています。
4. 推敲済みの字幕を1つのSRTファイルにマージ
最後のステップでは、個別に推敲された字幕ファイルを時系列順に再統合し、一つの完成度の高いSRTファイルとして出力します。このファイルは、元の音声のタイミングを正確に保持しながらも、文法的・意味的に洗練された字幕となります。
実装事例:夏目漱石『夢十夜』を例に
ここからは、実際にこの手順をコードでどのように実装したのかを紹介します。今回は夏目漱石の『夢十夜』の『第一夜』を私自身が読み上げ、作成した音声ファイルのMFAの出力を例として、実際のディレクトリ構成やコードを詳しく解説していきます。
実際のコード
最終的なディレクトリ構成はこの通りです:
your_project_directory/ ├── main.py ├── textgrid_converter.py ├── srt_divider.py ├── claude_processor.py ├── claude_api.py ├── .env # Anthropic APIキーなどを記述 ├── Pipfile # pipenvの生成物 ├── Pipfile.lock ├── input/ │ └── yume/ │ └── yume.TextGrid # MFAの出力ファイル └── output/ #スクリプト実行で生成されるディレクトリ. 実行前は前述のディレクトリ構成になっていればok └── yume/ ├── yume.srt # textgrid_converter の出力 ├── split/ # srt_divider の出力ディレクトリ │ ├── split_001.srt │ └── ... ├── divided_output/ # claude_processor の中間出力ディレクトリ │ ├── split_001_processed.srt │ └── ... └── merged_output.srt # claude_processor の最終出力
各スクリプトの役割と詳細実装
次に主要ファイルの説明をします。
textgrid_converter.py
textgrid_converter.py
ファイルの役割は非常にシンプルです。MFAの出力をSRTに変換することだけを担当します。この時点では文章の推敲などは考慮せず、純粋にTextGrid形式のファイルをSRT形式に変換します。
例えば、このようなMFAの出力がある場合は:
item [1]: class = "IntervalTier" name = "words" xmin = 0 xmax = 411.861338 intervals: size = 1144 intervals [1]: xmin = 0.0 xmax = 1.65 text = "" intervals [2]: xmin = 1.65 xmax = 1.97 text = "夢" intervals [3]: xmin = 1.97 xmax = 2.25 text = "十" intervals [4]: xmin = 2.25 xmax = 2.45 text = "夜" intervals [5]: xmin = 2.45 xmax = 3.01 text = "" intervals [6]: xmin = 3.01 xmax = 3.5 text = "夏目" intervals [7]: xmin = 3.5 xmax = 4.22 text = "漱石" intervals [8]: xmin = 4.22 xmax = 4.83 text = "" intervals [9]: xmin = 4.83 xmax = 5.18 text = "第" intervals [10]: xmin = 5.18 xmax = 5.6 text = "一夜" ...
以下のようなシンプルなSRTフォーマットに変換します:
1 00:00:01,649 --> 00:00:01,970 夢 2 00:00:01,970 --> 00:00:02,250 十 3 00:00:02,250 --> 00:00:02,450 夜 4 00:00:03,009 --> 00:00:03,500 夏目 5 00:00:03,500 --> 00:00:04,219 漱石 6 00:00:04,830 --> 00:00:05,179 第 7 00:00:05,179 --> 00:00:05,599 一夜 ...
この変換プロセスでは、各単語の開始時間と終了時間を正確に保持しながら、SRTの標準フォーマットに変換しています。
スクリプト
import os def format_timestamp(seconds: float) -> str: """ 秒数をSRTタイムスタンプ形式 (HH:MM:SS,mmm) に変換する。 """ hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) sec = seconds % 60 milliseconds = int((sec % 1) * 1000) seconds_int = int(sec) return f"{hours:02d}:{minutes:02d}:{seconds_int:02d},{milliseconds:03d}" def parse_textgrid(file_path: str) -> list: """ TextGridファイルを解析し、item [1] (通常は単語ティア) からインターバルを抽出する。 """ intervals = [] try: with open(file_path, 'r', encoding='utf-8') as f: lines = f.readlines() except FileNotFoundError: print(f"エラー: TextGridファイルが見つかりません: {file_path}") raise except Exception as e: print(f"エラー: TextGridファイルの読み込み中にエラーが発生しました: {e}") raise item1_start = -1 in_item1 = False in_intervals = False current_interval = {} for line in lines: line = line.strip() if line == "item [1]:": item1_start = 0 in_item1 = True continue if not in_item1: continue if line.startswith("item [") and line != "item [1]:": break if line.startswith("intervals ["): if current_interval and 'xmin' in current_interval and 'xmax' in current_interval and 'text' in current_interval: intervals.append(current_interval) current_interval = {} in_intervals = True # 必ずしも必要ではないが、状態を明確にする elif line.startswith("xmin = ") and in_item1: try: current_interval['xmin'] = float(line.split("=")[1].strip()) except ValueError: print(f"警告: 無効なxmin値をスキップ: {line}") current_interval = {} elif line.startswith("xmax = ") and in_item1: try: current_interval['xmax'] = float(line.split("=")[1].strip()) except ValueError: print(f"警告: 無効なxmax値をスキップ: {line}") current_interval = {} elif line.startswith("text = ") and in_item1: text = line.split("=")[1].strip() if text.startswith('"') and text.endswith('"'): text = text[1:-1] current_interval['text'] = text if 'xmin' in current_interval and 'xmax' in current_interval: intervals.append(current_interval) current_interval = {} # 次のインターバルに備えてリセット if item1_start == -1: raise ValueError("TextGridファイルに item [1] が見つかりませんでした。") if not intervals: print(f"警告: TextGridファイル ({file_path}) から有効なインターバルが抽出されませんでした。") return intervals def create_srt(intervals: list) -> str: """ インターバルのリストからSRT形式の文字列を生成する。 """ srt_entries = [] counter = 1 for interval in intervals: if not interval.get('text'): continue try: start_time = format_timestamp(interval['xmin']) end_time = format_timestamp(interval['xmax']) srt_entry = f"{counter}\n{start_time} --> {end_time}\n{interval['text']}\n" srt_entries.append(srt_entry) counter += 1 except KeyError as e: print(f"警告: インターバルに必要なキー ({e}) がありません。スキップします: {interval}") except Exception as e: print(f"警告: SRTエントリー作成中にエラーが発生しました。スキップします: {interval}, エラー: {e}") return "\n".join(srt_entries) def textgrid_to_srt(input_file: str, output_file: str): """ TextGridファイルをSRT形式に変換して保存する。 """ print(f"TextGridファイルをSRTに変換中: {input_file} -> {output_file}") try: output_dir = os.path.dirname(output_file) os.makedirs(output_dir, exist_ok=True) intervals = parse_textgrid(input_file) if not intervals: print("有効なインターバルがないため、SRTファイルの生成をスキップします。") return srt_content = create_srt(intervals) with open(output_file, 'w', encoding='utf-8') as f: f.write(srt_content) print(f"変換完了: {output_file}") except FileNotFoundError: pass except ValueError as e: print(f"エラー: TextGridファイルの解析に失敗しました: {e}") except Exception as e: print(f"エラー: TextGridからSRTへの変換中に予期せぬエラーが発生しました: {e}")
srt_divider.py
srt_divider.py
の主な役割は、textgrid_converter.py
で生成した単一のSRTファイルを、推敲作業に適した複数の小さなSRTファイルに分割することです。
分割が必要な理由は以下の通りです:
- AIモデルのコンテキスト制限: LLMには入力できるトークン数に制限があるため、大きすぎるテキストを一度に処理すると精度が落ちる
- 処理の並列化: 分割することで、複数の字幕ブロックを並列に処理できる (今回は並列化は実装しませんのでご了承ください)
- 意味のまとまり: 適切な区切りで分割することで、意味的な一貫性を保ちやすくなる
分割アルゴリズムの核心部分は以下のようになっています:
- すべての無音区間(字幕と字幕の間の時間)を抽出
- これらの無音区間の長さを計算し、95パーセンタイル以上の長さを持つ区間を「意味のある区切り」として識別
- 識別された区切りポイントでSRTファイルを分割し、個別のファイルとして保存
今回の『夢十夜』の例では、元の yume.srt
は 48個の小さなSRTファイルに分割されました。これにより、各ファイルが適切なサイズに収まり、LLMによる高精度な推敲が可能になります。
スクリプト
import datetime from dataclasses import dataclass from typing import List import os import numpy as np @dataclass class SubtitleEntry: index: int start_time: datetime.timedelta end_time: datetime.timedelta text: str original_index: int # 任意 def parse_time(time_str: str) -> datetime.timedelta: """SRTの時間文字列 (HH:MM:SS,mmm) をtimedeltaに変換する""" try: time_part, ms_part = time_str.split(',') hours, minutes, seconds = map(int, time_part.split(':')) milliseconds = int(ms_part) return datetime.timedelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds ) except ValueError: try: time_part, ms_part = time_str.replace(',', '.').split('.') hours, minutes, seconds = map(int, time_part.split(':')) milliseconds = int(ms_part) return datetime.timedelta( hours=hours, minutes=minutes, seconds=seconds, milliseconds=milliseconds ) except ValueError: raise ValueError(f"無効な時間フォーマットです: {time_str}") def format_time(td: datetime.timedelta) -> str: """timedeltaをSRT形式の時間文字列 (HH:MM:SS,mmm) に変換する""" total_seconds = td.total_seconds() hours = int(total_seconds // 3600) minutes = int((total_seconds % 3600) // 60) seconds = int(total_seconds % 60) milliseconds = int(td.microseconds / 1000) return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}" def parse_srt(file_path: str) -> List[SubtitleEntry]: """SRTファイルを解析してSubtitleEntryのリストを返す""" entries = [] try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read().strip() blocks = content.split('\n\n') for block in blocks: lines = block.strip().split('\n') if len(lines) < 3: continue try: index = int(lines[0]) time_range = lines[1].split(' --> ') if len(time_range) != 2: raise ValueError("時間範囲のフォーマットが無効です。") start_time = parse_time(time_range[0].strip()) end_time = parse_time(time_range[1].strip()) text = '\n'.join(lines[2:]) if start_time > end_time: print(f"警告: 終了時間が開始時間より前です (インデックス {index})。このエントリーをスキップします。") continue entries.append(SubtitleEntry(index=0, start_time=start_time, end_time=end_time, text=text, original_index=index)) except (ValueError, IndexError) as e: print(f"警告: SRTブロックの解析中にエラーが発生しました。スキップします: {block}\nエラー: {e}") continue except FileNotFoundError: print(f"エラー: SRTファイルが見つかりません: {file_path}") raise except Exception as e: print(f"エラー: SRTファイルの読み込み・解析中にエラーが発生しました: {e}") raise entries.sort(key=lambda x: x.start_time) for i, entry in enumerate(entries): entry.index = i + 1 return entries def find_split_points(entries: List[SubtitleEntry], percentile: float = 90.0, min_entries_per_segment: int = 5, max_entries_per_segment: int = 60) -> List[datetime.timedelta]: all_gaps = [] for i in range(len(entries) - 1): gap = (entries[i + 1].start_time - entries[i].end_time).total_seconds() all_gaps.append(gap) threshold = np.percentile(all_gaps, percentile) print(f"全体のギャップ: {percentile}パーセンタイル = {threshold:.2f}秒 を閾値として使用します") split_points = [] last_split = 0 def calculate_gap(entry1: SubtitleEntry, entry2: SubtitleEntry) -> float: return (entry2.start_time - entry1.end_time).total_seconds() while last_split < len(entries) - 1: candidate_index = None candidate_gap = 0.0 found = False end_index = min(last_split + max_entries_per_segment, len(entries) - 1) for i in range(last_split, end_index): gap = calculate_gap(entries[i], entries[i + 1]) current_section_size = i - last_split + 1 if current_section_size >= min_entries_per_segment and gap >= threshold: candidate_index = i print(f"有意なギャップで分割: {format_time(entries[i].end_time)} " f"(ギャップ: {gap:.2f}秒, 字幕数: {current_section_size})") found = True break if gap > candidate_gap: candidate_gap = gap candidate_index = i if candidate_index is None: candidate_index = end_index - 1 if not found: print(f"強制分割: {format_time(entries[candidate_index].end_time)} " f"(最大ギャップ: {candidate_gap:.2f}秒, 字幕数: {candidate_index - last_split + 1})") split_points.append(entries[candidate_index].end_time) last_split = candidate_index + 1 return split_points def split_subtitles(entries: List[SubtitleEntry], split_points: List[datetime.timedelta]) -> List[List[SubtitleEntry]]: """字幕エントリーリストを、指定された分割ポイント (終了時間) で分割する""" if not split_points: return [entries] if entries else [] groups = [] current_group = [] split_point_iter = iter(sorted(split_points)) current_split_point = next(split_point_iter, None) for entry in entries: current_group.append(entry) if current_split_point is not None and entry.end_time == current_split_point: groups.append(current_group) current_group = [] current_split_point = next(split_point_iter, None) if current_group: groups.append(current_group) return groups def merge_short_groups(groups: List[List[SubtitleEntry]], min_entries_per_segment: int) -> List[List[SubtitleEntry]]: """ 指定された最小エントリ数に満たないグループを、隣接するグループとマージする。 """ if len(groups) <= 1: return groups merged_groups = [] temp_group = [] i = 0 while i < len(groups): current_group = groups[i] temp_group.extend(current_group) if len(temp_group) >= min_entries_per_segment or i == len(groups) - 1: if len(temp_group) != len(current_group) and merged_groups: print(f" - 短いグループをマージしました (合計 {len(temp_group)} エントリー)") elif len(current_group) < min_entries_per_segment and not merged_groups and i == 0 and len(groups) > 1: print(f" - 最初の短いグループ ({len(current_group)} エントリー) を次のグループとマージします。") elif len(current_group) < min_entries_per_segment and i == len(groups) -1 and merged_groups: print(f" - 最後の短いグループ ({len(current_group)} エントリー) を前のグループとマージしました。") merged_groups.append(temp_group) temp_group = [] else: pass i += 1 final_groups = [] if merged_groups and len(merged_groups[0]) < min_entries_per_segment and len(merged_groups) > 1: print(f" - 再マージ: 最初のグループ ({len(merged_groups[0])}) がまだ短いので、2番目と結合します。") merged_groups[1] = merged_groups[0] + merged_groups[1] final_groups = merged_groups[1:] else: final_groups = merged_groups final_groups_reindexed = [] for group in final_groups: reindexed_group = [] for idx, entry in enumerate(group, 1): reindexed_group.append(SubtitleEntry(index=idx, start_time=entry.start_time, end_time=entry.end_time, text=entry.text, original_index=entry.original_index)) final_groups_reindexed.append(reindexed_group) return final_groups_reindexed def write_srt(entries: List[SubtitleEntry], output_path: str): """SubtitleEntryのリストをSRTファイルとして書き出す""" try: os.makedirs(os.path.dirname(output_path), exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: for entry in entries: f.write(f"{entry.index}\n") f.write(f"{format_time(entry.start_time)} --> {format_time(entry.end_time)}\n") f.write(f"{entry.text}\n\n") except Exception as e: print(f"エラー: SRTファイルの書き込み中にエラーが発生しました ({output_path}): {e}") raise def split_srt_file(input_path: str, output_prefix: str, percentile: float = 90.0, min_entries_per_segment: int = 5, max_entries_per_segment: int = 60): """ SRTファイルを読み込み、分割し、マージして、複数のSRTファイルとして出力する。 """ print(f"\nSRTファイルの分割処理を開始します...") print(f"入力ファイル: {input_path}") print(f"設定: percentile={percentile}, min_entries={min_entries_per_segment}, max_entries={max_entries_per_segment}") try: print("SRTファイルを解析中...") entries = parse_srt(input_path) print(f"合計 {len(entries)} 個の字幕エントリーを検出しました。") if not entries: print("字幕エントリーがないため、処理を終了します。") return 0 print("\n分割ポイントを検出中...") split_points = find_split_points( entries, percentile=percentile, min_entries_per_segment=min_entries_per_segment, max_entries_per_segment=max_entries_per_segment ) print(f"\n{len(split_points)} 個の分割ポイントを検出しました。") print("\n字幕をグループに分割中...") split_groups = split_subtitles(entries, split_points) print(f"{len(split_groups)} 個の初期グループに分割しました。") print("\n短いグループをマージ中...") final_groups = merge_short_groups(split_groups, min_entries_per_segment) print(f"マージ後、{len(final_groups)} 個の最終グループになりました。") print("\n分割ファイルを書き出し中...") output_dir = os.path.dirname(output_prefix) os.makedirs(output_dir, exist_ok=True) output_file_paths = [] for i, group in enumerate(final_groups, 1): output_path = f"{output_prefix}_{i:03d}.srt" write_srt(group, output_path) start_time = format_time(group[0].start_time) end_time = format_time(group[-1].end_time) print(f" - {output_path} を出力 (エントリー数: {len(group)}, 時間: {start_time} --> {end_time})") output_file_paths.append(output_path) print(f"\nSRTファイルの分割処理が完了しました!") print(f"合計 {len(final_groups)} 個のファイルを生成しました。") return len(final_groups) except FileNotFoundError: print(f"エラー: 入力ファイルが見つかりません: {input_path}") return 0 except Exception as e: print(f"エラー: SRT分割処理中に予期せぬエラーが発生しました: {e}") return 0
claude_processor.py
claude_processor.py
の役割は非常に重要で、以下の2つの主要な機能を担っています:
- 単語レベルのSRTを意味のある単位にまとめる
- 文法的・意味的な推敲を行い、最終的なSRTを生成する
例えば、分割されたファイルが以下のような単語レベルのSRTだった場合:
1 00:00:01,649 --> 00:00:01,970 夢 2 00:00:01,970 --> 00:00:02,250 十 3 00:00:02,250 --> 00:00:02,450 夜 4 00:00:03,009 --> 00:00:03,500 夏目 5 00:00:03,500 --> 00:00:04,219 漱石 6 00:00:04,830 --> 00:00:05,179 第 7 00:00:05,179 --> 00:00:05,599 一夜
これを意味のある単位にまとめた推敲済みSRTは以下のようになります:
1 00:00:01,649 --> 00:00:02,750 夢十夜 2 00:00:03,009 --> 00:00:04,519 夏目漱石 3 00:00:04,830 --> 00:00:05,899 第一夜
このプロセスの重要なポイントは2つあります:
- 意味のあるまとまりの作成: 単語を文法的・意味的に適切な単位でグループ化
- タイムスタンプの再計算: グループ化された字幕の開始時間と終了時間を正確に計算
さらに、推敲プロセスでは文法的な修正や、台詞の適切な表示も行います。例えば:
推敲前:
... 25 00:00:51,280 --> 00:00:51,990 覗き込む 26 00:00:51,990 --> 00:00:52,200 よう 27 00:00:52,200 --> 00:00:52,320 に 28 00:00:52,320 --> 00:00:52,560 して 29 00:00:52,560 --> 00:00:53,009 聞いて 30 00:00:53,009 --> 00:00:53,350 見た 31 00:00:54,179 --> 00:00:54,869 死にます 32 00:00:54,869 --> 00:00:55,219 とも 33 00:00:55,549 --> 00:00:55,689 と 34 00:00:55,689 --> 00:00:55,899 云い 35 00:00:55,899 --> 00:00:56,420 ながら 36 00:00:56,770 --> 00:00:57,140 女 37 00:00:57,140 --> 00:00:57,250 は 38 00:00:57,250 --> 00:00:57,810 ぱっちり 39 00:00:57,810 --> 00:00:57,939 と 40 00:00:57,939 --> 00:00:58,119 眼 41 00:00:58,119 --> 00:00:58,280 を 42 00:00:58,280 --> 00:00:58,729 開けた
推敲後:
... 5 00:00:50,130 --> 00:00:52,560 と上から覗き込むようにして 6 00:00:52,560 --> 00:00:53,350 聞いて見た 7 00:00:54,179 --> 00:00:55,219 「死にますとも」 8 00:00:55,549 --> 00:00:56,420 と云いながら 9 00:00:56,770 --> 00:00:58,729 女はぱっちりと眼を開けた
このように、台詞と思われる部分(行番号7)には鉤括弧を適切に追加し、読みやすさを向上させています。
スクリプト
import os import glob from typing import List import claude_api import time from srt_divider import parse_time, format_time, SubtitleEntry, parse_srt def read_and_format_srt_for_claude(file_path: str) -> tuple[str, List[SubtitleEntry]]: """ SRTファイルを読み込み、Claudeへのプロンプト用に整形された文字列と、 元のSubtitleEntryリストを返す。 """ formatted_blocks = [] entries = [] try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read().strip() blocks = content.split('\n\n') current_entries = [] # このファイル内のエントリー for block in blocks: lines = block.strip().split('\n') if len(lines) < 3: continue try: index = int(lines[0]) timestamp_line = lines[1] time_range = timestamp_line.split(' --> ') if len(time_range) != 2: raise ValueError("時間範囲のフォーマットが無効です。") start_time = parse_time(time_range[0].strip()) end_time = parse_time(time_range[1].strip()) text = '\n'.join(lines[2:]) # Claude送信用文字列 formatted_blocks.append(f"タイムスタンプ: {timestamp_line}\n台詞: {text}") # 元情報保持用 current_entries.append(SubtitleEntry(index=index, start_time=start_time, end_time=end_time, text=text, original_index=index)) except (ValueError, IndexError) as e: print(f"警告: SRTブロックの解析中にエラー ({file_path}): {e}. スキップします: {block}") continue entries = current_entries return '\n\n'.join(formatted_blocks), entries except FileNotFoundError: print(f"エラー: SRTファイルが見つかりません: {file_path}") raise except Exception as e: print(f"エラー: SRTファイルの読み込み中にエラーが発生しました ({file_path}): {e}") raise def create_claude_prompt(formatted_content: str) -> str: """Claudeへ送信するプロンプトを作成""" return ( "あなたはTextGridファイルをSRTファイルに変換するアシスタントです。以下の基準に従って変換を行ってください:\n\n" "## 1. 字幕の区切り方\n" "- 1行あたり30文字を目安とする\n" "- 改行は行わない\n" "- 以下の基準で区切りを入れる:\n" " - 文法的な区切り(文節)を優先\n" " - 主語と述語は可能な限り同じ字幕に含める\n" " - 助詞・助動詞は前の内容とセットにする\n" "## 2. タイミングの調整\n" "- 各エントリの区間を正確に計算する\n" "- タイムコードは以下の形式で記述:\n" " 00:00:00,000 --> 00:00:00,000 (時:分:秒,ミリ秒)\n\n" "- 「〜て」「〜に」などの接続助詞で終わる場合は次の内容と\n\n" "## 3. シンボルを追記する\n" "- 劇中に誰かが発言されたことが明記されている箇所は鉤括弧(「」)を追加する\n" "## 5. フォーマット\n" "```\n" "1\n" "00:00:11,360 --> 00:00:13,360\n" "1行目の内容\n" "2\n" "...\n" "```\n\n" "## 6. チェックリスト\n" "変換後、以下の点を確認:\n" "1. すべての時間が正しく変換されているか\n" "2. 字幕の表示時間は適切か\n" "3. 文の区切りは自然か\n" "4. 意味のつながりは保たれているか\n" "## 7. 補足\n" "- 歌詞の場合、メロディーラインも考慮して区切る\n" "- 話し言葉の場合、発話の区切りを優先する\n" "- 専門用語や固有名詞は分割を避ける\n" "- 感情表現(「!」「?」など)は前の内容と一緒にする\n\n" "ただし、あなた自身の言葉は不要で、結果だけを返してください。\n\n" "以下のデータを上記の基準に従って変換してください:\n\n" f"{formatted_content}" ) def validate_claude_response(response: str) -> bool: """ Claudeの応答が基本的なSRTフォーマットを満たしているか簡易チェック。 特にタイムスタンプ形式を重点的に確認。 """ if not response or not response.strip(): print("警告: Claudeからの応答が空です。") return False lines = response.strip().split('\n') if len(lines) < 3: print(f"警告: Claudeの応答が短すぎます (3行未満)。SRT形式ではありません。\n応答:\n{response}") return False import re timestamp_pattern = re.compile(r'^\d{2}:\d{2}:\d{2},\d{3}\s+-->\s+\d{2}:\d{2}:\d{2},\d{3}$') found_timestamp = False for i, line in enumerate(lines): if '-->' in line: found_timestamp = True if not timestamp_pattern.match(line.strip()): print(f"警告: Claudeの応答に不正な形式のタイムスタンプが含まれています (行 {i+1}): {line.strip()}") print(f"完全な応答:\n{response}") return False if not found_timestamp: print(f"警告: Claudeの応答にタイムスタンプ行 ('-->') が見つかりません。\n応答:\n{response}") return False if not lines[0].strip().isdigit(): print(f"警告: Claudeの応答の最初の行が数字(インデックス)ではありません。\n応答:\n{response}") return False return True def save_claude_response(response: str, output_path: str): """Claudeからの応答をSRTファイルとして保存""" try: os.makedirs(os.path.dirname(output_path), exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: f.write(response.strip() + "\n\n") print(f" - Claudeの処理結果を保存しました: {output_path}") except Exception as e: print(f"エラー: Claudeの応答の保存中にエラーが発生しました ({output_path}): {e}") raise def merge_srt_files(processed_files: List[str], final_output_path: str): """ Claudeによって処理された複数のSRTファイルを読み込み、 タイムスタンプを維持したままインデックスを振り直して1つのファイルにマージする。 """ print("\n処理済みSRTファイルのマージを開始します...") print(f"マージ対象ファイル数: {len(processed_files)}") print(f"出力ファイル: {final_output_path}") all_entries = [] file_order_map = {path: i for i, path in enumerate(processed_files)} for file_path in processed_files: try: entries_in_file = parse_srt(file_path) if entries_in_file: all_entries.extend([(file_order_map[file_path], entry) for entry in entries_in_file]) else: print(f"警告: ファイル {file_path} から有効なエントリーが読み込めませんでした。マージから除外します。") except FileNotFoundError: print(f"エラー: マージ対象のファイルが見つかりません: {file_path}") continue except Exception as e: print(f"エラー: ファイルの読み込み中にエラーが発生しました ({file_path}): {e}") continue if not all_entries: print("エラー: マージする有効な字幕エントリーがありません。") return all_entries.sort(key=lambda x: (x[0], x[1].start_time)) try: os.makedirs(os.path.dirname(final_output_path), exist_ok=True) with open(final_output_path, 'w', encoding='utf-8') as f: for new_index, (_, entry) in enumerate(all_entries, 1): f.write(f"{new_index}\n") f.write(f"{format_time(entry.start_time)} --> {format_time(entry.end_time)}\n") f.write(f"{entry.text}\n\n") print(f"マージ完了: {len(all_entries)} 個のエントリーを {final_output_path} に保存しました。") except Exception as e: print(f"エラー: マージされたSRTファイルの書き込み中にエラーが発生しました: {e}") def process_files_with_claude(input_dir: str, output_dir: str, final_output_path: str, file_pattern: str = "split_*.srt", retry_attempts: int = 1, retry_delay: int = 5): """ 指定されたディレクトリ内のSRTファイルをClaudeで処理し、結果を保存後、マージする。 """ print("\nClaudeによるSRTファイルの処理を開始します...") print(f"入力ディレクトリ: {input_dir}") print(f"中間出力ディレクトリ: {output_dir}") print(f"最終出力ファイル: {final_output_path}") search_pattern = os.path.join(input_dir, file_pattern) srt_files = sorted(glob.glob(search_pattern)) if not srt_files: print(f"警告: 処理対象のSRTファイルが見つかりません ({search_pattern})。") return processed_files_paths = [] for file_path in srt_files: print(f"\nファイル処理中: {file_path}") try: formatted_content, original_entries = read_and_format_srt_for_claude(file_path) if not formatted_content: print(f" - 内容が空のためスキップします。") continue prompt = create_claude_prompt(formatted_content) claude_response = None for attempt in range(retry_attempts + 1): try: print(f" - Claude APIに送信中 (試行 {attempt + 1}/{retry_attempts + 1})...") response_text = claude_api.send_to_claude(prompt) if validate_claude_response(response_text): claude_response = response_text print(" - Claudeからの応答を受信し、基本形式を検証しました。") break else: print(f" - Claudeからの応答の形式が無効です。") if attempt < retry_attempts: print(f" {retry_delay}秒待機して再試行します...") time.sleep(retry_delay) else: print(" リトライ上限に達しました。このファイルの処理をスキップします。") except Exception as api_error: print(f" - Claude API呼び出し中にエラーが発生しました: {api_error}") if attempt < retry_attempts: print(f" {retry_delay}秒待機して再試行します...") time.sleep(retry_delay) else: print(" リトライ上限に達しました。このファイルの処理をスキップします。") if claude_response: base_name = os.path.splitext(os.path.basename(file_path))[0] output_path = os.path.join(output_dir, f"{base_name}_processed.srt") save_claude_response(claude_response, output_path) processed_files_paths.append(output_path) else: print(f" - Claudeから有効な応答を得られなかったため、{file_path} の処理結果は保存されません。") except FileNotFoundError: continue except Exception as e: print(f"エラー: ファイル処理中に予期せぬエラーが発生しました ({file_path}): {e}") continue if processed_files_paths: merge_srt_files(processed_files_paths, final_output_path) else: print("Claudeによって処理されたファイルがないため、マージ処理は行われませんでした。") print("\nClaudeによるSRTファイルの処理が完了しました!")
推敲プロセスとClaudeへのプロンプト
推敲には、Anthropicの大規模言語モデル「Claude」を活用しています。Claudeに対して送信するプロンプトには、以下の指示を含めています:
- 単語を意味のある単位に適切にまとめること
- まとめた字幕のタイムスタンプを正確に計算すること(最初の単語の開始時間から最後の単語の終了時間まで)
- 台詞と判断される箇所には鉤括弧を挿入すること
このようなプロンプトエンジニアリングにより、Claudeは高精度で一貫性のある推敲を行い、視聴者にとって読みやすく、原音声の意図を正確に反映した字幕を生成することができます。
プロンプト例:
def create_claude_prompt(formatted_content: str) -> str: """Claudeへ送信するプロンプトを作成""" return ( "あなたはTextGridファイルをSRTファイルに変換するアシスタントです。以下の基準に従って変換を行ってください:\n\n" "## 1. 字幕の区切り方\n" "- 1行あたり30文字を目安とする\n" "- 改行は行わない\n" "- 以下の基準で区切りを入れる:\n" " - 文法的な区切り(文節)を優先\n" " - 主語と述語は可能な限り同じ字幕に含める\n" " - 助詞・助動詞は前の内容とセットにする\n" "## 2. タイミングの調整\n" "- 各字幕に最低1秒の表示時間を確保\n" "- 字幕間に100-200ミリ秒の間隔を設ける\n" "- タイムコードは以下の形式で記述:\n" " 00:00:00,000 --> 00:00:00,000 (時:分:秒,ミリ秒)\n\n" "- 「〜て」「〜に」などの接続助詞で終わる場合は次の内容と\n\n" "## 3. シンボルを追記する\n" "- 劇中に誰かが発言されたことが明記されている箇所は鉤括弧(「」)を追加する\n" "## 5. フォーマット\n" "```\n" "1\n" "00:00:11,360 --> 00:00:13,360\n" "1行目の内容\n" "2\n" "...\n" "```\n\n" "## 6. チェックリスト\n" "変換後、以下の点を確認:\n" "1. すべての時間が正しく変換されているか\n" "2. 字幕の表示時間は適切か\n" "3. 文の区切りは自然か\n" "4. 意味のつながりは保たれているか\n" "## 7. 補足\n" "- 話し言葉の場合、発話の区切りを優先する\n" "- 専門用語や固有名詞は分割を避ける\n" "- 感情表現(「!」「?」など)は前の内容と一緒にする\n\n" "ただし、あなた自身の言葉は不要で、結果だけを返してください。\n\n" "以下のデータを上記の基準に従って変換してください:\n\n" f"{formatted_content}" )
分割された全てのSRTの推敲を終えると、最終的にはそれぞれのSRTをマージして、一つのSRTを作成します。これが実際に字幕ファイルとして活用できる最終成果物です。
main.py
main.py
の役割は、先ほどのスクリプトから関数をimport
して実行することです。
main.py
の9行目のproject name
はinput directory
のTextGrid
があるdir名に変更してください。
スクリプト
import os import time import textgrid_converter import srt_divider import claude_processor def main(): # --- 設定 --- project_name = "yume" # プロジェクト名 (ファイルパスに使用) base_input_dir = "./input" base_output_dir = "./output" # TextGrid -> SRT 変換設定 textgrid_input_file = os.path.join(base_input_dir, project_name, f"{project_name}.TextGrid") initial_srt_output_file = os.path.join(base_output_dir, project_name, f"{project_name}.srt") # SRT 分割設定 split_output_dir = os.path.join(base_output_dir, project_name, "split") split_output_prefix = os.path.join(split_output_dir, "split") split_percentile = 95.0 split_min_entries = 5 split_max_entries = 60 # Claude 処理 & マージ設定 claude_intermediate_output_dir = os.path.join(base_output_dir, project_name, "divided_output") claude_final_output_file = os.path.join(base_output_dir, project_name, "merged_output.srt") claude_retry_attempts = 1 claude_retry_delay = 5 # --- ディレクトリ準備 --- os.makedirs(os.path.dirname(initial_srt_output_file), exist_ok=True) os.makedirs(split_output_dir, exist_ok=True) os.makedirs(claude_intermediate_output_dir, exist_ok=True) if not os.path.exists(os.path.dirname(textgrid_input_file)): print(f"エラー: 入力ディレクトリが見つかりません: {os.path.dirname(textgrid_input_file)}") print("処理を中断します。") return if not os.path.exists(textgrid_input_file): print(f"エラー: 入力TextGridファイルが見つかりません: {textgrid_input_file}") print("TextGridファイルを配置してから再実行してください。") print("処理を中断します。") return start_time = time.time() print(f"プロジェクト '{project_name}' の処理を開始します。") try: # 1. TextGrid -> SRT 変換 print("\n--- ステップ 1: TextGrid から SRT へ変換 ---") textgrid_converter.textgrid_to_srt(textgrid_input_file, initial_srt_output_file) # 変換結果が存在するか確認 if not os.path.exists(initial_srt_output_file) or os.path.getsize(initial_srt_output_file) == 0: print(f"エラー: SRTファイル ({initial_srt_output_file}) が生成されませんでした。以降の処理をスキップします。") return # 2. SRT 分割 print("\n--- ステップ 2: SRT ファイルを分割 ---") num_split_files = srt_divider.split_srt_file( input_path=initial_srt_output_file, output_prefix=split_output_prefix, percentile=split_percentile, min_entries_per_segment=split_min_entries, max_entries_per_segment=split_max_entries ) if num_split_files == 0: print("SRTファイルが分割されませんでした。Claude処理をスキップします。") return # 3. Claude 処理 & マージ print("\n--- ステップ 3: Claude で処理し、最終マージ ---") claude_processor.process_files_with_claude( input_dir=split_output_dir, output_dir=claude_intermediate_output_dir, final_output_path=claude_final_output_file, retry_attempts=claude_retry_attempts, retry_delay=claude_retry_delay ) end_time = time.time() print(f"\n--- 全ての処理が完了しました ---") print(f"最終出力ファイル: {claude_final_output_file}") print(f"総処理時間: {end_time - start_time:.2f} 秒") except Exception as e: print(f"\n--- エラー発生により処理が中断されました ---") print(f"エラー詳細: {e}") import traceback traceback.print_exc() # 詳細なトレースバックを出力 if __name__ == "__main__": main()
その他必要なファイル
また、実際にこのコードを動かすために必要なファイルはこちらです。
私の手元では仮想環境作成のためにPipenv (version 2024.4.1)
を使用しているため、Pipfile
とPipfile.lock
が必要なことをご留意ください。
claude_api.py
import anthropic import os from dotenv import load_dotenv load_dotenv('.env') key = os.environ.get('ANTHROPIC_KEY') if not key: raise ValueError("ANTHROPIC_KEYが環境変数に設定されていません。.envファイルを確認してください。") client = anthropic.Anthropic(api_key=key) def send_to_claude(prompt: str) -> str: """ プロンプトをClaude APIに送信して応答を取得する関数 """ try: message = client.messages.create( model="claude-3-5-sonnet-20240620", max_tokens=4096, temperature=0.0, system="", messages=[ { "role": "user", "content": prompt } ] ) if message.content and isinstance(message.content, list) and hasattr(message.content[0], 'text'): return message.content[0].text else: print("Warning: Claudeからの予期しないレスポンス形式です。", message) return "" except anthropic.APIConnectionError as e: print(f"Claude APIへの接続に失敗しました: {e}") raise except anthropic.RateLimitError as e: print(f"Claude APIのレート制限に達しました: {e}") raise except anthropic.APIStatusError as e: print(f"Claude APIエラー (ステータスコード: {e.status_code}): {e.response}") raise except Exception as e: print(f"Claude APIとの通信中に予期せぬエラーが発生しました: {e}") raise
.env
ANTHROPIC_KEY={YOUR_API_KEY}
Pipfile
[[source]] url = "https://pypi.org/simple" verify_ssl = true name = "pypi" [packages] numpy = "2.2.4" anthropic = "0.49.0" python-dotenv = "1.1.0" [dev-packages] [requires] python_version = "3.12"
Pipfile.lock
{ "_meta": { "hash": { "sha256": "3cd563feca3966635f4324187ca2536fb9f88ecaaf426dcf339305895dd41db9" }, "pipfile-spec": 6, "requires": { "python_version": "3.12" }, "sources": [ { "name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true } ] }, "default": { "annotated-types": { "hashes": [ "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" ], "markers": "python_version >= '3.8'", "version": "==0.7.0" }, "anthropic": { "hashes": [ "sha256:bbc17ad4e7094988d2fa86b87753ded8dce12498f4b85fe5810f208f454a8375", "sha256:c09e885b0f674b9119b4f296d8508907f6cff0009bc20d5cf6b35936c40b4398" ], "index": "pypi", "markers": "python_version >= '3.8'", "version": "==0.49.0" }, "anyio": { "hashes": [ "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c" ], "markers": "python_version >= '3.9'", "version": "==4.9.0" }, "certifi": { "hashes": [ "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" ], "markers": "python_version >= '3.6'", "version": "==2025.1.31" }, "distro": { "hashes": [ "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" ], "markers": "python_version >= '3.6'", "version": "==1.9.0" }, "h11": { "hashes": [ "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" ], "markers": "python_version >= '3.7'", "version": "==0.14.0" }, "httpcore": { "hashes": [ "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd" ], "markers": "python_version >= '3.8'", "version": "==1.0.7" }, "httpx": { "hashes": [ "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" ], "markers": "python_version >= '3.8'", "version": "==0.28.1" }, "idna": { "hashes": [ "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], "markers": "python_version >= '3.6'", "version": "==3.10" }, "jiter": { "hashes": [ "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", "sha256:0c058ecb51763a67f019ae423b1cbe3fa90f7ee6280c31a1baa6ccc0c0e2d06e", "sha256:113f30f87fb1f412510c6d7ed13e91422cfd329436364a690c34c8b8bd880c42", "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", "sha256:1339f839b91ae30b37c409bf16ccd3dc453e8b8c3ed4bd1d6a567193651a4a51", "sha256:1537a890724ba00fdba21787010ac6f24dad47f763410e9e1093277913592784", "sha256:161d461dcbe658cf0bd0aa375b30a968b087cdddc624fc585f3867c63c6eca95", "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", "sha256:1fd19112d1049bdd47f17bfbb44a2c0001061312dcf0e72765bfa8abd4aa30e5", "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", "sha256:2221176dfec87f3470b21e6abca056e6b04ce9bff72315cb0b243ca9e835a4b5", "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", "sha256:2685f44bf80e95f8910553bf2d33b9c87bf25fceae6e9f0c1355f75d2922b0ee", "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", "sha256:27cd1f2e8bb377f31d3190b34e4328d280325ad7ef55c6ac9abde72f79e84d2e", "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", "sha256:351f4c90a24c4fb8c87c6a73af2944c440494ed2bea2094feecacb75c50398ae", "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", "sha256:3c7adb66f899ffa25e3c92bfcb593391ee1947dbdd6a9a970e0d7e713237d572", "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", "sha256:42f8a68a69f047b310319ef8e2f52fdb2e7976fb3313ef27df495cf77bcad965", "sha256:4a2d16360d0642cd68236f931b85fe50288834c383492e4279d9f1792e309571", "sha256:4feafe787eb8a8d98168ab15637ca2577f6ddf77ac6c8c66242c2d028aa5420e", "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", "sha256:5cfc7d0a8e899089d11f065e289cb5b2daf3d82fbe028f49b20d7b809193958d", "sha256:5f4c677c424dc76684fea3e7285a7a2a7493424bea89ac441045e6a1fb1d7b3b", "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", "sha256:6ef5da104664e526836070e4a23b5f68dec1cc673b60bf1edb1bfbe8a55d0678", "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", "sha256:7825f46e50646bee937e0f849d14ef3a417910966136f59cd1eb848b8b5bb3e4", "sha256:7a9aaa5102dba4e079bb728076fadd5a2dca94c05c04ce68004cfd96f128ea34", "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad", "sha256:8793b6df019b988526f5a633fdc7456ea75e4a79bd8396a3373c371fc59f5c9b", "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", "sha256:923b54afdd697dfd00d368b7ccad008cccfeb1efb4e621f32860c75e9f25edbd", "sha256:9872aeff3f21e437651df378cb75aeb7043e5297261222b6441a620218b58708", "sha256:9897115ad716c48f0120c1f0c4efae348ec47037319a6c63b2d7838bb53aaef4", "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea", "sha256:9ef340fae98065071ccd5805fe81c99c8f80484e820e40043689cf97fb66b3e2", "sha256:9f3c848209ccd1bfa344a1240763975ca917de753c7875c77ec3034f4151d06c", "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", "sha256:a25519efb78a42254d59326ee417d6f5161b06f5da827d94cf521fed961b1ff2", "sha256:a7954a401d0a8a0b8bc669199db78af435aae1e3569187c2939c477c53cb6a0a", "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", "sha256:c0194f813efdf4b8865ad5f5c5f50f8566df7d770a82c51ef593d09e0b347020", "sha256:c43ca669493626d8672be3b645dbb406ef25af3f4b6384cfd306da7eb2e70322", "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", "sha256:c98d27330fdfb77913c1097a7aab07f38ff2259048949f499c9901700789ac15", "sha256:cb12e6d65ebbefe5518de819f3eda53b73187b7089040b2d17f5b39001ff31c4", "sha256:d1d9870561eb26b11448854dce0ff27a9a27cb616b632468cafc938de25e9e51", "sha256:d45807b0f236c485e1e525e2ce3a854807dfe28ccf0d013dd4a563395e28008a", "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", "sha256:d82a811928b26d1a6311a886b2566f68ccf2b23cf3bfed042e18686f1f22c2d7", "sha256:d838650f6ebaf4ccadfb04522463e74a4c378d7e667e0eb1865cfe3990bfac49", "sha256:dd5ab5ddc11418dce28343123644a100f487eaccf1de27a459ab36d6cca31043", "sha256:e00a1a2bbfaaf237e13c3d1592356eab3e9015d7efd59359ac8b51eb56390a12", "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", "sha256:e3630ec20cbeaddd4b65513fa3857e1b7c4190d4481ef07fb63d0fad59033321", "sha256:e84ed1c9c9ec10bbb8c37f450077cbe3c0d4e8c2b19f0a49a60ac7ace73c7452", "sha256:e8b36d8a16a61993be33e75126ad3d8aa29cf450b09576f3c427d27647fcb4aa", "sha256:eda3f8cc74df66892b1d06b5d41a71670c22d95a1ca2cbab73654745ce9d0419", "sha256:efb767d92c63b2cd9ec9f24feeb48f49574a713870ec87e9ba0c2c6e9329c3e2", "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", "sha256:ffba79584b3b670fefae66ceb3a28822365d25b7bf811e030609a3d5b876f538" ], "markers": "python_version >= '3.8'", "version": "==0.9.0" }, "numpy": { "hashes": [ "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", "sha256:0d54974f9cf14acf49c60f0f7f4084b6579d24d439453d5fc5805d46a165b542", "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", "sha256:218f061d2faa73621fa23d6359442b0fc658d5b9a70801373625d958259eaca3", "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", "sha256:2fa8fa7697ad1646b5c93de1719965844e004fcad23c91228aca1cf0800044a1", "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", "sha256:4ba5054787e89c59c593a4169830ab362ac2bee8a969249dc56e5d7d20ff8df9", "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", "sha256:7051ee569db5fbac144335e0f3b9c2337e0c8d5c9fee015f259a5bd70772b7e8", "sha256:7716e4a9b7af82c06a2543c53ca476fa0b57e4d760481273e09da04b74ee6ee2", "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", "sha256:8146f3550d627252269ac42ae660281d673eb6f8b32f113538e0cc2a9aed42b9", "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", "sha256:892c10d6a73e0f14935c31229e03325a7b3093fafd6ce0af704be7f894d95687", "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", "sha256:9eeea959168ea555e556b8188da5fa7831e21d91ce031e95ce23747b7609f8a4", "sha256:a0258ad1f44f138b791327961caedffbf9612bfa504ab9597157806faa95194a", "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", "sha256:a84eda42bd12edc36eb5b53bbcc9b406820d3353f1994b6cfe453a33ff101775", "sha256:ab2939cd5bec30a7430cbdb2287b63151b77cf9624de0532d629c9a1c59b1d5c", "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", "sha256:adf8c1d66f432ce577d0197dceaac2ac00c0759f573f28516246351c58a85020", "sha256:b4adfbbc64014976d2f91084915ca4e626fbf2057fb81af209c1a6d776d23e3d", "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", "sha256:bd3ad3b0a40e713fc68f99ecfd07124195333f1e689387c180813f0e94309d6f", "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", "sha256:cf28633d64294969c019c6df4ff37f5698e8326db68cc2b66576a51fad634880", "sha256:d0f35b19894a9e08639fd60a1ec1978cb7f5f7f1eace62f38dd36be8aecdef4d", "sha256:db1f1c22173ac1c58db249ae48aa7ead29f534b9a948bc56828337aa84a32ed6", "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", "sha256:df2f57871a96bbc1b69733cd4c51dc33bea66146b8c63cacbfed73eec0883017", "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", "sha256:e642d86b8f956098b564a45e6f6ce68a22c2c97a04f5acd3f221f57b8cb850ae", "sha256:e9e0a277bb2eb5d8a7407e14688b85fd8ad628ee4e0c7930415687b6564207a4", "sha256:ea2bb7e2ae9e37d96835b3576a4fa4b3a97592fbea8ef7c3587078b0068b8f09", "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", "sha256:f4162988a360a29af158aeb4a2f4f09ffed6a969c9776f8f3bdee9b06a8ab7e5", "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", "sha256:f7de08cbe5551911886d1ab60de58448c6df0f67d9feb7d1fb21e9875ef95e91" ], "index": "pypi", "markers": "python_version >= '3.10'", "version": "==2.2.4" }, "pydantic": { "hashes": [ "sha256:2138628e050bd7a1e70b91d4bf4a91167f4ad76fdb83209b107c8d84b854917e", "sha256:7f17d25846bcdf89b670a86cdfe7b29a9f1c9ca23dee154221c9aa81845cfca7" ], "markers": "python_version >= '3.9'", "version": "==2.11.2" }, "pydantic-core": { "hashes": [ "sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672", "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", "sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5", "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", "sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5", "sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5", "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", "sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33", "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", "sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2", "sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb", "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", "sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331", "sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396", "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", "sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea", "sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b", "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", "sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b", "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", "sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599", "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", "sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6", "sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d", "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", "sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5", "sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd", "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", "sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96", "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", "sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824", "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3", "sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568", "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", "sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad", "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091" ], "markers": "python_version >= '3.9'", "version": "==2.33.1" }, "python-dotenv": { "hashes": [ "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d" ], "index": "pypi", "markers": "python_version >= '3.9'", "version": "==1.1.0" }, "sniffio": { "hashes": [ "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" ], "markers": "python_version >= '3.7'", "version": "==1.3.1" }, "typing-extensions": { "hashes": [ "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff" ], "markers": "python_version >= '3.8'", "version": "==4.13.1" }, "typing-inspection": { "hashes": [ "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122" ], "markers": "python_version >= '3.9'", "version": "==0.4.0" } }, "develop": {} }
この章の冒頭でも紹介しましたが、改めてディレクトリ構成は下記の通りになっていると芳しいです。
your_project_directory/ ├── main.py ├── textgrid_converter.py ├── srt_divider.py ├── claude_processor.py ├── claude_api.py ├── .env # Anthropic APIキーなどを記述 ├── Pipfile # pipenvの生成物 ├── Pipfile.lock ├── input/ │ └── yume/ # project nameなので任意の値でok │ └── yume.TextGrid # MFAの出力ファイル └── output/ #スクリプト実行で生成されるディレクトリ. └── yume/ # project nameなので任意の値でok ├── yume.srt # textgrid_converter の出力 ├── split/ # srt_divider の出力ディレクトリ │ ├── split_001.srt │ └── ... ├── divided_output/ # claude_processor の中間出力ディレクトリ │ ├── split_001_processed.srt │ └── ... └── merged_output.srt # claude_processor の最終出力
main.py
の実行を完了させると、output/{project_name}/
にmerged_output.srt
というファイルが生成されます。
これが動画に字幕を載せるための最終的な字幕ファイルになります。
最終成果物
merged_output.srt
の中身を動画に流し込んだ結果が以下の通りです。
いかがでしょうか。一部に改行位置の不適切さなどの細かな課題は見られるものの、全体としては高い精度で字幕を付与できていると思います。
またさらに精度の高いSRTを生成したい場合は、merged_output.srt
を再度分割し、各ファイルに対してより細やかな校正を適用するアプローチが有効だと思います。このプロセスでは改行位置の最適化や視認性の向上などに焦点を当てたプロンプトの調整が必要になりますが、現在の基盤があれば比較的容易に実装できるはずです。
最後に
いかがだったでしょうか。今回はMFAの出力を効率よく校正する方法をご紹介しました。 ただ、今回紹介した方法では台本(原稿)が必要になるというMFAの制約があります。しかしながら、この記事を執筆中に非常に精度の高い書き起こしのサービスが登場しました。そこで次回は台本がなくても高品質な字幕を生成できる、音声認識ベースの書き起こし方法についても紹介する予定です。そちらもぜひお楽しみに。 それではまた次回の記事でお会いしましょう!