📚

動画や音声をサクッと翻訳して字幕をつける

2024/08/02に公開

経緯

動画の文字起こしツールって乱立していて大量にあるんですが、API を使っているからなのか有料のものが多いようです。使い勝手を考え出すと大変ですが、基本的な部分は割と簡単に実装できそうだったので試しに実装してみました。

DeepL の API key を取得すれば誰でも使えます。

つくったもの

以下のツールを実装します。

  • 動画を文字起こしして字幕をつけるツール
  • 動画に DeepL で翻訳した字幕をつけるツール

まず動画の文字起こしを行い、次に文字起こししたデータを翻訳します。

字幕(srt)、テキストファイル(txt)を翻訳前と翻訳後でそれぞれ出力します。

使用技術

  • 動画の文字起こし: Whisper
  • 翻訳: DeepL
  • 動画変換: ffmpeg

Whisper は OpenAI が提供する音声認識 AI で、高い精度で文字起こしが可能です。API 経由は有料ですが、GitHub 上のオープンソースモデルを使えば、無料で使えます。

DeepL は言わずもがなの翻訳用 AI ですね。翻訳に限れば GPT より高性能でしょう。今回は API 経由で使用します。

また、Whisper で動画を読み込む場合、変換に ffmpeg を使用するため、ffmpeg のパスを通しておく必要があります。

事前準備

Python はバージョンによってライブラリ等の対応状況が違うため、システムの Python をそのまま使うのではなく、何らかの仮想環境を構築して使うのが一般的です。

Google Colaboratory

今回掲載するソースを試しに動かすだけならば Google Colab を使うのが一番お手軽だと思います。ただ、一定時間を超えるとリセットされてしまうため、自分で何かしらのアプリケーションを作りたい場合はローカルで開発を行い、重い計算は Google Colab に任せるといった方法を推奨します。

ローカル環境

Anaconda は商用ライセンスが有償化されている他、システムの Python や pip と競合することがあるので注意です。Python のパッケージ・バージョン管理はいろいろな方法がありますが、今回私が使用したのは WSL+pyenv+venv の環境です。

パッケージ管理の選択肢は以下の記事などを見て、自分の用途にあったものを選ぶといいでしょう。

今回私が用いたのは以下の記事と同様の手法です。

環境構築

Python の環境構築がすでにできていることを前提とします。

ffmpeg のインストール

ffmpeg がインストールできていない場合はインストールしておきます。 (Debian, Ubuntu 系の場合は以下コマンド)

sudo apt install ffmpeg

Windows 上に直接構築した場合は ffmpeg を任意のフォルダに配置し、パスを通しておいてください。

Google Colab の場合、ffmpeg はデフォルトで入っているためインストールは不要です。

Whisper のインストール

Whisper をインストールします。

ローカル環境の場合

pip install git+https://github.com/openai/whisper.git

Google Colab 上でコマンドを使用する場合は!pipというように先頭に!をつけて実行します。

Google Colab の場合

!pip install git+https://github.com/openai/whisper.git

CUDA のセットアップ (ローカル環境で GPU を使う場合)

PC に Nvidia の GPU が刺さっている場合は CUDA を使うと高速になります。今回はローカル環境で GPU を使用していないため未確認ですが、セットアップしておいたほうが快適に使えるでしょう。

参考記事

ソースと解説

動画の文字起こし

動画の文字起こしを行うプログラムは以下のようになります。Whisper で文字起こしをしている箇所はtranscribe関数の部分で、その他の部分はデータ整形とファイル読み書きを行っています。

Whisper の出力データからセグメント単位の情報を得ることができるため、それを字幕データに整形しています。

動画ファイルを読み込み、srt 形式の字幕とテキストファイルに出力します。

from __future__ import unicode_literals
import whisper
import pathlib
import math
from functools import reduce

FILE_NAME = "sample.mp4" # Video or Audio file to be transcribed.

def transcribe(file: str):
    model = whisper.load_model("medium")
    transcribe_result = model.transcribe(file)
    return transcribe_result["segments"]

def convert_time(t: float):
    hour = math.floor(t / (60 * 60))
    minute = math.floor(t / 60) - (hour * 60)
    sec = math.floor(t % 60)
    msec = round((t % 1) * (10**3))

    return f"{hour:02}:{minute:02}:{sec:02}.{msec:03}"

def get_srt_section(index: int, start_sec: float, end_sec: float, text: str):
    start = convert_time(start_sec)
    end = convert_time(end_sec)
    return [
        f"{index}",
        f"{start} --> {end}",
        text,
        ""
    ]

def write_file(file_name: str, text: str):
    with open(file_name, "w") as f:
        f.write(text)

def get_file_stem(file_name: str):
    return pathlib.PurePath(file_name).stem

if __name__ == '__main__':
    movie_segments = transcribe(FILE_NAME)

    source_srt_list: list[str] = reduce(
        lambda arr, curr: arr + get_srt_section(
            curr[0],
            curr[1]["start"],
            curr[1]["end"],
            curr[1]["text"]),
        enumerate(movie_segments),
        [])
    write_file(f"{get_file_stem(FILE_NAME)}.srt", "\n".join(source_srt_list))

    movie_texts = [seg["text"] for seg in movie_segments]
    write_file(f"{get_file_stem(FILE_NAME)}.txt", "\n".join(movie_texts))

動画の翻訳

translate関数を追加し、文字起こしを翻訳します。DeepL の API には文字列のリストを渡すことができるため、Whisper で得たセグメント単位の文字データを単純な文字列のリストに整形して DeepL の API に渡します。翻訳結果を字幕形式のデータに整形すれば OK です。

from __future__ import unicode_literals
import whisper
import pathlib
import math
from functools import reduce
import requests

FILE_NAME = "sample.mp4" # Video or Audio file to be translated.

DEEP_L_URL = "https://api-free.deepl.com/v2/translate" # Note that the url is different for the free version and the paid version.
DEEP_L_API_KEY = "Enter Your DeepL API Key" # DeepL API Key
SOURCE_LANGUAGE = "en" # Source language of the video.
TARGET_LANGUAGE = "ja" # Specify the translation language.

def transcribe(file: str):
    model = whisper.load_model("large")
    transcribe_result = model.transcribe(file)
    return transcribe_result["segments"]

def convert_time(t: float):
    hour = math.floor(t / (60 * 60))
    minute = math.floor(t / 60) - (hour * 60)
    sec = math.floor(t % 60)
    msec = round((t % 1) * (10**3))

    return f"{hour:02}:{minute:02}:{sec:02}.{msec:03}"

def get_srt_section(index: int, start_sec: float, end_sec: float, text: str):
    start = convert_time(start_sec)
    end = convert_time(end_sec)
    return [
        f"{index}",
        f"{start} --> {end}",
        text,
        ""
    ]

def write_file(file_name: str, text: str):
    with open(file_name, "w") as f:
        f.write(text)

def get_file_stem(file_name: str):
    return pathlib.PurePath(file_name).stem

def translate(text: list[str], source_lang: str, target_lang: str):
    params = {
                'auth_key' : DEEP_L_API_KEY,
                'text' : text,
                'source_lang' : source_lang.upper(),
                "target_lang": target_lang.upper()
            }

    deep_l_request = requests.post(DEEP_L_URL, data=params)
    deep_l_result = deep_l_request.json()

    return [row["text"] for row in deep_l_result["translations"]]

if __name__ == '__main__':
    movie_segments = transcribe(FILE_NAME)

    source_srt_list: list[str] = reduce(
        lambda arr, curr: arr + get_srt_section(
            curr[0],
            curr[1]["start"],
            curr[1]["end"],
            curr[1]["text"]),
        enumerate(movie_segments),
        [])
    write_file(f"{get_file_stem(FILE_NAME)}_{SOURCE_LANGUAGE}.srt", "\n".join(source_srt_list))

    movie_texts = [seg["text"] for seg in movie_segments]
    write_file(f"{get_file_stem(FILE_NAME)}_{SOURCE_LANGUAGE}.txt", "\n".join(movie_texts))

    translated_texts = translate(movie_texts, SOURCE_LANGUAGE, TARGET_LANGUAGE)

    translated_srt_list: list[str] = reduce(
        lambda arr, curr: arr + get_srt_section(
            curr[0],
            curr[1]["start"],
            curr[1]["end"],
            translated_texts[curr[0]]),
        enumerate(movie_segments),
        [])

    write_file(f"{get_file_stem(FILE_NAME)}_{TARGET_LANGUAGE}.srt", "\n".join(translated_srt_list))
    write_file(f"{get_file_stem(FILE_NAME)}_{TARGET_LANGUAGE}.txt", "\n".join(translated_texts))

実際に動かしてみる

文字起こし

以下のフリー音源で文字起こしツールを試してみました。今回は音声ファイルを使っていますが動画ファイルでも問題なく動きます。

https://github.com/koniwa/koniwa/tree/master

ごんぎつねの朗読

対象ファイル: tnc フォルダ内の"tnc__gongitsune.aac"
ライセンス: CC BY 3.0
ごんぎつねのライセンス: Public Domain

ごんぎつねの朗読データがあったので、試しに文字起こししてみました。

アナウンサーがはっきりとした滑舌で喋るため精度はかなりいいです。

ほぼ平仮名で出力されるのは童話だから?声の調子から子供向けだと判断しているのかもしれないです。

0
00:00:00.000 --> 00:00:04.000
ごんぎつね

1
00:00:04.000 --> 00:00:08.000
にいみなんきち

2
00:00:08.000 --> 00:00:18.000
これは、わたしが小さいときに、 村のもへいというおじいさんから聞いたお話です。

3
00:00:18.000 --> 00:00:33.000
むかしは、わたしたちの村のちかくの、 なかやまというところに、ちいさなおしろがあって、 なかやまさまというおとのさまがおられたそうです。

4
00:00:33.000 --> 00:00:42.000
そのなかやまからすこしはなれた山のなかに、 ごんぎつねというきつねがいました。

5
00:00:42.000 --> 00:00:47.000
ごんはひとりぼっちのこぎつねで、

ラジオ放送

対象ファイル: amagasaki フォルダ内の"amagasaki__2014_09_23.mp3"
ライセンス: CC BY 4.0

次にラジオ放送を文字起こししてみました。
先程のごんぎつねは特殊な例で、通常は漢字に変換して出力してくれます。

尼崎 → 天ヶ崎、市立 → 一律など同音異義語の誤字以外は、ほぼ正確に文字起こしできていました。

地名や口語独自の読み方(市立 → いちりつ、化学 → ばけがく など)に弱いかもしれません。

0
00:00:00.000 --> 00:00:04.800
みなさんこんにちは。天ヶ崎市長の稲村です。

1
00:00:05.420 --> 00:00:08.180
今回は、秋の特別企画として、

2
00:00:08.520 --> 00:00:13.300
一律天ヶ崎創生高校放送部の皆さんによる番組をお届けします。

3
00:00:14.140 --> 00:00:18.560
それでは、天ヶ崎創生高校放送部の皆さん、どうぞ。

4
00:00:19.580 --> 00:00:20.740
創生なんです。

5
00:00:22.180 --> 00:00:26.020
みなさんこんにちは。創生高校放送部の白井です。

6
00:00:26.300 --> 00:00:26.960
末継です。

7
00:00:27.180 --> 00:00:27.800
兵藤です。

8
00:00:28.980 --> 00:00:29.860
よろしくお願いします。

9
00:00:30.000 --> 00:00:30.120
よろしくお願いします。

10
00:00:31.140 --> 00:00:37.000
天ヶ崎創生高校は、天ヶ崎東高校と天ヶ崎産業高校の伝統を受け継いで、

11
00:00:37.420 --> 00:00:43.720
天ヶ崎市として48年ぶりに新設校として開校した、創立4年目の学校です。

12
00:00:45.220 --> 00:00:49.080
天ヶ崎創生高校には、普通科と専門科の2つがあり、

13
00:00:49.680 --> 00:00:54.840
普通科は国際コミュニケーション類型、自然科学類型、音楽類型、

14
00:00:55.200 --> 00:00:59.900
人文社会類型の4つがあり、専門科にはものづくり機械科、

15
00:01:00.000 --> 00:01:03.540
電気情報科、商業学科の3つがあります。

翻訳

ごんぎつねの朗読を翻訳

タイトルの"ごんぎつね"と作者名の"にいみなんきち"が無理やり意訳されているせいで意味不明になっています。固有名詞は苦手なようです。

ストーリー部分は若干の違和感があるものの概ね正しく訳されています。ただ"oshiro"では通じない気がするので"castle"にしてほしかったですね。

0
00:00:00.000 --> 00:00:04.000
fox spirit from the Kyushu region capable of possession

1
00:00:04.000 --> 00:00:08.000
area of southwestern Japan south of the Median Tectonic Line

2
00:00:08.000 --> 00:00:18.000
This is a story I heard from an old man named Mohei in my village when I was little.

3
00:00:18.000 --> 00:00:33.000
Once upon a time, there was a small oshiro in a place called Nakayama, near our village, and there was a man named Nakayama-sama.

4
00:00:33.000 --> 00:00:42.000
There was a fox named Gongitsune in the mountains a short distance away from the mountain.

5
00:00:42.000 --> 00:00:47.000
Gon is a lonely little fox,

英語の映画を翻訳

Internet Archive に Public Domain の古い映画がありました。

今回はこれを翻訳にかけてみます。
https://archive.org/details/BehindGreenLights

全体を文字起こしすると時間がかかりすぎるため、09:52 から 12:52 の区間をカット編集して入力ファイルとしています。

英語の文字起こし

正確に文字起こしできています。このように短文をやり取りする場合は、字幕が複数のセクションにまたがることがないため翻訳もしやすいです。

0
00:00:00.000 --> 00:00:01.000
 This is Miss Bradley, Lieutenant.

1
00:00:01.000 --> 00:00:02.000
 Lieutenant Carson.

2
00:00:02.000 --> 00:00:03.000
 How do you do?

3
00:00:03.000 --> 00:00:05.000
 Sorry we had to bring you out this hour of the night,

4
00:00:05.000 --> 00:00:06.000
 Miss Bradley.

5
00:00:06.000 --> 00:00:07.000
 Sit down, please.

6
00:00:10.000 --> 00:00:13.000
 What do you know about a man named Walter Bard?

7
00:00:13.000 --> 00:00:15.000
 You knew him?

8
00:00:15.000 --> 00:00:16.000
 Knew him?

9
00:00:16.000 --> 00:00:17.000
 I knew him.

日本語に翻訳

文字列のリストで渡しても文脈を判断してくれるわけではなさそうです。それぞれが独立した文章として解釈されます。

ただ、短文のやり取りなのでそこまで違和感はありません。

0
00:00:00.000 --> 00:00:01.000
中尉、ブラッドリーさんです。

1
00:00:01.000 --> 00:00:02.000
カーソン中尉

2
00:00:02.000 --> 00:00:03.000
はじめまして。

3
00:00:03.000 --> 00:00:05.000
こんな時間に呼び出して申し訳ない、

4
00:00:05.000 --> 00:00:06.000
ブラッドリーさん

5
00:00:06.000 --> 00:00:07.000
座ってください。

6
00:00:10.000 --> 00:00:13.000
ウォルター・バルドという男について知っていることは?

7
00:00:13.000 --> 00:00:15.000
彼を知っているのか?

8
00:00:15.000 --> 00:00:16.000
彼を知っていた?

9
00:00:16.000 --> 00:00:17.000
私は彼を知っていた。

翻訳の改善案

全部まとめて DeepL に渡す

セクション毎ではなく、全部まとめて DeepL に渡せば自然な文章になります。今回作成したツールは srt だけではなく、txt も出力するため、それをそのまま DeepL の翻訳画面に貼り付けて翻訳することも可能です。

ただ、字幕ファイルの形式が崩れるのが難点です。

ピリオドで区切る

今回は Whisper で生成した文章をそのまま DeepL に渡しました。この場合、短文形式の場合は文章が破綻しづらいのですが、文章が複数のセクションにまたがる場合はうまく翻訳できず意味不明な文章になることがあります。ピリオド(あるいは"。")区切りになるように整形してから DeepL に渡せばより自然な文章になりそうです。

この方法なら字幕形式を保ったままにできますが、一度に表示される文章が多くなります。特に日本語の場合、区切らずに延々と文章をつなげることがままあるので、字幕付きで動画をみたら文章で画面が埋め尽くされるなんてことが起きるかもしれません。

応用

Whisper で文字起こししたデータを GPT などの AI に投げれば色々と面白いことができます。例えば感情値を出したり、要約をしたり、議事録を自動生成したりといったことですね。

OpenCV を使えばテキストと一緒に画像を渡すこともできそうです。

割と簡単にできそうなので今後やる気が出たら記事にするかもしれません。

Thinkingsテックブログ

Discussion