Newer
Older
remotion_docker_devcontainer / voicevox-remotion-template / scripts / voicevox-generate.js
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {createRequire} from "node:module";
import {fileURLToPath} from "node:url";
import ts from "typescript";

const VOICEVOX_URL =
  process.env.VOICEVOX_URL ?? "http://host.docker.internal:50021";

const projectRoot = path.resolve(
  fileURLToPath(new URL("..", import.meta.url))
);
const publicDir = path.join(projectRoot, "public");

const voiceTargets = [
  {
    name: "yukkuri-composition",
    script: "src/data/yukkuri-composition/script.ts",
    output: "public/audio/yukkuri-composition/lines",
    manifest: "src/data/yukkuri-composition/voicevox-manifest.json",
  },
  {
    name: "pizza-kiln",
    script: "src/data/pizza-kiln/script.ts",
    output: "public/audio/pizza-kiln/lines",
    manifest: "src/data/pizza-kiln/voicevox-manifest.json",
  },
  {
    name: "pizza-oven-project-01",
    script: "src/data/pizza-oven-project-01/script.ts",
    output: "public/audio/pizza-oven-project-01/lines",
    manifest: "src/data/pizza-oven-project-01/voicevox-manifest.json",
  },
];

const voiceTargetByName = new Map(
  voiceTargets.map((target) => [target.name, target])
);

// 用途: プロジェクト基準の相対パスを、ファイル操作で使う絶対パスへ変換する。
// 使用方法: CLI引数や既定値のパス文字列を渡し、以降の処理では戻り値を使う。
// オプションや引数詳細: value は絶対パスならそのまま、相対パスなら projectRoot から解決する。
const resolveProjectPath = (value) =>
  path.isAbsolute(value) ? value : path.resolve(projectRoot, value);

// 用途: 定義済みの音声生成対象を、実ファイル操作で使うパス情報へ変換する。
// 使用方法: CLI パース後、生成対象ごとに呼び出して scriptPath・outputDir・manifestPath を得る。
// オプションや引数詳細: target は name・script・output・manifest を持つ設定オブジェクトを想定する。
const resolveVoiceTarget = (target) => ({
  name: target.name,
  scriptPath: resolveProjectPath(target.script),
  outputDir: resolveProjectPath(target.output),
  manifestPath: resolveProjectPath(target.manifest),
});

// 用途: npm run voice:generate のヘルプを表示し、明示的な生成コマンドを案内する。
// 使用方法: 引数なし、または --help / -h が指定されたときに呼び出す。
// オプションや引数詳細: 生成対象一覧は voiceTargets から作り、対象追加時の表示漏れを防ぐ。
const printHelp = () => {
  const projectCommands = voiceTargets
    .map(({name}) => `  npm run voice:generate:${name}`)
    .join("\n");
  const projectOptions = voiceTargets
    .map(({name}) => `  --project ${name}`)
    .join("\n");

  console.log(`VOICEVOX 音声生成

Usage:
  npm run voice:generate
  npm run voice:generate:all
${projectCommands}

Options:
  --all
${projectOptions}
  --script <path> --output <path> --manifest <path>
  --help, -h

引数なしの npm run voice:generate は、このヘルプだけを表示して音声を生成しません。`);
};

// 用途: 値を取る CLI オプションについて、次の引数を検証して取得する。
// 使用方法: --project や --script などを読み取る直前に呼び出す。
// オプションや引数詳細: value が未指定、または別オプションに見える場合は利用者向けエラーを投げる。
const getOptionValue = (args, index, arg) => {
  const value = args[index + 1];
  if (!value || value.startsWith("--")) {
    throw new Error(`Option "${arg}" needs a value.`);
  }

  return value;
};

// 用途: npm run voice:generate に渡された CLI オプションを読み取り、実行モードと生成対象を決める。
// 使用方法: 起動直後に呼び出し、help・single・all のどれとして動くかを取得する。
// オプションや引数詳細: 引数なしは help、--all は全対象、--project は登録済み対象、--script 等は直接パス指定として扱う。
const parseArgs = () => {
  const values = {
    script: null,
    output: null,
    manifest: null,
  };
  const args = process.argv.slice(2);
  let generateAll = false;
  let projectName = null;

  if (args.length === 0) {
    return {mode: "help", targets: []};
  }

  for (let index = 0; index < args.length; index += 1) {
    const arg = args[index];
    if (arg === "--help" || arg === "-h") {
      return {mode: "help", targets: []};
    }

    if (arg === "--all") {
      generateAll = true;
      continue;
    }

    if (arg === "--project") {
      projectName = getOptionValue(args, index, arg);
      index += 1;
      continue;
    }

    if (!arg.startsWith("--")) {
      throw new Error(`Unknown argument "${arg}".`);
    }

    const key = arg.slice(2);
    if (!(key in values)) {
      throw new Error(`Unknown option "${arg}".`);
    }

    values[key] = getOptionValue(args, index, arg);
    index += 1;
  }

  const hasCustomPath = Object.values(values).some(Boolean);

  if (generateAll) {
    if (projectName || hasCustomPath) {
      throw new Error("--all cannot be combined with --project or path options.");
    }

    return {mode: "all", targets: voiceTargets.map(resolveVoiceTarget)};
  }

  if (projectName) {
    if (hasCustomPath) {
      throw new Error("--project cannot be combined with path options.");
    }

    const target = voiceTargetByName.get(projectName);
    if (!target) {
      const availableTargets = voiceTargets.map(({name}) => name).join(", ");
      throw new Error(
        `Unknown project "${projectName}". Available projects: ${availableTargets}`
      );
    }

    return {mode: "single", targets: [resolveVoiceTarget(target)]};
  }

  if (hasCustomPath) {
    const missingOptions = Object.entries(values)
      .filter(([, value]) => !value)
      .map(([key]) => `--${key}`);
    if (missingOptions.length > 0) {
      throw new Error(
        `Missing required option(s): ${missingOptions.join(", ")}`
      );
    }

    return {
      mode: "single",
      targets: [
        {
          name: "custom",
          scriptPath: resolveProjectPath(values.script),
          outputDir: resolveProjectPath(values.output),
          manifestPath: resolveProjectPath(values.manifest),
        },
      ],
    };
  }

  return {
    mode: "help",
    targets: [],
  };
};

// 用途: ログやエラーメッセージに表示しやすいプロジェクト相対パスへ変換する。
// 使用方法: 絶対パスを利用者向けに表示したい場面で呼び出す。
// オプションや引数詳細: targetPath は projectRoot からの相対表記にし、区切り文字はスラッシュへ統一する。
const toProjectRelative = (targetPath) =>
  path.relative(projectRoot, targetPath).split(path.sep).join("/");

// 用途: public 配下の出力先を、Remotion から参照できる public 相対パスへ変換する。
// 使用方法: manifest に書き込む音声ファイルのパスを作る前に呼び出す。
// オプションや引数詳細: targetPath は public 配下にある必要があり、外側を指す場合はエラーにする。
const toPublicRelative = (targetPath) => {
  const relativePath = path.relative(publicDir, targetPath);
  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
    throw new Error(
      `Output directory must be inside public/: ${toProjectRelative(targetPath)}`
    );
  }

  return relativePath.split(path.sep).join("/");
};

// 用途: 生成された WAV バッファから再生時間を秒単位で読み取る。
// 使用方法: synthesis のレスポンスを Buffer 化した後、manifest の durationSeconds 用に呼び出す。
// オプションや引数詳細: buffer は RIFF/WAVE 形式の WAV データを想定し、fmt と data チャンクから長さを計算する。
const getWavDurationSeconds = (buffer) => {
  if (buffer.toString("ascii", 0, 4) !== "RIFF") {
    throw new Error("Invalid WAV header: RIFF not found.");
  }
  if (buffer.toString("ascii", 8, 12) !== "WAVE") {
    throw new Error("Invalid WAV header: WAVE not found.");
  }

  let offset = 12;
  let byteRate = 0;
  let dataSize = 0;

  while (offset + 8 <= buffer.length) {
    const chunkId = buffer.toString("ascii", offset, offset + 4);
    const chunkSize = buffer.readUInt32LE(offset + 4);
    if (chunkId === "fmt ") {
      byteRate = buffer.readUInt32LE(offset + 16);
    }
    if (chunkId === "data") {
      dataSize = chunkSize;
      break;
    }
    offset += 8 + chunkSize + (chunkSize % 2);
  }

  if (!byteRate || !dataSize) {
    throw new Error("Failed to read WAV duration.");
  }

  return dataSize / byteRate;
};

// 用途: TypeScript の時系列脚本を一時ディレクトリへコンパイルし、Node.js から読み込む。
// 使用方法: 対象 script.ts の characters と timeline を取得するために await して呼び出す。
// オプションや引数詳細: scriptPath の内容を CommonJS として emit し、処理後は一時出力を削除する。
const loadScriptModule = async (scriptPath) => {
  const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "voicevox-script-"));
  const compilerOptions = {
    module: ts.ModuleKind.CommonJS,
    moduleResolution: ts.ModuleResolutionKind.Node10,
    target: ts.ScriptTarget.ES2022,
    jsx: ts.JsxEmit.ReactJSX,
    rootDir: projectRoot,
    outDir,
    esModuleInterop: true,
    resolveJsonModule: true,
    skipLibCheck: true,
  };
  const program = ts.createProgram([scriptPath], compilerOptions);
  const emit = program.emit();
  const errors = ts
    .getPreEmitDiagnostics(program)
    .concat(emit.diagnostics)
    .filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);

  if (errors.length) {
    await fs.rm(outDir, {recursive: true, force: true});
    const message = errors
      .map((diagnostic) =>
        ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
      )
      .join("\n");
    throw new Error(
      `Failed to compile ${toProjectRelative(scriptPath)}:\n${message}`
    );
  }

  const compiledPath = path
    .join(outDir, path.relative(projectRoot, scriptPath))
    .replace(/\.[cm]?tsx?$/, ".js");
  const require = createRequire(import.meta.url);

  try {
    return require(compiledPath);
  } finally {
    await fs.rm(outDir, {recursive: true, force: true});
  }
};

// 用途: 起動中の VOICEVOX エンジンから利用可能な話者一覧を取得する。
// 使用方法: 音声生成前に await して呼び出し、speakerName と styleName の解決に使う。
// オプションや引数詳細: VOICEVOX_URL の /speakers を参照し、HTTP エラー時は例外を投げる。
const fetchSpeakers = async () => {
  const response = await fetch(`${VOICEVOX_URL}/speakers`);
  if (!response.ok) {
    throw new Error(`speakers failed: ${response.status}`);
  }

  return response.json();
};

// 用途: say(...) の個別指定とキャラクター既定値から、VOICEVOX の話者名とスタイル名を決める。
// 使用方法: 各 speech イベントの処理時に呼び出し、resolveSpeakerId へ渡す voice 情報を得る。
// オプションや引数詳細: characters は脚本のキャラクター定義、speech は id・character・voicevox を含む say イベントを想定する。
const resolveVoice = (characters, speech) => {
  const character = characters[speech.character];
  if (!character) {
    throw new Error(`Unknown character "${speech.character}" in ${speech.id}.`);
  }

  return {
    speakerName:
      speech.voicevox?.speakerName ?? character.voicevox?.speakerName,
    styleName: speech.voicevox?.styleName ?? character.voicevox?.styleName,
  };
};

// 用途: VOICEVOX の話者一覧から、指定された話者名とスタイル名に対応する speaker id を探す。
// 使用方法: audio_query と synthesis の speaker パラメータを作る直前に呼び出す。
// オプションや引数詳細: speakers は /speakers の戻り値、voice は speakerName・styleName、speechId はエラー表示用の発話IDを渡す。
const resolveSpeakerId = (speakers, voice, speechId) => {
  const speaker = speakers.find(({name}) => name === voice.speakerName);
  if (!speaker) {
    const names = speakers.map(({name}) => name).join(", ");
    throw new Error(
      `Speaker "${voice.speakerName}" for ${speechId} was not found. Available speakers: ${names}`
    );
  }

  const style = speaker.styles.find(({name}) => name === voice.styleName);
  if (!style) {
    const styles = speaker.styles.map(({name}) => name).join(", ");
    throw new Error(
      `Style "${voice.styleName}" for ${speechId} was not found on "${voice.speakerName}". Available styles: ${styles}`
    );
  }

  return style.id;
};

// 用途: 1つのコンポジション設定に対して VOICEVOX 音声と manifest を生成する。
// 使用方法: CLI で解決した targets を順に渡し、共有済み speakers を使って await する。
// オプションや引数詳細: target は name・scriptPath・outputDir・manifestPath、speakers は /speakers のレスポンスを渡す。
const generateVoiceForTarget = async (target, speakers) => {
  const {name, scriptPath, outputDir, manifestPath} = target;
  const publicRelativeOutputDir = toPublicRelative(outputDir);

  console.log(
    `Generating ${name}: ${toProjectRelative(scriptPath)} -> ${toProjectRelative(outputDir)}`
  );

  const {characters, timeline} = await loadScriptModule(scriptPath);
  if (!characters || !timeline) {
    throw new Error(
      `${toProjectRelative(scriptPath)} must export characters and timeline.`
    );
  }

  const speechEvents = timeline.filter((event) => event?.type === "say");
  if (speechEvents.length === 0) {
    throw new Error(`${toProjectRelative(scriptPath)} has no say(...) events.`);
  }

  await fs.mkdir(outputDir, {recursive: true});
  const manifest = [];

  for (const speech of speechEvents) {
    if (!speech?.id || !speech?.text || !speech?.character) {
      throw new Error("Each say(...) entry needs id, character, and text.");
    }

    const voice = resolveVoice(characters, speech);
    const speakerId = resolveSpeakerId(speakers, voice, speech.id);
    const queryResponse = await fetch(
      `${VOICEVOX_URL}/audio_query?text=${encodeURIComponent(speech.text)}&speaker=${speakerId}`,
      {method: "POST"}
    );
    if (!queryResponse.ok) {
      throw new Error(`audio_query failed: ${queryResponse.status}`);
    }

    const query = await queryResponse.json();
    query.speedScale = 1.02;
    query.pitchScale = 0.0;
    query.intonationScale = 1.1;

    const synthResponse = await fetch(
      `${VOICEVOX_URL}/synthesis?speaker=${speakerId}`,
      {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(query),
      }
    );
    if (!synthResponse.ok) {
      throw new Error(`synthesis failed: ${synthResponse.status}`);
    }

    const audioBuffer = Buffer.from(await synthResponse.arrayBuffer());
    const outputPath = path.join(outputDir, `${speech.id}.wav`);
    await fs.writeFile(outputPath, audioBuffer);
    const durationSeconds = getWavDurationSeconds(audioBuffer);
    manifest.push({
      id: speech.id,
      character: speech.character,
      speakerName: voice.speakerName,
      styleName: voice.styleName,
      speakerId,
      file: `${publicRelativeOutputDir}/${speech.id}.wav`,
      durationSeconds,
    });
    console.log(
      `Wrote ${outputPath} (${voice.speakerName} / ${voice.styleName}, ${durationSeconds.toFixed(2)}s)`
    );
  }

  await fs.mkdir(path.dirname(manifestPath), {recursive: true});
  await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
  console.log(`Updated ${manifestPath}`);
};

const {mode, targets} = parseArgs();
if (mode === "help") {
  printHelp();
} else {
  const speakers = await fetchSpeakers();
  for (const target of targets) {
    await generateVoiceForTarget(target, speakers);
  }
}