diff --git a/voicevox-remotion-template/README.md b/voicevox-remotion-template/README.md index 8c6fb47..def6c42 100644 --- a/voicevox-remotion-template/README.md +++ b/voicevox-remotion-template/README.md @@ -1,6 +1,6 @@ # Remotion x VOICEVOX ゆっくり解説テンプレート -Remotion と VOICEVOX を組み合わせて、ずんだもんが解説する動画テンプレートです。 +Remotion と VOICEVOX を組み合わせて、複数キャラクターが時系列で登場・発話する動画テンプレートです。 サンプルテーマは「ネコミミはなぜかわいいのか?」です。 ## 使い方 @@ -16,33 +16,51 @@ https://github.com/VOICEVOX/voicevox_engine -### 3. 音声を生成 +### 3. 脚本を編集 +`src/data/script.ts` の `characters` と `timeline` を編集します。 + +```ts +show("sayo", {caption: "ネコミミ代表として、小夜が登場!"}); +say("sayo-001", "sayo", "小夜です。ネコミミ代表として、耳のかわいさを証明しに来ました。"); +say("zunda-005", "zundamon", "それじゃあ、また次回なのだ!"); +``` + +- `characters`: 表示名、VOICEVOX の `speakerName` / `styleName`、立ち絵設定を定義します。 +- `initialVisibleCharacters`: 動画開始時から表示するキャラクターを定義します。 +- `show(...)`: キャラクターを画面に登場させ、任意の説明字幕を出します。 +- `say(...)`: キャラクターに読み上げさせ、字幕と音声を同期します。 +- 行ごとにスタイルを変える場合は `say(..., {voicevox: {styleName: "スタイル名"}})` を使います。 + +### 4. 音声を生成 ```bash npm run voice:generate ``` -`src/data/script.json` の各センテンスから `public/audio/lines/*.wav` を生成し、 -`src/data/voicevox-manifest.json` に長さ情報を記録します。 +`src/data/script.ts` の `say(...)` から `public/audio/lines/*.wav` を生成し、 +`src/data/voicevox-manifest.json` に長さ・話者・スタイル情報を記録します。 +音声が未生成の行は、プレビュー時にテキスト長から尺を推定します。 -以前の `public/audio/zundamon.txt` は互換用に残していますが、現在は参照しません。 - -### 4. プレビュー +### 5. プレビュー ```bash npm run start ``` -### 5. レンダリング +### 6. レンダリング ```bash npm run render YukkuriZundamon out/video.mp4 ``` ## 編集ポイント -- ナレーション文: `src/data/script.json` +- 時系列脚本: `src/data/script.ts` - 音声タイミング: `src/data/voicevox-manifest.json` (自動生成) - 映像の構成: `src/yukkuri-composition.tsx` ## VOICEVOX設定 -環境変数で変更できます。 - - `VOICEVOX_URL` (既定: `http://host.docker.internal:50021`) -- `VOICEVOX_SPEAKER_ID` (既定: `3` / ずんだもん) +- 話者とスタイルは `src/data/script.ts` の `characters.*.voicevox` で指定します。 + +## 立ち絵の差し替え +現在の小夜は CSS の仮立ち絵です。画像素材を使う場合は `public` 配下に画像を置き、 +`characters.sayo.avatar.imagePath` に `images/sayo.png` のような `public` からの相対パスを設定してください。 + +以前の `public/audio/zundamon.txt` と `src/data/script.json` は互換用に残していますが、現在は参照しません。 diff --git a/voicevox-remotion-template/public/audio/lines/sayo-001.wav b/voicevox-remotion-template/public/audio/lines/sayo-001.wav new file mode 100644 index 0000000..973de8d --- /dev/null +++ b/voicevox-remotion-template/public/audio/lines/sayo-001.wav Binary files differ diff --git a/voicevox-remotion-template/scripts/voicevox-generate.js b/voicevox-remotion-template/scripts/voicevox-generate.js index c434762..77d33ce 100644 --- a/voicevox-remotion-template/scripts/voicevox-generate.js +++ b/voicevox-remotion-template/scripts/voicevox-generate.js @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; +import ts from "typescript"; const VOICEVOX_URL = process.env.VOICEVOX_URL ?? "http://host.docker.internal:50021"; -const SPEAKER_ID = Number(process.env.VOICEVOX_SPEAKER_ID ?? "3"); -const inputPath = new URL("../src/data/script.json", import.meta.url); +const scriptPath = new URL("../src/data/script.ts", import.meta.url); const outputDir = new URL("../public/audio/lines/", import.meta.url); const manifestPath = new URL( "../src/data/voicevox-manifest.json", @@ -43,22 +43,100 @@ return dataSize / byteRate; }; -const raw = await fs.readFile(inputPath, "utf8"); -const script = JSON.parse(raw); -if (!Array.isArray(script) || script.length === 0) { - throw new Error("src/data/script.json is empty."); +const loadScriptModule = async () => { + const source = await fs.readFile(scriptPath, "utf8"); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + }, + fileName: scriptPath.pathname, + }); + const errors = transpiled.diagnostics?.filter( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error + ); + + if (errors?.length) { + const message = errors + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n") + ) + .join("\n"); + throw new Error(`Failed to transpile src/data/script.ts:\n${message}`); + } + + const moduleUrl = `data:text/javascript;base64,${Buffer.from( + transpiled.outputText + ).toString("base64")}`; + return import(moduleUrl); +}; + +const fetchSpeakers = async () => { + const response = await fetch(`${VOICEVOX_URL}/speakers`); + if (!response.ok) { + throw new Error(`speakers failed: ${response.status}`); + } + + return response.json(); +}; + +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, + }; +}; + +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; +}; + +const {characters, timeline} = await loadScriptModule(); +if (!characters || !timeline) { + throw new Error("src/data/script.ts must export characters and timeline."); } +const speechEvents = timeline.filter((event) => event?.type === "say"); +if (speechEvents.length === 0) { + throw new Error("src/data/script.ts has no say(...) events."); +} + +const speakers = await fetchSpeakers(); + await fs.mkdir(outputDir, {recursive: true}); const manifest = []; -for (const sentence of script) { - if (!sentence?.id || !sentence?.text) { - throw new Error("Each entry needs id and text in script.json."); +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(sentence.text)}&speaker=${SPEAKER_ID}`, + `${VOICEVOX_URL}/audio_query?text=${encodeURIComponent(speech.text)}&speaker=${speakerId}`, {method: "POST"} ); if (!queryResponse.ok) { @@ -71,7 +149,7 @@ query.intonationScale = 1.1; const synthResponse = await fetch( - `${VOICEVOX_URL}/synthesis?speaker=${SPEAKER_ID}`, + `${VOICEVOX_URL}/synthesis?speaker=${speakerId}`, { method: "POST", headers: {"Content-Type": "application/json"}, @@ -83,16 +161,20 @@ } const audioBuffer = Buffer.from(await synthResponse.arrayBuffer()); - const outputPath = new URL(`./${sentence.id}.wav`, outputDir); + const outputPath = new URL(`./${speech.id}.wav`, outputDir); await fs.writeFile(outputPath, audioBuffer); const durationSeconds = getWavDurationSeconds(audioBuffer); manifest.push({ - id: sentence.id, - file: `audio/lines/${sentence.id}.wav`, + id: speech.id, + character: speech.character, + speakerName: voice.speakerName, + styleName: voice.styleName, + speakerId, + file: `audio/lines/${speech.id}.wav`, durationSeconds, }); console.log( - `Wrote ${outputPath.pathname} (${durationSeconds.toFixed(2)}s)` + `Wrote ${outputPath.pathname} (${voice.speakerName} / ${voice.styleName}, ${durationSeconds.toFixed(2)}s)` ); } diff --git a/voicevox-remotion-template/src/data/script.json b/voicevox-remotion-template/src/data/script.json index 2fd33f9..3c49ed9 100644 --- a/voicevox-remotion-template/src/data/script.json +++ b/voicevox-remotion-template/src/data/script.json @@ -1,22 +1,4 @@ -[ - { - "id": "zunda-001", - "text": "みなさんこんにちは、ずんだもんなのだ!" - }, - { - "id": "zunda-002", - "text": "今日のテーマは「ネコミミはなぜかわいいのか?」なのだ。" - }, - { - "id": "zunda-003", - "text": "まず大きな理由は、丸みのあるシルエットと動きなのだ。" - }, - { - "id": "zunda-004", - "text": "そして感情が伝わりやすくて、親近感が増すのだ!" - }, - { - "id": "zunda-005", - "text": "それじゃあ、また次回なのだ!" - } -] +{ + "deprecated": true, + "message": "The editable sequence now lives in src/data/script.ts." +} diff --git a/voicevox-remotion-template/src/data/voicevox-manifest.json b/voicevox-remotion-template/src/data/voicevox-manifest.json index 59ab5a8..a34bc69 100644 --- a/voicevox-remotion-template/src/data/voicevox-manifest.json +++ b/voicevox-remotion-template/src/data/voicevox-manifest.json @@ -1,26 +1,55 @@ [ { "id": "zunda-001", + "character": "zundamon", + "speakerName": "ずんだもん", + "styleName": "ノーマル", + "speakerId": 3, "file": "audio/lines/zunda-001.wav", "durationSeconds": 3.0613333333333332 }, { "id": "zunda-002", + "character": "zundamon", + "speakerName": "ずんだもん", + "styleName": "ノーマル", + "speakerId": 3, "file": "audio/lines/zunda-002.wav", "durationSeconds": 4.48 }, { "id": "zunda-003", + "character": "zundamon", + "speakerName": "ずんだもん", + "styleName": "ノーマル", + "speakerId": 3, "file": "audio/lines/zunda-003.wav", "durationSeconds": 4.394666666666667 }, { "id": "zunda-004", + "character": "zundamon", + "speakerName": "ずんだもん", + "styleName": "ノーマル", + "speakerId": 3, "file": "audio/lines/zunda-004.wav", "durationSeconds": 4.32 }, { + "id": "sayo-001", + "character": "sayo", + "speakerName": "小夜/SAYO", + "styleName": "ノーマル", + "speakerId": 46, + "file": "audio/lines/sayo-001.wav", + "durationSeconds": 5.834666666666666 + }, + { "id": "zunda-005", + "character": "zundamon", + "speakerName": "ずんだもん", + "styleName": "ノーマル", + "speakerId": 3, "file": "audio/lines/zunda-005.wav", "durationSeconds": 2.474666666666667 }