目次

はじめに

  • STTとLLMを利用して動画から日本語字幕と日本語音声を生成する方法です。
  • 全てのプログラムはLLMによって出力されたものです。

手順

  1. yt-dlp などを利用して翻訳対象の動画をダウンロードする
  2. Subtitle Edit などの SST 機能を利用して (1) の動画ファイルから字幕ファイルを生成する
  3. 以下のようなプロンプトを利用して字幕ファイルを翻訳する
// URL Context: ON
// Grounding with Google Search: ON

韓国語の字幕ファイル(SRT形式)を日本語訳しなさい。

字幕ファイルの内容はアラド戦記(던전앤파이터)というゲームのディレジエレイドというコンテンツの解説です。
詳細な情報は以下URLのwikiに記述されています。参考にしなさい。

- https://namu.wiki/w/%EA%B2%80%EC%9D%80%20%EC%A7%88%EB%B3%91%EC%9D%98%20%EB%94%94%EB%A0%88%EC%A7%80%EC%97%90%20%EB%A0%88%EC%9D%B4%EB%93%9C
- https://namu.wiki/w/%EA%B2%80%EC%9D%80%20%EC%A7%88%EB%B3%91%EC%9D%98%20%EB%94%94%EB%A0%88%EC%A7%80%EC%97%90%20%EB%A0%88%EC%9D%B4%EB%93%9C/1%ED%8E%98%EC%9D%B4%EC%A6%88
- https://namu.wiki/w/%EA%B2%80%EC%9D%80%20%EC%A7%88%EB%B3%91%EC%9D%98%20%EB%94%94%EB%A0%88%EC%A7%80%EC%97%90%20%EB%A0%88%EC%9D%B4%EB%93%9C/2%ED%8E%98%EC%9D%B4%EC%A6%88
  1. Qwen3-TTS を利用して日本語音声ファイルを生成する
# .venv\Scripts\python.exe -m pip install webvtt-py
# .venv\Scripts\python.exe vtt_to_audio.py
import torch
import soundfile as sf
from qwen_tts import Qwen3TTSModel
from pydub import AudioSegment
import webvtt

# モデル読み込み(初回はHugging Faceからダウンロード)
model = Qwen3TTSModel.from_pretrained(
  "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
  device_map="cuda:0",
  dtype=torch.float16
)

print('model load finished')

ref_audio = "ref_audio.wav"
ref_text = "おはようございます よろしくお願いします"

i=1
for caption in webvtt.read("ja.vtt"):
  # 改行を削除する
  captionText = caption.text.replace('\r\n', '\n').replace('\n', '')
  print(captionText)

  # 音声ファイルを生成する
  wavs, sr = model.generate_voice_clone(
    text=captionText,
    language="Japanese",
    ref_audio=ref_audio,
    ref_text=ref_text,
  )

  # 保存する
  sf.write(f"c:\\work\\wav\\{i:03d}.wav", wavs[0], sr)
  print(f"Saved! {i}")
  i = i + 1
  1. wavファイルをmp3ファイルに変換する。再生速度は1.5倍速にする。
for (int i = 1; i <= 330; i++)
{
  var num = i.ToString("D3"); // 001, 002, …, 330
  var line = $"ffmpeg -i {num}.wav -filter:a \"atempo=1.5\" -vn -y ..\\mp3\\{num}.mp3";
  Console.WriteLine(line);
}
  1. 動画ファイルに日本語音声をマージする
using SubtitlesParserV2;
using System.Diagnostics;
using System.Text;

class AudioVideoMerger
{
  // FFmpeg 実行パス (環境変数にパスが通っている場合は "ffmpeg" で可)
  private const string FfmpegPath = "ffmpeg";

  // 音声パート情報 (ファイルパス, 開始秒, 終了秒)
  private readonly List<(string filePath, double start, double end)> audioParts;

  private readonly string videoPath;
  private readonly string outputPath;
  private readonly string tempDir;

  public AudioVideoMerger(string videoPath, List<(string, double, double)> audioParts, string outputPath)
  {
    this.videoPath = videoPath;
    this.audioParts = audioParts;
    this.outputPath = outputPath;
    this.tempDir = Path.Combine(Path.GetTempPath(), "AudioVideoMerge_" + Guid.NewGuid().ToString("N"));
    Directory.CreateDirectory(this.tempDir);
  }

  public void Execute()
  {
    try
    {
      Mux(videoPath, outputPath);
      // MergeAudioWithVideo(mergedAudioPath, videoPath, outputPath);
    }
    finally
    {
      Console.WriteLine("ffmpeg の終了待機中(5秒)");
      Thread.Sleep(TimeSpan.FromSeconds(5));

      // 一時ファイルの削除 (必要に応じてコメントアウト可)
      Directory.Delete(tempDir, true);
    }
  }

  private void Mux(string videoPath, string outputPath)
  {
    string filterFile = Path.Combine(Path.GetTempPath(), "filter.txt");

    var filter = new StringBuilder();

    // 元の動画の音量を5%で維持する
    // filter.AppendLine("[0:a]volume=0.05[base];");

    // 元の動画の音量を0%で維持する
    filter.AppendLine("[0:a]volume=0.00[base];");

    for (int i = 0; i < audioParts.Count; i++)
    {
      // ミリ秒単位に変換
      int delay = (int)Math.Round(audioParts[i].start * 1000);
      filter.AppendLine($"[{i+1}:a]adelay={delay}|{delay}[a{i+1}];");
    }

    for (int i = 0; i < audioParts.Count; i++)
    {
      filter.Append($"[a{i+1}]");
    }

    filter.AppendLine($"amix=inputs={audioParts.Count}:dropout_transition=0:normalize=0[jpmix];");
    filter.AppendLine("[base][jpmix]amix=inputs=2:dropout_transition=0[aout];");

    File.WriteAllText(filterFile, filter.ToString());

    var args = new StringBuilder();

    args.Append($"-i \"{videoPath}\" ");

    foreach (var part in audioParts)
    {
      args.Append($"-i \"{part.filePath}\" ");
    }

    args.Append($"-filter_complex_script \"{filterFile}\" ");
    args.Append("-map 0:v ");
    args.Append("-map \"[aout]\" ");
    args.Append("-c:v copy ");
    args.Append("-c:a aac -b:a 128k -y ");
    args.Append($"\"{outputPath}\"");

    RunProcess(FfmpegPath, args.ToString());
  }

  // 音声と動画を合成 (映像はコピー、音声は AAC に変換)
  private void MergeAudioWithVideo(string audioPath, string videoPath, string outputPath)
  {
    // string args = $"-i \"{videoPath}\" -i \"{audioPath}\" -c:v copy -c:a aac -map 0:v:0 -map 1:a:0 -shortest -y \"{outputPath}\"";
    var args = $"-i {videoPath} -i {audioPath} -filter_complex \"[0:a]volume=0.1[a0];[a0][1:a]amix=inputs=2:duration=first:dropout_transition=2[aout]\" -map 0:v -map \"[aout]\" -c:v copy -c:a aac -b:a 192k -strict -2 -y {outputPath}";
    Console.WriteLine(args);
    RunProcess(FfmpegPath, args);
  }

  // 汎用的なプロセス実行ユーティリティ
  private void RunProcess(string fileName, string arguments)
  {
    var startInfo = new ProcessStartInfo
    {
      FileName = fileName,
      Arguments = arguments,
      CreateNoWindow = true,
      UseShellExecute = false,
      RedirectStandardError = true,
      RedirectStandardOutput = true
    };

    using (var proc = Process.Start(startInfo))
    {
      proc.OutputDataReceived += (sender, e) =>
      {
        if (!string.IsNullOrEmpty(e.Data))
        {
          Console.WriteLine(e.Data);
        }
      };

      proc.ErrorDataReceived += (sender, e) =>
      {
        if (!string.IsNullOrEmpty(e.Data))
        {
          Console.Error.WriteLine(e.Data);
        }
      };

      proc.Start();

      // 非同期で読み取り開始
      proc.BeginOutputReadLine();
      proc.BeginErrorReadLine();

      proc.WaitForExit();

      Console.WriteLine($"FFmpeg ExitCode={proc.ExitCode}");
    }
  }
}

class Program
{
  /// <summary>
  /// ミリ秒を秒に変換する。
  /// </summary>
  /// <param name="milliseconds">変換対象のミリ秒。整数でも実数でも可。</param>
  /// <returns>変換後の秒数(小数部を含む)</returns>
  public static double MillisecondsToSeconds(double milliseconds)
  {
    return milliseconds / 1000.0;
  }

  static void Main()
  {
    // 動画ファイル
    string videoFile = @"C:\work\input.webm";

    // 音声パート (ファイルパス, 開始秒, 終了秒)
    var parts = new List<(string, double, double)>();
    using (FileStream fileStream = System.IO.File.OpenRead("C:\\work\\\\ja.vtt"))
    {
      var result = SubtitleParser.ParseStream(fileStream, Encoding.UTF8);
      foreach (var x in result?.Subtitles?.Index() ?? [])
      {
        var index = x.Index + 1;
        parts.Add((@$"C:\work\mp3\{index:D3}.mp3", MillisecondsToSeconds(x.Item.StartTime), MillisecondsToSeconds(x.Item.EndTime)));
      }
    }

    // 出力動画
    string outputFile = @"C:\work\output.mp4";

    var merger = new AudioVideoMerger(videoFile, parts, outputFile);
    merger.Execute();

    Console.WriteLine("合成が完了した。");
  }
}

以上

参考

その他