英語の動画を自動翻訳する(改良編2)

Ubuntu
スポンサーリンク

Amazonのアソシエイトとして、当ブログは適格販売により収入を得ています。

前回の記事までにこの手の記事は3つ書きました。
この3つは、実は内部処理で「英文ひとつ」に対し、「翻訳文をひとつ」返すという処理になっていました。
このため、全体としての翻訳文の意味がわかりにくくなる場合がありました。

今回はこのあたりに手を入れ、ある程度まとまった英文を渡し、まとめて翻訳するという変更が入っています。
それから以前までは「Qwen3」をメインにしていましたが、今回は「gpt-oss-120b」を使っています。

ただし、前回までのようにスクリプトの中で外部ライブラリを呼び出して動かすという方法ではなく、「whisper」と「llama.cpp」をクローンしてメインスクリプトから呼び出す方法をとっています。
かなり大掛かりな変更だったため、デバッグも十分ではないかもしれません。
一応まっさらなUbuntuを入れ直してインストールからテストしていますが、何か不具合があったら連絡していただけるとうれしいです。

あと、今回のスクリプトはUbuntu用です。
開発は「Intel Arc」でやっています。
ソースコード自体は「GeForce」や「Radeon」でも動くよう書いたつもりですが、試してはいません。

インストールを工夫して、ソースコードを変更すれば、Windowsでも動くと思いますが、たぶんやらない・・・、かな?

それでは、いっていみましょう。

環境の準備

GeForceの場合はわかりません。持ってないもので・・・。

インストールスクリプト

以下は、「Intel Arc用」です。Radeonの場合はpytorchの部分が違います。
その場合は「#pip install –pre torch torchvision –index-url https://download.pytorch.org/whl/nightly/rocm7.1」のコメントアウトをはずし、「pip install –pre torch torchvision torchaudio –index-url https://download.pytorch.org/whl/nightly/xpu」をコメントアウトしてください。

#!/bin/bash

#インストールに必要なライブラリ等をインストール
sudo apt update
sudo apt install git
sudo apt install curl
sudo apt install build-essential cmake
#sudo apt install ffmpeg #自前でビルドした場合はこの行はコメントアウトしてください。

# uvをcurlでインストール
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env

mkdir -p $HOME/install/langtrans_llama/input
mkdir -p $HOME/install/langtrans_llama/models

cd ~/install/langtrans_llama

#uvでpython3.13環境を作る
uv venv --python 3.13 --seed
source .venv/bin/activate

uv pip install openai requests

#whisperをクローン
git clone https://github.com/openai/whisper.git
cd whisper

#Radeonの場合のpytorchのインストール
#pip install --pre torch torchvision --index-url https://download.pytorch.org/whl/nightly/rocm7.1

#torch.xpuのnightly(oneAPIベースツールキットと整合が取れない場合があるため)
pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/xpu

#whisperのセットアップ
uv pip install -U pip setuptools
uv pip install . 
cd ../

#準備とllama.cppのクローン
sudo apt install -y cmake g++ libcurlpp-dev
git clone  https://github.com/ggml-org/llama.cpp.git
cd llama.cpp

#CPU用ビルド(ggufの変換で使う)
mkdir build-cpu
cmake -B build-cpu
cmake --build build-cpu --config Release -j$(nproc)

#sycl用ビルド
source /opt/intel/oneapi/setvars.sh
cmake -B build-sycl -DGGML_SYCL=ON -DCMAKE_C_COMPILER=icx -DCMAKE_CXX_COMPILER=icpx
cmake --build build-sycl --config Release -j$(nproc)

#vulkanビルド
sudo apt install -y libvulkan-dev glslc
cmake -B build-vulkan -DGGML_VULKAN=ON
cmake --build build-vulkan --config Release -j$(nproc)

torch.xpuは「nightly」を使っています。
「stable」はoneAPIベースツールキットとの整合が取れない場合があるみたいです。

メインスクリプトの用意

以下のスクリプトを「langtrans_llama.py」として保存してください。

import os
import sys
import subprocess
import time
import requests
import re
import torch
from openai import OpenAI
from pathlib import Path

# ==========================================
# 設定変数(環境に合わせて調整してください)
# ==========================================
# --- 翻訳設定 ---
LANGUAGE = "日本語" #デフォルトは「日本語」です
BATCH_SIZE  = 15      # 一回にまとめてLLMに渡す字幕の数
LIMIT       = None      # None に設定すると全件処理(デバッグ用)
MODEL       = "local-model"
TEMPERATURE = 0.3

# --- whisperモデル ---
WHISPER_MODEL = "medium" #文字起こしに使うモデルです。"tiny","base","small","medium","large","turbo"などから選んで下さい

# --- サーバー設定 ---
#MODEL_PATH  = "./models/gpt-oss-20b-F16.gguf" # 使用するモデルのパス
MODEL_PATH  = "./models/gpt-oss-120b-F16.gguf" # 使用するモデルのパス
CONTEXT_LENGTH = 16392 #「0」だとVRAM空き容量分をほぼ全てコンテキスト長に当てます(実際は学習モデルによります)
CPU_MOE = 33 # 「0」に設定すると、全てのレイヤーをGPUに乗せます
N_GPU_LAYERS = 99 # 学習モデルをいくつGPUレイヤーに乗せるかの設定値(基本的に「CPU_MOE」で調整します)
PORT        = 8080
BASE_URL    = f"http://localhost:{PORT}/v1"

#===========================================
#以下はスクリプトの一貫です。触る必要はありません
#===========================================
#ファイルを取得
# フォルダ内を調べて、拡張子が一致するものだけをリストにする
# 対象の拡張子
extensions = {".mp4", ".mkv", ".avi"}
# 最初に見つかった1つだけを FILE に代入。見つからなければ None
FILE = next((p for p in Path("./input").iterdir() if p.suffix.lower() in extensions), None)
if FILE:
    print(f"「{FILE.name}」が見つかりました。")
else:
    print("inputフォルダの中に動画ファイルが見つかりません。")
    sys.exit(1)
#================================================
#パスの取得
temp_dir = Path(FILE.stem + "_temp")
temp_dir.mkdir(exist_ok=True)
audio_path = temp_dir / Path(FILE.stem + ".wav")
input_srt = temp_dir / Path(FILE.stem + ".srt")
translated_srt = temp_dir / Path(FILE.stem + "_translated.srt")
#===========================================
# デバイスの自動判別 (Whisper / Standard Transformers用)
if hasattr(torch, "xpu") and torch.xpu.is_available():
    DEVICE = "xpu"
elif torch.cuda.is_available():
    DEVICE = "cuda"
else:
    DEVICE = "cpu"
print(f"使用デバイス: {DEVICE}")
#===========================================
#llama.cppのサーバーコマンド
if DEVICE == "xpu":
    BACKEND = "sycl"
elif DEVICE == "cuda":
    BACKEND = "vulkan"
else:
    BACKEND = "cpu"
SERVER_BIN  = f"./llama.cpp/build-{BACKEND}/bin/llama-server"
# 確認用
print(f"使用するバックエンド: {BACKEND}")
#===========================================
# 1. 動画から音声を抜き出す処理
def process_video(video_path):
    subprocess.run(["ffmpeg", "-i", str(video_path), "-vn", "-ac", "1", "-ar", "16000", str(audio_path), "-loglevel", "error"], check=True)
    
# 2. 音声からwhisperで文字起こしする処理
def run_whisper(audio_path, temp_dir):
    print(f"--- Whisperの{WHISPER_MODEL}モデルで文字起こし中 ---")
    if DEVICE == "xpu":
        subprocess.run([
            "whisper", str(audio_path),
            "--model", WHISPER_MODEL,
            "--output_dir", str(temp_dir),
            "--output_format", "srt",
            "--device", "xpu"
        ], check=True)
        
    else:
        subprocess.run([
            "whisper", str(audio_path),
            "--model", "medium",
            "--output_dir", str(temp_dir),
            "--output_format", "srt"
        ], check=True)  
      
    
def start_llama_server():
    """llama-serverを起動し、準備ができるまで待機する"""
    if DEVICE == "cpu":
        cmd = [
            SERVER_BIN,
            "-m", MODEL_PATH,
            "--port", str(PORT),
        ]
    else:
        cmd = [
            SERVER_BIN,
            "-m", MODEL_PATH,
            "--port", str(PORT),
            "-ngl", str(N_GPU_LAYERS),
            "-c", str(CONTEXT_LENGTH),
            "--n-cpu-moe", str(CPU_MOE)
        ]
    
    print(f"llama-server を起動中: {' '.join(cmd)}")
    # ログが混ざらないよう、標準出力は捨てます
    process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    #process = subprocess.Popen(cmd, stdout=None, stderr=None)
    
    # 起動完了を待機 (ポーリング)
    print("サーバーの準備完了を待っています...", end="", flush=True)
    max_retries = 90  # 大きなモデル用に長めに設定
    for i in range(max_retries):
        try:
            # llama.cppのヘルスチェック用エンドポイントを確認
            response = requests.get(f"http://localhost:{PORT}/health", timeout=5)
            if response.status_code == 200:
                print("\nサーバー準備完了!")
                return process
        except requests.exceptions.ConnectionError:
            pass
        
        print(".", end="", flush=True)
        time.sleep(1)
    
    process.terminate()
    raise TimeoutError("\nサーバーの起動がタイムアウトしました。パスやモデルを確認してください。")

def translate_batch(client, batch_content):
    """思考プロセスを表示しつつ翻訳する(ストリーミング版)"""
    system_prompt = (
        f"あなたはプロの翻訳家です。SRT形式を守り、英語を{LANGUAGE}に翻訳してください。\n"
        "インデックスがずれないようにしてください。\n"
        "思考プロセスを出力しても構いませんが、最終的な回答は必ずSRT形式にしてください。"
    )
    print(f"翻訳後の言語: {LANGUAGE} ")
    
    try:
        response = client.chat.completions.create(
            model=MODEL,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": batch_content}
            ],
            temperature=TEMPERATURE,
            stream=True 
        )

        full_content = ""
        print("\n--- 思考・翻訳中 ---")
        for chunk in response:
            content = chunk.choices[0].delta.content
            if content:
                print(content, end="", flush=True)
                full_content += content
        print("\n-------------------\n")

        # <think>タグの中身を除去して保存用テキストを作成
        clean_content = re.sub(r'<think>.*?</think>', '', full_content, flags=re.DOTALL).strip()
        return clean_content
    except Exception as e:
        print(f"  [!] エラー: {e}")
        return None

def input_srt_translate(input_srt, translated_srt):
    if not os.path.exists(input_srt):
        print("エラー: 字幕ファイルが見つかりません。")
        return

    # 2. クライアント初期化
    client = OpenAI(base_url=BASE_URL, api_key="sk-no-key-required")

    # 3. 字幕ファイルの読み込みと処理
    with open(input_srt, 'r', encoding='utf-8') as f:
        content = f.read()

    raw_blocks = [b.strip() for b in re.split(r'\n\s*\n', content.strip()) if b.strip()]
    
    indices = []
    for b in raw_blocks:
        lines = b.split('\n')
        match = re.search(r'(\d+)$', lines[0])
        if match:
            indices.append(int(match.group(1)))
    
    detected_limit = max(indices) if indices else 0
    final_limit = LIMIT if LIMIT is not None else detected_limit
    
    print("--- 翻訳開始 ---")
    target_blocks = []
    for block in raw_blocks:
        lines = block.split('\n')
        match = re.search(r'(\d+)$', lines[0])
        if match and int(match.group(1)) > final_limit:
            break
        target_blocks.append(block)

    translated_full_text = []

    # 4. バッチ処理実行
    for i in range(0, len(target_blocks), BATCH_SIZE):
        batch = target_blocks[i : i + BATCH_SIZE]
        batch_text = "\n\n".join(batch)
        
        current_range = f"{i+1}~{min(i + BATCH_SIZE, len(target_blocks))}"
        print(f"[{current_range} / {len(target_blocks)}] 処理中...")
        
        result = translate_batch(client, batch_text)
        translated_full_text.append(result if result else batch_text)

    # 5. 保存
    with open(translated_srt, 'w', encoding='utf-8') as f:
        f.write("\n\n".join(translated_full_text) + "\n")

    print("--- 完了!  翻訳した字幕を出力しました ---")
        
def Combining_srt_an_video(video_path, srt_path, server_process, temp_dir):
    try:
        FILE = Path(video_path)
        output_path = Path(f"{temp_dir}") / f"{Path(video_path).stem}_subtitled.mp4"
        
        print(f"--- 字幕と動画を結合中 ---")
        
        # -c copy: 映像と音声を再エンコードせずにコピー
        # -c:s mov_text: MP4で利用可能な字幕形式に変換
        # -map 0: 元動画の全ストリームを使用
        # -map 1: 字幕ファイルのストリームを使用
        if LANGUAGE == "日本語":
            cmd = [
                "ffmpeg", "-y",
                "-i", str(FILE),
                "-i", str(srt_path),
                "-c", "copy",
                "-c:s", "mov_text",
                "-map", "0",
                "-map", "1",
                "-metadata:s:s:0", "language=jpn",
                "-metadata:s:s:0", "title=Japanese",
                str(output_path),
                "-loglevel", "error"
            ]
            
        else:
            cmd = [
                "ffmpeg", "-y",
                "-i", str(FILE),
                "-i", str(srt_path),
                "-c", "copy",
                "-c:s", "mov_text",
                "-map", "0",
                "-map", "1",
                "-metadata:s:s:0", "language=xxx",
                "-metadata:s:s:0", "title=xxx",
                str(output_path),
                "-loglevel", "error"
            ]       
        
        try:
            subprocess.run(cmd, check=True)
            print("--- 結合完了 ---")
        except subprocess.CalledProcessError as e:
            print(f"結合エラー: {e}") 
                   
    finally:
        # サーバーを確実に停止
        if server_process:
            print("\nサーバーを停止しています...")
            server_process.terminate()
            try:
                server_process.wait(timeout=10) # 10秒待機
            except subprocess.TimeoutExpired:
                print("応答がないため強制終了します...")
                server_process.kill() # 強制終了
                server_process.wait()
            print("サーバーを正常に終了しました。")

if __name__ == "__main__":
    if FILE:
        process_video(FILE)
        run_whisper(audio_path, temp_dir)
        server_process = None
        server_process = start_llama_server()
        input_srt_translate(input_srt, translated_srt)
        Combining_srt_an_video(FILE, translated_srt, server_process, temp_dir)

モデルファイル(gguf)準備

unsloth/gpt-oss-120b-GGUF at main
We’re on a journey to advance and democratize artificial intelligence through open source and open science.

上のリンクから、ggufファイルをダウンロードしましょう。

翻訳する動画を用意して実行

次に「langtrans_llama.py」を実行していくわけですが、まだgradio(GUI)に対応していません。
なので、細かい調整はソースコードをいじることになります。

現状の設定は、VRAM12GBくらいの想定で設定しています。
もしVRAMが足りないようなら、「CPU_MOE」の値で調整してください。
大きくするとVRAM消費量が少なくなります。

設定が済んだらターミナルを起動して、

source .venv/bin/activate
source /opt/intel/oneapi/setvars.sh #Intel Arcを使っている場合はこのコマンドを入れる
python ./langtrans_llama.py

で起動すれば、あとは出来上がるまで待つだけです。

こちらは出来上がった動画の例です。ブログに掲載するため、字幕はトラックではなく、動画自体に焼き付けています。

最後に

このスクリプトの実行内容は、今まで自分が手動でしていたことを全部自動で実行できるようにしたものです。
自分はわかって使っていますが、知らない人が使うとエラーが起こった時に対処できないかもしれません。

もし、llama-serverのあたりでうまく処理が繋がらないようなら、

process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

この部分を

process = subprocess.Popen(cmd, stdout=None, stderr=None)

に変更してください。
ターミナルにエラーなどのログが表示されるようになります。


もうひとつ

スクリプトを強制終了した時に、VRAMのモデル等開放できていない場合があります。
その場合は

pkill -f llama-server

を実行してください。VRAMが開放されると思います。

あと余談ですが、動画の言語は自動判別になっていますので、英語に限らず翻訳できると思います。

今回は以上です。


この記事を書いている現状では、Arc B580は以下のモデルくらいしか安いのは無さそうです。

Amazon | Intel Arc Bシリーズ Intel Arc B580 Limited Edition Graphics ビデオカード 国内正規代理店品 | インテル | グラフィックボード 通販
Intel Arc Bシリーズ Intel Arc B580 Limited Edition Graphics ビデオカード 国内正規代理店品がグラフィックボードストアでいつでもお買い得。当日お急ぎ便対象商品は、当日お届け可能です。アマゾン...
Amazon | SPARKLE Intel Arc B580グラフィックカードOC版 トリプルファン「TITAN」シリーズ [SB580TB-12GOC] | スパークル(Sparkle) | グラフィックボード 通販
SPARKLE Intel Arc B580グラフィックカードOC版 トリプルファン「TITAN」シリーズ がグラフィックボードストアでいつでもお買い得。当日お急ぎ便対象商品は、当日お届け可能です。アマゾン配送商品は、通常配送無料(一部除く...

コメント

タイトルとURLをコピーしました