import fs from "node:fs/promises";
import path from "node:path";
import {spawn} from "node:child_process";
import {fileURLToPath} from "node:url";
import {normalizeRhubarbJson} from "./lipsync-utils.js";
const projectRoot = path.resolve(
fileURLToPath(new URL("..", import.meta.url))
);
const publicDir = path.join(projectRoot, "public");
const generatedDir = path.join(projectRoot, "src/generated/lipsync");
const rawDir = path.join(publicDir, "lipsync/raw");
const lipsyncTargets = [
{
name: "yukkuri-composition",
sourceManifest: "src/data/yukkuri-composition/voicevox-manifest.json",
},
{
name: "pizza-kiln",
sourceManifest: "src/data/pizza-kiln/voicevox-manifest.json",
},
{
name: "pizza-oven-project-01",
sourceManifest: "src/data/pizza-oven-project-01/voicevox-manifest.json",
},
];
const lipsyncTargetByName = new Map(
lipsyncTargets.map((target) => [target.name, target])
);
// 用途: プロジェクト基準の相対パスを、ファイル操作で使う絶対パスへ変換する。
// 使用方法: CLI引数や環境変数から受け取ったパスを、読み書き前にこの関数へ渡す。
// オプションや引数詳細: value は絶対パスならそのまま、相対パスなら projectRoot から解決する。
const resolveProjectPath = (value) =>
path.isAbsolute(value) ? value : path.resolve(projectRoot, value);
// 用途: ログやエラーメッセージに表示しやすいプロジェクト相対パスへ変換する。
// 使用方法: 内部処理で使う絶対パスを、利用者向けに表示したい場面で呼び出す。
// オプションや引数詳細: targetPath は projectRoot からの相対表記にし、区切り文字はスラッシュへ統一する。
const toProjectRelative = (targetPath) =>
path.relative(projectRoot, targetPath).split(path.sep).join("/");
// 用途: public 配下の音声ファイルを、Remotion から参照できる public 相対パスへ変換する。
// 使用方法: lipsync manifest に sourceAudio を記録するときに呼び出す。
// オプションや引数詳細: targetPath が public 配下なら public 相対、外側ならプロジェクト相対の表示用パスを返す。
const toPublicRelative = (targetPath) => {
const relativePath = path.relative(publicDir, targetPath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return toProjectRelative(targetPath);
}
return relativePath.split(path.sep).join("/");
};
// 用途: 指定されたファイルまたはディレクトリが存在するかを確認する。
// 使用方法: 入力音声、manifest、Rhubarb 実行ファイルの存在チェックで await して呼び出す。
// オプションや引数詳細: targetPath は確認対象の絶対または相対パスで、アクセスできれば true、できなければ false を返す。
const pathExists = async (targetPath) => {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
};
// 用途: npm run lipsync:generate のヘルプを表示し、明示的な生成コマンドを案内する。
// 使用方法: 引数なし、または --help / -h が指定されたときに呼び出す。
// オプションや引数詳細: 生成対象一覧は lipsyncTargets から作り、対象追加時の表示漏れを防ぐ。
const printHelp = () => {
const projectCommands = lipsyncTargets
.map(({name}) => ` npm run lipsync:generate:${name}`)
.join("\n");
const projectOptions = lipsyncTargets
.map(({name}) => ` --project ${name}`)
.join("\n");
console.log(`Rhubarb 口パクデータ生成
Usage:
npm run lipsync:generate
npm run lipsync:generate:all
${projectCommands}
npm run lipsync:generate -- public/audio/yukkuri-composition/lines/zunda-001.wav
Options:
--all
${projectOptions}
--source-manifest <path>
--manifest <path>
--out <path> --raw-out <path>
--help, -h
引数なしの npm run lipsync:generate は、このヘルプだけを表示して口パクデータを生成しません。`);
};
// 用途: 値を取る CLI オプションについて、次の引数を検証して取得する。
// 使用方法: --project や --source-manifest などを読み取る直前に呼び出す。
// オプションや引数詳細: 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;
};
// 用途: CLI パース中に集める一時的な値入れ物を作る。
// 使用方法: collectCliValues の先頭で呼び出し、各オプションの読み取り結果を詰める。
// オプションや引数詳細: manifest は lipsync manifest の既定出力先で初期化する。
const createCliValues = () => ({
help: false,
generateAll: false,
projectName: null,
audioPaths: [],
options: {
out: undefined,
rawOut: undefined,
manifest: "src/generated/lipsync/manifest.json",
sourceManifests: [],
},
});
// 用途: npm run lipsync:generate に渡された CLI 引数を一時値へ読み取る。
// 使用方法: parseArgs から呼び出し、後続の検証と実行モード決定に使う。
// オプションや引数詳細: --help、--all、--project、音声パス、--out、--raw-out、--manifest、--source-manifest に対応する。
const collectCliValues = (args) => {
const values = createCliValues();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
values.help = true;
continue;
}
if (arg === "--all") {
values.generateAll = true;
continue;
}
if (arg === "--project") {
values.projectName = getOptionValue(args, index, arg);
index += 1;
continue;
}
if (!arg.startsWith("--")) {
values.audioPaths.push(arg);
continue;
}
const key = arg.slice(2);
if (!["out", "raw-out", "manifest", "source-manifest"].includes(key)) {
throw new Error(`Unknown option "${arg}".`);
}
const value = getOptionValue(args, index, arg);
if (key === "source-manifest") {
values.options.sourceManifests.push(value);
} else if (key === "raw-out") {
values.options.rawOut = value;
} else {
values.options[key] = value;
}
index += 1;
}
return values;
};
// 用途: --all 指定時に併用できない入力がないかを検証し、全対象の実行設定を返す。
// 使用方法: parseArgs 内で generateAll が true のときに呼び出す。
// オプションや引数詳細: --manifest は併用可能で、生成済み lipsync manifest を全置換する。
const parseAllArgs = ({audioPaths, projectName, options}) => {
if (
audioPaths[0] ||
projectName ||
options.sourceManifests.length > 0 ||
options.out ||
options.rawOut
) {
throw new Error(
"--all cannot be combined with audio path, --project, --source-manifest, --out, or --raw-out."
);
}
return {
mode: "all",
audioPath: undefined,
outPath: undefined,
rawOutPath: undefined,
manifestPath: resolveProjectPath(options.manifest),
sourceManifestPaths: lipsyncTargets.map(({sourceManifest}) =>
resolveProjectPath(sourceManifest)
),
shouldMergeExistingManifest: false,
};
};
// 用途: --project 指定時に対象 manifest を解決し、単一コンポジションの実行設定を返す。
// 使用方法: parseArgs 内で projectName があるときに呼び出す。
// オプションや引数詳細: --manifest は併用可能で、既存 lipsync manifest へマージする。
const parseProjectArgs = ({audioPaths, projectName, options}) => {
if (
audioPaths[0] ||
options.sourceManifests.length > 0 ||
options.out ||
options.rawOut
) {
throw new Error(
"--project cannot be combined with audio path, --source-manifest, --out, or --raw-out."
);
}
const target = lipsyncTargetByName.get(projectName);
if (!target) {
const availableTargets = lipsyncTargets.map(({name}) => name).join(", ");
throw new Error(
`Unknown project "${projectName}". Available projects: ${availableTargets}`
);
}
return {
mode: "single",
audioPath: undefined,
outPath: undefined,
rawOutPath: undefined,
manifestPath: resolveProjectPath(options.manifest),
sourceManifestPaths: [resolveProjectPath(target.sourceManifest)],
shouldMergeExistingManifest: true,
};
};
// 用途: 音声パス指定または --source-manifest 指定の実行設定を返す。
// 使用方法: parseArgs 内で --all / --project 以外の生成指定を処理するときに呼び出す。
// オプションや引数詳細: 単一音声では --out と --raw-out を使え、--source-manifest では既存 manifest へマージする。
const parseSingleArgs = ({audioPaths, options}) => {
if (audioPaths[0] && options.sourceManifests.length > 0) {
throw new Error("Audio path and --source-manifest cannot be used together.");
}
if (!audioPaths[0] && (options.out || options.rawOut)) {
throw new Error("--out and --raw-out can only be used with one audio path.");
}
if (!audioPaths[0] && options.sourceManifests.length === 0) {
return {mode: "help"};
}
return {
mode: "single",
audioPath: audioPaths[0],
outPath: options.out ? resolveProjectPath(options.out) : undefined,
rawOutPath: options.rawOut
? resolveProjectPath(options.rawOut)
: undefined,
manifestPath: resolveProjectPath(options.manifest),
sourceManifestPaths: options.sourceManifests.map(resolveProjectPath),
shouldMergeExistingManifest: true,
};
};
// 用途: npm run lipsync:generate に渡された CLI オプションを読み取り、実行モードと生成対象を決める。
// 使用方法: 起動直後に呼び出し、help・single・all のどれとして動くかを取得する。
// オプションや引数詳細: 引数なしは help、--all は全対象、--project は登録済み対象、音声パスや --source-manifest は単独生成として扱う。
const parseArgs = () => {
const args = process.argv.slice(2);
if (args.length === 0) {
return {mode: "help"};
}
const values = collectCliValues(args);
if (values.help) {
return {mode: "help"};
}
if (values.audioPaths.length > 1) {
throw new Error("Only one audio path can be specified.");
}
if (values.generateAll) {
return parseAllArgs(values);
}
if (values.projectName) {
return parseProjectArgs(values);
}
return parseSingleArgs(values);
};
// 用途: 実行環境に応じた Rhubarb CLI の実行ファイル名候補を返す。
// 使用方法: findRhubarbBin の候補パス作成時に呼び出す。
// オプションや引数詳細: Windows では rhubarb.exe・rhubarb.cmd・rhubarb、それ以外では rhubarb を候補にする。
const executableNames = () =>
process.platform === "win32"
? ["rhubarb.exe", "rhubarb.cmd", "rhubarb"]
: ["rhubarb"];
// 用途: 環境変数、ローカル配置、PATH から利用可能な Rhubarb CLI を探す。
// 使用方法: lipsync 生成処理の前に await して呼び出し、runRhubarb に渡す実行ファイルパスを取得する。
// オプションや引数詳細: RHUBARB_BIN が指定されていれば優先し、見つからない場合は node_modules/.bin、tools、vendor、PATH を順に探す。
const findRhubarbBin = async () => {
if (process.env.RHUBARB_BIN) {
const envPath = resolveProjectPath(process.env.RHUBARB_BIN);
if (await pathExists(envPath)) {
return envPath;
}
throw new Error(`RHUBARB_BIN was set, but not found: ${envPath}`);
}
const candidates = [];
for (const name of executableNames()) {
candidates.push(path.join(projectRoot, "node_modules/.bin", name));
candidates.push(path.join(projectRoot, "tools/rhubarb", name));
candidates.push(path.join(projectRoot, "vendor/rhubarb", name));
}
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return candidate;
}
}
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
for (const name of executableNames()) {
const candidate = path.join(directory, name);
if (await pathExists(candidate)) {
return candidate;
}
}
}
throw new Error(
[
"Rhubarb Lip Sync CLI was not found.",
"Set RHUBARB_BIN to the Rhubarb executable path to use any installed CLI.",
"Executable names differ by OS, for example rhubarb, rhubarb.exe, or rhubarb.cmd.",
"When using a Dev Container, install the Linux Rhubarb binary and point RHUBARB_BIN to it.",
].join("\n")
);
};
// 用途: Rhubarb CLI を実行し、音声ファイルから口パク用の生 JSON を生成する。
// 使用方法: generateTask 内で await し、task.inputPath から task.rawOutputPath へ Rhubarb 出力を書き出す。
// オプションや引数詳細: rhubarbBin は実行ファイルパス、inputPath は音声ファイル、rawOutputPath は Rhubarb JSON の出力先を指定する。
const runRhubarb = (rhubarbBin, inputPath, rawOutputPath) =>
new Promise((resolve, reject) => {
const args = [
"--recognizer",
"phonetic",
"--exportFormat",
"json",
"--extendedShapes",
"X",
"--output",
rawOutputPath,
inputPath,
];
const child = spawn(rhubarbBin, args, {cwd: projectRoot});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve({stdout, stderr});
return;
}
reject(
new Error(
`Rhubarb exited with code ${code} for ${toProjectRelative(inputPath)}.\n${stderr || stdout}`
)
);
});
});
// 用途: JSON ファイルを読み込み、JavaScript の値として返す。
// 使用方法: manifest、Rhubarb 生 JSON、既存 lipsync manifest の読み込みで await して呼び出す。
// オプションや引数詳細: targetPath は UTF-8 の JSON ファイルパスを指定し、内容は JSON.parse で解釈する。
const loadJson = async (targetPath) =>
JSON.parse(await fs.readFile(targetPath, "utf8"));
// 用途: 既存の lipsync manifest を読み込み、追記可能な manifest 形式に整える。
// 使用方法: 単一音声や source manifest 指定時に、既存 timelines を維持するため await して呼び出す。
// オプションや引数詳細: manifestPath が存在しない、または version 1 の timelines 形式でない場合は空の manifest を返す。
const loadExistingGeneratedManifest = async (manifestPath) => {
if (!(await pathExists(manifestPath))) {
return {version: 1, timelines: {}};
}
const manifest = await loadJson(manifestPath);
if (manifest?.version !== 1 || typeof manifest.timelines !== "object") {
return {version: 1, timelines: {}};
}
return manifest;
};
// 用途: CLI で直接指定された単一音声ファイルから、口パク生成タスクを作る。
// 使用方法: audioPath が指定されたときに await し、generateTask へ渡す task オブジェクトを得る。
// オプションや引数詳細: audioPath は入力音声、outPath は正規化後 JSON、rawOutPath は Rhubarb 生 JSON の任意出力先を指定する。
const taskForAudioPath = async ({audioPath, outPath, rawOutPath}) => {
const inputPath = resolveProjectPath(audioPath);
if (!(await pathExists(inputPath))) {
throw new Error(`Input audio file was not found: ${audioPath}`);
}
const id = path.basename(inputPath, path.extname(inputPath));
return {
id,
inputPath,
sourceAudio: toPublicRelative(inputPath),
rawOutputPath: rawOutPath ?? path.join(rawDir, `${id}.rhubarb.json`),
outputPath: outPath ?? path.join(generatedDir, `${id}.mouth.json`),
};
};
// 用途: VOICEVOX manifest の各音声エントリから、口パク生成タスク群を作る。
// 使用方法: --all、--project、または --source-manifest 指定時の処理で await して呼び出す。
// オプションや引数詳細: manifestPath は id と file を含む JSON 配列を想定し、file は public 配下から解決する。
const tasksForVoicevoxManifest = async (manifestPath) => {
if (!(await pathExists(manifestPath))) {
return [];
}
const entries = await loadJson(manifestPath);
if (!Array.isArray(entries)) {
throw new Error(`${toProjectRelative(manifestPath)} must be a JSON array.`);
}
return Promise.all(
entries.map(async (entry) => {
if (!entry?.id || !entry?.file) {
throw new Error(
`${toProjectRelative(manifestPath)} entries need id and file.`
);
}
const inputPath = path.join(publicDir, entry.file);
if (!(await pathExists(inputPath))) {
throw new Error(`Input audio file was not found: ${entry.file}`);
}
return {
id: entry.id,
inputPath,
sourceAudio: entry.file,
rawOutputPath: path.join(rawDir, `${entry.id}.rhubarb.json`),
outputPath: path.join(generatedDir, `${entry.id}.mouth.json`),
};
})
);
};
// 用途: 指定された VOICEVOX manifest から、まとめて生成する口パクタスクを集める。
// 使用方法: --all、--project、または --source-manifest 指定時に await して呼び出す。
// オプションや引数詳細: sourceManifestPaths は1件以上を想定し、読み取れる音声エントリが1件もなければエラーにする。
const tasksForVoicevoxManifests = async (sourceManifestPaths) => {
const taskGroups = await Promise.all(
sourceManifestPaths.map((manifest) => tasksForVoicevoxManifest(manifest))
);
const tasks = taskGroups.flat();
if (tasks.length === 0) {
throw new Error("No VOICEVOX manifest entries were found.");
}
return tasks;
};
// 用途: JSON を整形してファイルへ書き出す。
// 使用方法: 正規化済み口パク JSON や lipsync manifest を保存するときに await して呼び出す。
// オプションや引数詳細: targetPath は出力先、value は JSON.stringify(value, null, 2) で書き出す値を指定する。
const writeJson = async (targetPath, value) => {
await fs.mkdir(path.dirname(targetPath), {recursive: true});
await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`);
};
// 用途: 1件の音声タスクについて Rhubarb 実行、正規化、JSON 保存までを行う。
// 使用方法: tasks の各要素に対して await し、manifest に格納する timeline を受け取る。
// オプションや引数詳細: rhubarbBin は Rhubarb CLI、task は id・inputPath・sourceAudio・rawOutputPath・outputPath を含む。
const generateTask = async (rhubarbBin, task) => {
await fs.mkdir(path.dirname(task.rawOutputPath), {recursive: true});
await runRhubarb(rhubarbBin, task.inputPath, task.rawOutputPath);
const rawJson = await loadJson(task.rawOutputPath);
const {timeline, warnings} = normalizeRhubarbJson(rawJson, {
audio: task.sourceAudio,
});
warnings.forEach((warning) => {
console.warn(`${task.id}: ${warning}`);
});
await writeJson(task.outputPath, timeline);
console.log(
`Wrote ${toProjectRelative(task.outputPath)} from ${toProjectRelative(
task.inputPath
)}`
);
return timeline;
};
const parsedArgs = parseArgs();
if (parsedArgs.mode === "help") {
printHelp();
} else {
const {
audioPath,
outPath,
rawOutPath,
manifestPath,
sourceManifestPaths,
shouldMergeExistingManifest,
} = parsedArgs;
const rhubarbBin = await findRhubarbBin();
const tasks = audioPath
? [await taskForAudioPath({audioPath, outPath, rawOutPath})]
: await tasksForVoicevoxManifests(sourceManifestPaths);
const generatedManifest = shouldMergeExistingManifest
? await loadExistingGeneratedManifest(manifestPath)
: {version: 1, timelines: {}};
for (const task of tasks) {
generatedManifest.timelines[task.id] = await generateTask(
rhubarbBin,
task
);
}
await writeJson(manifestPath, generatedManifest);
console.log(`Updated ${toProjectRelative(manifestPath)}`);
}