diff --git a/voicevox-remotion-template/AGENTS.MD b/voicevox-remotion-template/AGENTS.MD index 0179de4..664a25b 100644 --- a/voicevox-remotion-template/AGENTS.MD +++ b/voicevox-remotion-template/AGENTS.MD @@ -10,7 +10,7 @@ ## 実用上の制約 - `src/data/{コンポジション名}/script.ts` が時系列脚本の唯一の編集元。`src/data/script.json` は互換用であり、現在は参照しない。 -- `npm run voice:generate` は、VOICEVOX エンジンが `VOICEVOX_URL` で起動していることを前提にする。 +- `npm run voice:generate` はヘルプ表示のみで音声生成しない。音声生成は `npm run voice:generate:{コンポジション名}` または `npm run voice:generate:all` を使い、VOICEVOX エンジンが `VOICEVOX_URL` で起動していることを前提にする。 - VOICEVOX の話者名・スタイル名は利用環境の `/speakers` に依存する。合わない場合は `characters.*.voicevox` を調整する。 - `say(...)` を追加・変更した場合は、音声と `src/data/{コンポジション名}/voicevox-manifest.json` を再生成する。 - `public/audio/{コンポジション名}/lines/*.wav` は動画再現に必要な音声素材として扱う。`out/` などレンダリング済み動画はコミットしない。 diff --git a/voicevox-remotion-template/README.md b/voicevox-remotion-template/README.md index 7763080..7496e41 100644 --- a/voicevox-remotion-template/README.md +++ b/voicevox-remotion-template/README.md @@ -36,17 +36,25 @@ npm run voice:generate ``` -`src/data/yukkuri-composition/script.ts` の `say(...)` から -`public/audio/yukkuri-composition/lines/*.wav` を生成し、 -`src/data/yukkuri-composition/voicevox-manifest.json` に長さ・話者・スタイル情報を記録します。 +対象一覧と使い方を表示します。このコマンドだけでは音声ファイルを生成しません。 音声が未生成の行は、プレビュー時にテキスト長から尺を推定します。 +全コンポジションの音声をまとめて生成する場合は、次を実行します。 + +```bash +npm run voice:generate:all +``` + YukkuriComposition の音声だけを明示して生成する場合は、次も使えます。 ```bash npm run voice:generate:yukkuri-composition ``` +`src/data/yukkuri-composition/script.ts` の `say(...)` から +`public/audio/yukkuri-composition/lines/*.wav` を生成し、 +`src/data/yukkuri-composition/voicevox-manifest.json` に長さ・話者・スタイル情報を記録します。 + ピザ窯サンプルの音声を生成する場合は、次を実行します。 ```bash @@ -89,7 +97,8 @@ npm run lipsync:generate:pizza-oven-project-01 ``` -処理順は `1. npm run voice:generate`、`2. npm run lipsync:generate`、 +処理順は `1. npm run voice:generate:all` または対象別の `voice:generate:*`、 +`2. npm run lipsync:generate`、 `3. npm run start` です。音声を作り直したら、口パク指示データも再生成してください。 Rhubarb CLI は次の順で検出します。 @@ -240,7 +249,7 @@ ./node_modules/.bin/remotion still src/index.ts PizzaKilnSayo /tmp/pizza-kiln.png --frame=30 ``` -音声や `say(...)` を変更した場合は、`npm run voice:generate` と +音声や `say(...)` を変更した場合は、対象別の `voice:generate:*` または `npm run voice:generate:all` と `npm run lipsync:generate` も実行してください。立ち絵画像だけを差し替える場合は、 口パクタイミングの再生成は不要です。 diff --git a/voicevox-remotion-template/package.json b/voicevox-remotion-template/package.json index b4b96d0..dfa2be7 100644 --- a/voicevox-remotion-template/package.json +++ b/voicevox-remotion-template/package.json @@ -12,9 +12,10 @@ "lipsync:generate:pizza-oven-project-01": "node scripts/generate-lipsync.js --source-manifest src/data/pizza-oven-project-01/voicevox-manifest.json", "test:lipsync": "node --test scripts/lipsync-utils.test.js", "voice:generate": "node scripts/voicevox-generate.js", - "voice:generate:yukkuri-composition": "node scripts/voicevox-generate.js --script src/data/yukkuri-composition/script.ts --output public/audio/yukkuri-composition/lines --manifest src/data/yukkuri-composition/voicevox-manifest.json", - "voice:generate:pizza-kiln": "node scripts/voicevox-generate.js --script src/data/pizza-kiln/script.ts --output public/audio/pizza-kiln/lines --manifest src/data/pizza-kiln/voicevox-manifest.json", - "voice:generate:pizza-oven-project-01": "node scripts/voicevox-generate.js --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" + "voice:generate:all": "node scripts/voicevox-generate.js --all", + "voice:generate:yukkuri-composition": "node scripts/voicevox-generate.js --project yukkuri-composition", + "voice:generate:pizza-kiln": "node scripts/voicevox-generate.js --project pizza-kiln", + "voice:generate:pizza-oven-project-01": "node scripts/voicevox-generate.js --project pizza-oven-project-01" }, "dependencies": { "@remotion/google-fonts": "4.0.460", diff --git a/voicevox-remotion-template/scripts/voicevox-generate.js b/voicevox-remotion-template/scripts/voicevox-generate.js index 24e5644..6d1a0fb 100644 --- a/voicevox-remotion-template/scripts/voicevox-generate.js +++ b/voicevox-remotion-template/scripts/voicevox-generate.js @@ -13,25 +13,120 @@ ); 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); -// 用途: npm run voice:generate に渡された CLI オプションを読み取り、生成対象のパスを決める。 -// 使用方法: 起動直後に呼び出し、scriptPath・outputDir・manifestPath を取得する。 -// オプションや引数詳細: --script、--output、--manifest を指定でき、省略時は yukkuri-composition 用の既定値を使う。 +// 用途: 定義済みの音声生成対象を、実ファイル操作で使うパス情報へ変換する。 +// 使用方法: 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 --output --manifest + --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: "src/data/yukkuri-composition/script.ts", - output: "public/audio/yukkuri-composition/lines", - manifest: "src/data/yukkuri-composition/voicevox-manifest.json", + 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}".`); } @@ -41,24 +136,65 @@ throw new Error(`Unknown option "${arg}".`); } - const value = args[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error(`Option "${arg}" needs a value.`); - } - - values[key] = value; + 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 { - scriptPath: resolveProjectPath(values.script), - outputDir: resolveProjectPath(values.output), - manifestPath: resolveProjectPath(values.manifest), + mode: "help", + targets: [], }; }; -const {scriptPath, outputDir, manifestPath} = parseArgs(); - // 用途: ログやエラーメッセージに表示しやすいプロジェクト相対パスへ変換する。 // 使用方法: 絶対パスを利用者向けに表示したい場面で呼び出す。 // オプションや引数詳細: targetPath は projectRoot からの相対表記にし、区切り文字はスラッシュへ統一する。 @@ -79,8 +215,6 @@ return relativePath.split(path.sep).join("/"); }; -const publicRelativeOutputDir = toPublicRelative(outputDir); - // 用途: 生成された WAV バッファから再生時間を秒単位で読み取る。 // 使用方法: synthesis のレスポンスを Buffer 化した後、manifest の durationSeconds 用に呼び出す。 // オプションや引数詳細: buffer は RIFF/WAVE 形式の WAV データを想定し、fmt と data チャンクから長さを計算する。 @@ -117,9 +251,9 @@ }; // 用途: TypeScript の時系列脚本を一時ディレクトリへコンパイルし、Node.js から読み込む。 -// 使用方法: script.ts の characters と timeline を取得するために await して呼び出す。 +// 使用方法: 対象 script.ts の characters と timeline を取得するために await して呼び出す。 // オプションや引数詳細: scriptPath の内容を CommonJS として emit し、処理後は一時出力を削除する。 -const loadScriptModule = async () => { +const loadScriptModule = async (scriptPath) => { const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "voicevox-script-")); const compilerOptions = { module: ts.ModuleKind.CommonJS, @@ -214,73 +348,93 @@ return style.id; }; -const {characters, timeline} = await loadScriptModule(); -if (!characters || !timeline) { - throw new Error( - `${toProjectRelative(scriptPath)} must export characters and timeline.` - ); -} +// 用途: 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); -const speechEvents = timeline.filter((event) => event?.type === "say"); -if (speechEvents.length === 0) { - throw new Error(`${toProjectRelative(scriptPath)} has no say(...) events.`); -} - -const speakers = await fetchSpeakers(); - -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)` + `Generating ${name}: ${toProjectRelative(scriptPath)} -> ${toProjectRelative(outputDir)}` ); -} -await fs.mkdir(path.dirname(manifestPath), {recursive: true}); -await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); -console.log(`Updated ${manifestPath}`); + 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); + } +}