Last active
September 7, 2026 13:44
-
-
Save z80oolong/f95f5e98cd4874bd6f7173f03cdf6fce to your computer and use it in GitHub Desktop.
VOICEVOX 関連の短いプログラム若しくはファイル等
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """VVM 内の metas から speaker_uuid を読み取り、character_info を生成する。 | |
| デフォルト: | |
| character_info/<uuid>/metas.json (内容は {}) | |
| --full: | |
| metas.json / policy.md / portrait.png / icons/<style_id>.png | |
| --voice-samples: | |
| voice_samples/<style_id>_00N.wav を合成 | |
| 優先: voicevox_core Python API | |
| 代替: voicevox-core-cli | |
| voicevox-core-cli [-x lib] [-d dict] [-v vvm] [-i style_id] [-o out.wav] TEXT | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import io | |
| import json | |
| import re | |
| import shutil | |
| import struct | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import zipfile | |
| from pathlib import Path | |
| from typing import Any, Iterable, Protocol | |
| _UUID_RE = re.compile( | |
| r"^[0-9a-fA-F]{8}-" | |
| r"[0-9a-fA-F]{4}-" | |
| r"[0-9a-fA-F]{4}-" | |
| r"[0-9a-fA-F]{4}-" | |
| r"[0-9a-fA-F]{12}$" | |
| ) | |
| ENGINE_METAS_CONTENT = "{}\n" | |
| # 識別用の近似色(公式テーマの完全再現ではない) | |
| IMAGE_COLORS_BY_NAME: dict[str, tuple[int, int, int]] = { | |
| "四国めたん": (0xDF, 0x4C, 0x94), | |
| "ずんだもん": (0x33, 0xA6, 0x5E), | |
| "春日部つむぎ": (0xFF, 0x99, 0x14), | |
| "雨晴はう": (0x2C, 0xA9, 0xE1), | |
| "波音リツ": (0x67, 0x31, 0x53), | |
| "玄野武宏": (0x50, 0x96, 0x6A), | |
| "白上虎太郎": (0x99, 0xD0, 0x2B), | |
| "青山龍星": (0x38, 0x6C, 0xB0), | |
| "冥鳴ひまり": (0xA4, 0x5A, 0xAA), | |
| "九州そら": (0x69, 0x64, 0xAD), | |
| "もち子さん": (0x57, 0x58, 0x55), | |
| "剣崎雌雄": (0xBB, 0xBF, 0xE5), | |
| "WhiteCUL": (0x1D, 0x86, 0xAE), | |
| "後鬼": (0x21, 0x85, 0xDA), | |
| "No.7": (0x77, 0x77, 0x77), | |
| "ちび式じい": (0x89, 0xC3, 0xEB), | |
| "櫻歌ミコ": (0xE9, 0xA3, 0x68), | |
| "小夜/SAYO": (0xFF, 0x66, 0x87), | |
| "ナースロボ_タイプT": (0x00, 0xB0, 0x50), | |
| "†聖騎士 紅桜†": (0xF1, 0x61, 0x52), | |
| "雀松朱司": (0xE3, 0x70, 0x6B), | |
| "麒ヶ島宗麟": (0xE3, 0xD3, 0x5A), | |
| "春歌ナナ": (0xFF, 0xC0, 0xCB), | |
| "猫使アル": (0xF5, 0xA9, 0xB8), | |
| "猫使ビィ": (0xA9, 0xD0, 0xF5), | |
| "中国うさぎ": (0xFC, 0x4E, 0x32), | |
| "栗田まろん": (0xC8, 0xA6, 0x72), | |
| "あいえるたん": (0xEE, 0x7B, 0x2B), | |
| "満別花丸": (0xE6, 0xF5, 0xB0), | |
| "琴詠ニア": (0xFB, 0x80, 0x28), | |
| "Voidoll": (0x6C, 0x7A, 0x89), | |
| "ぞん子": (0x7D, 0x3C, 0x98), | |
| "中部つるぎ": (0x5D, 0x6D, 0x7E), | |
| "離途": (0x5D, 0x4E, 0x37), | |
| "黒沢冴白": (0xD5, 0xD8, 0xDC), | |
| "ユーレイちゃん": (0xD2, 0xB4, 0xDE), | |
| "東北ずん子": (0x31, 0x67, 0x45), | |
| "東北きりたん": (0x9F, 0x6F, 0x55), | |
| "東北イタコ": (0x00, 0x88, 0x99), | |
| "あんこもん": (0x6E, 0x2C, 0x00), | |
| } | |
| IMAGE_COLORS_BY_UUID: dict[str, tuple[int, int, int]] = { | |
| "7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff": IMAGE_COLORS_BY_NAME["四国めたん"], | |
| "388f246b-8c41-4ac1-8e2d-5d79f3ff56d9": IMAGE_COLORS_BY_NAME["ずんだもん"], | |
| "35b2c544-660e-401e-b503-0e14c635303a": IMAGE_COLORS_BY_NAME["春日部つむぎ"], | |
| "3474ee95-c274-47f9-aa1a-8322163d96f1": IMAGE_COLORS_BY_NAME["雨晴はう"], | |
| "8eaad775-3119-417e-8cf4-2a10bfd592c8": IMAGE_COLORS_BY_NAME["冥鳴ひまり"], | |
| } | |
| DEFAULT_SAMPLE_TEXTS = [ | |
| "こんにちは。", | |
| "音声サンプルです。", | |
| "よろしくお願いします。", | |
| ] | |
| POLICY_TEMPLATE = """\ | |
| # {name} | |
| 本ディレクトリの画像は公式イラストではありません。 | |
| 識別用に自動生成したダミー画像です。 | |
| voice_samples がある場合、その WAV は VOICEVOX CORE により当該 VVM から | |
| 合成したサンプルです。利用時は各キャラクター/音声ライブラリの公式利用規約に | |
| 従い、必要なクレジットを表記してください。 | |
| - VOICEVOX: https://voicevox.hiroshiba.jp/ | |
| - 利用規約は公式サイトおよび各音声ライブラリの案内を参照 | |
| speaker_uuid: `{uuid}` | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # VVM / metas | |
| # --------------------------------------------------------------------------- | |
| def _find_metas_member(names: Iterable[str]) -> str | None: | |
| candidates = [ | |
| n for n in names if not n.endswith("/") and Path(n).name == "metas.json" | |
| ] | |
| if not candidates: | |
| return None | |
| candidates.sort(key=lambda n: (n.count("/"), len(n))) | |
| return candidates[0] | |
| def load_speakers_from_vvm(vvm_path: Path) -> list[dict[str, Any]]: | |
| if not vvm_path.is_file(): | |
| raise FileNotFoundError(f"VVM not found: {vvm_path}") | |
| with zipfile.ZipFile(vvm_path, "r") as zf: | |
| member = _find_metas_member(zf.namelist()) | |
| if member is None: | |
| raise FileNotFoundError(f"metas.json not found in archive: {vvm_path}") | |
| raw = json.loads(zf.read(member).decode("utf-8")) | |
| if isinstance(raw, list): | |
| speakers = raw | |
| elif isinstance(raw, dict): | |
| if "speakers" in raw and isinstance(raw["speakers"], list): | |
| speakers = raw["speakers"] | |
| elif "speaker_uuid" in raw or "uuid" in raw: | |
| speakers = [raw] | |
| else: | |
| raise ValueError( | |
| f"Unexpected metas.json structure in {vvm_path}: " | |
| f"top-level keys={list(raw.keys())!r}" | |
| ) | |
| else: | |
| raise ValueError(f"Unexpected metas.json type in {vvm_path}: {type(raw)}") | |
| if not speakers: | |
| raise ValueError(f"No speakers in metas.json of {vvm_path}") | |
| return speakers | |
| def extract_speaker_records(speakers: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| seen: set[str] = set() | |
| result: list[dict[str, Any]] = [] | |
| for sp in speakers: | |
| if not isinstance(sp, dict): | |
| continue | |
| uuid = sp.get("speaker_uuid") or sp.get("uuid") | |
| name = str(sp.get("name") or "") | |
| if not uuid or not isinstance(uuid, str): | |
| continue | |
| uuid = uuid.strip() | |
| if not _UUID_RE.match(uuid): | |
| print(f"warning: skip invalid uuid: {uuid!r}", file=sys.stderr) | |
| continue | |
| if uuid in seen: | |
| continue | |
| seen.add(uuid) | |
| style_ids: list[int] = [] | |
| styles = sp.get("styles") or [] | |
| if isinstance(styles, list): | |
| for st in styles: | |
| if not isinstance(st, dict): | |
| continue | |
| sid = st.get("id") | |
| if isinstance(sid, int): | |
| style_ids.append(sid) | |
| elif isinstance(sid, str) and sid.isdigit(): | |
| style_ids.append(int(sid)) | |
| if not style_ids: | |
| style_ids = [0] | |
| result.append({"uuid": uuid, "name": name, "style_ids": style_ids}) | |
| if not result: | |
| raise ValueError("No valid speaker_uuid found in metas") | |
| return result | |
| # --------------------------------------------------------------------------- | |
| # Colors / dummy images | |
| # --------------------------------------------------------------------------- | |
| def color_for(uuid: str, name: str = "") -> tuple[int, int, int]: | |
| if uuid in IMAGE_COLORS_BY_UUID: | |
| return IMAGE_COLORS_BY_UUID[uuid] | |
| if name and name in IMAGE_COLORS_BY_NAME: | |
| return IMAGE_COLORS_BY_NAME[name] | |
| if name: | |
| for key, rgb in IMAGE_COLORS_BY_NAME.items(): | |
| if key in name or name in key: | |
| return rgb | |
| digest = hashlib.sha256(uuid.encode("utf-8")).digest() | |
| return (64 + digest[0] % 128, 64 + digest[1] % 128, 64 + digest[2] % 128) | |
| def luminance(rgb: tuple[int, int, int]) -> float: | |
| r, g, b = rgb | |
| return 0.299 * r + 0.587 * g + 0.114 * b | |
| def text_color_for_bg(rgb: tuple[int, int, int]) -> tuple[int, int, int]: | |
| return (20, 20, 20) if luminance(rgb) >= 140 else (245, 245, 245) | |
| def initial_char(name: str) -> str: | |
| for ch in name.strip(): | |
| if ch.isascii() and ch.isalnum(): | |
| return ch.upper() | |
| o = ord(ch) | |
| if ( | |
| 0x3040 <= o <= 0x30FF | |
| or 0x4E00 <= o <= 0x9FFF | |
| or 0x3400 <= o <= 0x4DBF | |
| or 0xFF66 <= o <= 0xFF9D | |
| ): | |
| return ch | |
| return "?" | |
| def _load_font(size: int, font_path: Path | None): | |
| from PIL import ImageFont | |
| if font_path is not None: | |
| if not font_path.is_file(): | |
| raise FileNotFoundError(f"Font not found: {font_path}") | |
| return ImageFont.truetype(str(font_path), size=size) | |
| for path in ( | |
| "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", | |
| "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", | |
| "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | |
| "C:/Windows/Fonts/msgothic.ttc", | |
| "/System/Library/Fonts/Hiragino Sans GB.ttc", | |
| ): | |
| if Path(path).is_file(): | |
| try: | |
| return ImageFont.truetype(path, size=size) | |
| except OSError: | |
| continue | |
| return ImageFont.load_default() | |
| def render_icon( | |
| path: Path, | |
| ch: str, | |
| rgb: tuple[int, int, int], | |
| font_path: Path | None, | |
| size: int = 256, | |
| ) -> None: | |
| from PIL import Image, ImageDraw | |
| img = Image.new("RGB", (size, size), rgb) | |
| draw = ImageDraw.Draw(img) | |
| font = _load_font(max(size // 2, 32), font_path) | |
| fg = text_color_for_bg(rgb) | |
| bbox = draw.textbbox((0, 0), ch, font=font) | |
| tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] | |
| xy = ((size - tw) / 2 - bbox[0], (size - th) / 2 - bbox[1]) | |
| draw.text(xy, ch, fill=fg, font=font) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| img.save(path, "PNG") | |
| def render_portrait( | |
| path: Path, | |
| name: str, | |
| uuid: str, | |
| rgb: tuple[int, int, int], | |
| font_path: Path | None, | |
| width: int = 256, | |
| height: int = 512, | |
| ) -> None: | |
| from PIL import Image, ImageDraw | |
| img = Image.new("RGB", (width, height), rgb) | |
| draw = ImageDraw.Draw(img) | |
| fg = text_color_for_bg(rgb) | |
| font_name = _load_font(max(width // 10, 18), font_path) | |
| font_uuid = _load_font(max(width // 14, 14), font_path) | |
| font_small = _load_font(max(width // 18, 12), font_path) | |
| def center_text(y: float, text: str, font) -> None: | |
| bbox = draw.textbbox((0, 0), text, font=font) | |
| tw = bbox[2] - bbox[0] | |
| x = (width - tw) / 2 - bbox[0] | |
| draw.text((x, y), text, fill=fg, font=font) | |
| center_text(height * 0.35, name or "(unknown)", font_name) | |
| center_text(height * 0.72, uuid[:8], font_uuid) | |
| center_text(height * 0.88, "dummy", font_small) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| img.save(path, "PNG") | |
| # --------------------------------------------------------------------------- | |
| # Voice samples | |
| # --------------------------------------------------------------------------- | |
| def make_silent_wav( | |
| path: Path, | |
| *, | |
| duration_sec: float = 0.3, | |
| sample_rate: int = 24000, | |
| ) -> None: | |
| nframes = int(sample_rate * duration_sec) | |
| data = b"\x00\x00" * nframes | |
| buf = io.BytesIO() | |
| buf.write(b"RIFF") | |
| buf.write(struct.pack("<I", 36 + len(data))) | |
| buf.write(b"WAVEfmt ") | |
| buf.write( | |
| struct.pack( | |
| "<IHHIIHH", | |
| 16, | |
| 1, | |
| 1, | |
| sample_rate, | |
| sample_rate * 2, | |
| 2, | |
| 16, | |
| ) | |
| ) | |
| buf.write(b"data") | |
| buf.write(struct.pack("<I", len(data))) | |
| buf.write(data) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_bytes(buf.getvalue()) | |
| class TtsBackend(Protocol): | |
| def tts(self, text: str, style_id: int) -> bytes: ... | |
| def close(self) -> None: ... | |
| class PythonCoreSynthesizer: | |
| """voicevox_core Python API。""" | |
| def __init__( | |
| self, | |
| vvm: Path, | |
| onnxruntime: Path, | |
| open_jtalk_dict: Path, | |
| ) -> None: | |
| from voicevox_core.blocking import ( | |
| Onnxruntime, | |
| OpenJtalk, | |
| Synthesizer, | |
| VoiceModelFile, | |
| ) | |
| if not vvm.is_file(): | |
| raise FileNotFoundError(f"VVM not found: {vvm}") | |
| if not onnxruntime.is_file(): | |
| raise FileNotFoundError(f"ONNX Runtime not found: {onnxruntime}") | |
| if not open_jtalk_dict.is_dir(): | |
| raise FileNotFoundError(f"OpenJTalk dict not found: {open_jtalk_dict}") | |
| ort = Onnxruntime.load_once(filename=str(onnxruntime)) | |
| self._synthesizer = Synthesizer(ort, OpenJtalk(str(open_jtalk_dict))) | |
| self._model_cm = VoiceModelFile.open(str(vvm)) | |
| self._model = self._model_cm.__enter__() | |
| self._synthesizer.load_voice_model(self._model) | |
| def tts(self, text: str, style_id: int) -> bytes: | |
| return self._synthesizer.tts(text, style_id) | |
| def close(self) -> None: | |
| try: | |
| self._model_cm.__exit__(None, None, None) | |
| except Exception: | |
| pass | |
| class CliCoreSynthesizer: | |
| """voicevox-core-cli 経由の TTS。 | |
| Usage: | |
| voicevox-core-cli [-x lib] [-d dict] [-v vvm] [-i speaker_id] [-o out.wav] TEXT | |
| """ | |
| def __init__( | |
| self, | |
| cli: Path, | |
| vvm: Path, | |
| onnxruntime: Path | None = None, | |
| open_jtalk_dict: Path | None = None, | |
| ) -> None: | |
| if cli.is_file(): | |
| self._cli = cli | |
| else: | |
| found = shutil.which(str(cli)) | |
| if not found: | |
| raise FileNotFoundError(f"voicevox-core-cli not found: {cli}") | |
| self._cli = Path(found) | |
| if not vvm.is_file(): | |
| raise FileNotFoundError(f"VVM not found: {vvm}") | |
| self._vvm = vvm | |
| self._onnxruntime = onnxruntime | |
| self._open_jtalk_dict = open_jtalk_dict | |
| def tts(self, text: str, style_id: int) -> bytes: | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: | |
| out = Path(tmp.name) | |
| try: | |
| cmd: list[str] = [str(self._cli)] | |
| if self._onnxruntime is not None: | |
| cmd.extend(["-x", str(self._onnxruntime)]) | |
| if self._open_jtalk_dict is not None: | |
| cmd.extend(["-d", str(self._open_jtalk_dict)]) | |
| cmd.extend( | |
| [ | |
| "-v", | |
| str(self._vvm), | |
| "-i", | |
| str(style_id), | |
| "-o", | |
| str(out), | |
| text, | |
| ] | |
| ) | |
| proc = subprocess.run(cmd, check=False, capture_output=True) | |
| if proc.returncode != 0: | |
| err = (proc.stderr or b"").decode("utf-8", errors="replace") | |
| out_s = (proc.stdout or b"").decode("utf-8", errors="replace") | |
| raise RuntimeError( | |
| f"voicevox-core-cli failed (exit {proc.returncode}): " | |
| f"{err or out_s}" | |
| ) | |
| if not out.is_file() or out.stat().st_size == 0: | |
| raise RuntimeError("voicevox-core-cli produced no WAV output") | |
| return out.read_bytes() | |
| finally: | |
| out.unlink(missing_ok=True) | |
| def close(self) -> None: | |
| pass | |
| def resolve_core_cli(explicit: Path | None) -> Path | None: | |
| if explicit is not None: | |
| if explicit.is_file(): | |
| return explicit | |
| found = shutil.which(str(explicit)) | |
| return Path(found) if found else None | |
| for name in ("voicevox-core-cli", "voicevox_core_cli"): | |
| found = shutil.which(name) | |
| if found: | |
| return Path(found) | |
| return None | |
| def create_tts_backend( | |
| vvm: Path, | |
| *, | |
| onnxruntime: Path | None, | |
| open_jtalk_dict: Path | None, | |
| core_cli: Path | None, | |
| ) -> tuple[TtsBackend, str]: | |
| """戻り値: (backend, \"python\"|\"cli\")""" | |
| try: | |
| import voicevox_core # noqa: F401 | |
| has_python = True | |
| except ImportError: | |
| has_python = False | |
| if has_python: | |
| if not onnxruntime or not open_jtalk_dict: | |
| raise RuntimeError( | |
| "Python voicevox_core is available but requires " | |
| "--onnxruntime and --open-jtalk-dict" | |
| ) | |
| return ( | |
| PythonCoreSynthesizer(vvm, onnxruntime, open_jtalk_dict), | |
| "python", | |
| ) | |
| cli_path = resolve_core_cli(core_cli) | |
| if cli_path is None: | |
| raise RuntimeError( | |
| "voicevox_core Python package not found, and voicevox-core-cli " | |
| "not found. Install one of them, pass --core-cli, " | |
| "or use --voice-placeholder." | |
| ) | |
| print(f"note: voicevox_core not found; using CLI: {cli_path}", file=sys.stderr) | |
| return ( | |
| CliCoreSynthesizer( | |
| cli_path, | |
| vvm, | |
| onnxruntime=onnxruntime, | |
| open_jtalk_dict=open_jtalk_dict, | |
| ), | |
| "cli", | |
| ) | |
| def generate_voice_samples( | |
| dest_dir: Path, | |
| style_ids: list[int], | |
| texts: list[str], | |
| *, | |
| synthesizer: TtsBackend | None, | |
| placeholder: bool, | |
| dry_run: bool, | |
| ) -> None: | |
| samples_dir = dest_dir / "voice_samples" | |
| if not dry_run: | |
| samples_dir.mkdir(parents=True, exist_ok=True) | |
| for sid in style_ids: | |
| for i, text in enumerate(texts, start=1): | |
| out = samples_dir / f"{sid}_{i:03d}.wav" | |
| print(f" sample {out.name} text={text!r}") | |
| if dry_run: | |
| continue | |
| if placeholder or synthesizer is None: | |
| make_silent_wav(out) | |
| continue | |
| try: | |
| out.write_bytes(synthesizer.tts(text, sid)) | |
| except Exception as e: | |
| print( | |
| f"warning: tts failed style_id={sid}: {e}; silent wav", | |
| file=sys.stderr, | |
| ) | |
| make_silent_wav(out) | |
| # --------------------------------------------------------------------------- | |
| # character_info 生成 | |
| # --------------------------------------------------------------------------- | |
| def ensure_character_info( | |
| character_info_dir: Path, | |
| records: list[dict[str, Any]], | |
| *, | |
| full: bool, | |
| voice_samples: bool, | |
| sample_texts: list[str], | |
| synthesizer: TtsBackend | None, | |
| voice_placeholder: bool, | |
| font_path: Path | None, | |
| overwrite: bool, | |
| dry_run: bool, | |
| ) -> tuple[int, int]: | |
| created = 0 | |
| skipped = 0 | |
| if full: | |
| try: | |
| from PIL import Image # noqa: F401 | |
| except ImportError as e: | |
| raise RuntimeError( | |
| "--full requires Pillow. Install with: pip install Pillow" | |
| ) from e | |
| if not dry_run: | |
| character_info_dir.mkdir(parents=True, exist_ok=True) | |
| for rec in records: | |
| uuid = rec["uuid"] | |
| name = rec["name"] | |
| style_ids = rec["style_ids"] | |
| dest_dir = character_info_dir / uuid | |
| metas_path = dest_dir / "metas.json" | |
| label = f"{uuid}" + (f" ({name})" if name else "") | |
| if metas_path.is_file() and not overwrite: | |
| print(f"skip {label}") | |
| skipped += 1 | |
| continue | |
| tags = [] | |
| if full: | |
| tags.append("full") | |
| if voice_samples: | |
| tags.append("voice") | |
| suffix = f" [{','.join(tags)}]" if tags else "" | |
| print(f"{'would create' if dry_run else 'create'} {label}{suffix}") | |
| if dry_run: | |
| if voice_samples: | |
| generate_voice_samples( | |
| dest_dir, | |
| style_ids, | |
| sample_texts, | |
| synthesizer=None, | |
| placeholder=True, | |
| dry_run=True, | |
| ) | |
| created += 1 | |
| continue | |
| dest_dir.mkdir(parents=True, exist_ok=True) | |
| metas_path.write_text(ENGINE_METAS_CONTENT, encoding="utf-8") | |
| if full: | |
| (dest_dir / "policy.md").write_text( | |
| POLICY_TEMPLATE.format(name=name or uuid, uuid=uuid), | |
| encoding="utf-8", | |
| ) | |
| rgb = color_for(uuid, name) | |
| ch = initial_char(name) | |
| render_portrait(dest_dir / "portrait.png", name, uuid, rgb, font_path) | |
| icons_dir = dest_dir / "icons" | |
| for sid in style_ids: | |
| render_icon(icons_dir / f"{sid}.png", ch, rgb, font_path) | |
| if voice_samples: | |
| generate_voice_samples( | |
| dest_dir, | |
| style_ids, | |
| sample_texts, | |
| synthesizer=synthesizer, | |
| placeholder=voice_placeholder or synthesizer is None, | |
| dry_run=False, | |
| ) | |
| created += 1 | |
| return created, skipped | |
| # --------------------------------------------------------------------------- | |
| # main | |
| # --------------------------------------------------------------------------- | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Create character_info from VVM metas. " | |
| "Default: empty metas.json. " | |
| "--full: policy + dummy images. " | |
| "--voice-samples: WAV via voicevox_core or voicevox-core-cli." | |
| ) | |
| ) | |
| parser.add_argument("--vvm", type=Path, required=True, help="Path to .vvm") | |
| parser.add_argument( | |
| "--character-info-dir", | |
| type=Path, | |
| required=True, | |
| help="Output character_info directory", | |
| ) | |
| parser.add_argument( | |
| "--full", | |
| action="store_true", | |
| help="policy.md + portrait.png + icons (requires Pillow)", | |
| ) | |
| parser.add_argument( | |
| "--font", | |
| type=Path, | |
| default=None, | |
| help="TrueType/OpenType font for dummy images (CJK recommended)", | |
| ) | |
| parser.add_argument( | |
| "--voice-samples", | |
| action="store_true", | |
| help="Generate voice_samples (Python API, else voicevox-core-cli)", | |
| ) | |
| parser.add_argument( | |
| "--onnxruntime", | |
| type=Path, | |
| default=None, | |
| help="ONNX Runtime shared library (-x for CLI / Python API)", | |
| ) | |
| parser.add_argument( | |
| "--open-jtalk-dict", | |
| type=Path, | |
| default=None, | |
| help="OpenJTalk dictionary directory (-d for CLI / Python API)", | |
| ) | |
| parser.add_argument( | |
| "--core-cli", | |
| type=Path, | |
| default=None, | |
| help="Path or name of voicevox-core-cli (fallback when no Python API)", | |
| ) | |
| parser.add_argument( | |
| "--sample-text", | |
| action="append", | |
| dest="sample_texts", | |
| default=None, | |
| metavar="TEXT", | |
| help="Sample phrase (repeatable). Default: 3 built-in phrases", | |
| ) | |
| parser.add_argument( | |
| "--voice-placeholder", | |
| action="store_true", | |
| help="Write silent WAV without calling CORE", | |
| ) | |
| parser.add_argument("--overwrite", action="store_true") | |
| parser.add_argument("--dry-run", action="store_true") | |
| parser.add_argument("--list-only", action="store_true") | |
| args = parser.parse_args() | |
| vvm_path = args.vvm.resolve() | |
| try: | |
| speakers = load_speakers_from_vvm(vvm_path) | |
| records = extract_speaker_records(speakers) | |
| except (OSError, ValueError, json.JSONDecodeError, zipfile.BadZipFile) as e: | |
| print(f"error: {e}", file=sys.stderr) | |
| return 1 | |
| if args.list_only: | |
| for rec in records: | |
| styles = ",".join(str(s) for s in rec["style_ids"]) | |
| print(f"{rec['uuid']}\t{rec['name']}\tstyles={styles}") | |
| return 0 | |
| texts = args.sample_texts if args.sample_texts else list(DEFAULT_SAMPLE_TEXTS) | |
| font_path = args.font.resolve() if args.font else None | |
| synthesizer: TtsBackend | None = None | |
| backend_kind = "none" | |
| if args.voice_samples and not args.voice_placeholder and not args.dry_run: | |
| try: | |
| synthesizer, backend_kind = create_tts_backend( | |
| vvm_path, | |
| onnxruntime=( | |
| args.onnxruntime.resolve() if args.onnxruntime else None | |
| ), | |
| open_jtalk_dict=( | |
| args.open_jtalk_dict.resolve() if args.open_jtalk_dict else None | |
| ), | |
| core_cli=args.core_cli, | |
| ) | |
| print(f"note: TTS backend={backend_kind}", file=sys.stderr) | |
| except Exception as e: | |
| print(f"error: failed to init TTS backend: {e}", file=sys.stderr) | |
| return 1 | |
| try: | |
| created, skipped = ensure_character_info( | |
| args.character_info_dir.resolve(), | |
| records, | |
| full=args.full, | |
| voice_samples=args.voice_samples, | |
| sample_texts=texts, | |
| synthesizer=synthesizer, | |
| voice_placeholder=args.voice_placeholder, | |
| font_path=font_path, | |
| overwrite=args.overwrite, | |
| dry_run=args.dry_run, | |
| ) | |
| except (RuntimeError, FileNotFoundError, OSError) as e: | |
| print(f"error: {e}", file=sys.stderr) | |
| return 1 | |
| finally: | |
| if synthesizer is not None: | |
| synthesizer.close() | |
| print( | |
| f"done: {len(records)} uuid(s), created={created}, skipped={skipped}, " | |
| f"full={args.full}, voice_samples={args.voice_samples}, " | |
| f"backend={backend_kind}, dir={args.character_info_dir}" | |
| ) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # VOICEVOX Engine wrapper script for Homebrew | |
| # Automatically supplies default paths and options. | |
| UV_PATH="@UV_BIN@/uv" | |
| RUN_PATH="@LIBEXEC@/run/run" | |
| DEFAULT_VOICELIB="@VOICEVOX_CORE_LIB@/voicevox" | |
| DEFAULT_RUNTIME="@VOICEVOX_CORE_LIB@/voicevox" | |
| DEFAULT_HOST="0.0.0.0" | |
| DEFAULT_PORT="50021" | |
| has_voicelib=0 | |
| has_runtime=0 | |
| has_host=0 | |
| has_port=0 | |
| for arg in "$@"; do | |
| case "$arg" in | |
| "--voicelib_dir") has_voicelib=1 ;; | |
| "--runtime_dir") has_runtime=1 ;; | |
| "--host") has_host=1 ;; | |
| "--port") has_port=1 ;; | |
| esac | |
| done | |
| args=("$@") | |
| [[ $has_voicelib -eq 0 ]] && args+=("--voicelib_dir" "$DEFAULT_VOICELIB") | |
| [[ $has_runtime -eq 0 ]] && args+=("--runtime_dir" "$DEFAULT_RUNTIME") | |
| [[ $has_host -eq 0 ]] && args+=("--host" "$DEFAULT_HOST") | |
| [[ $has_port -eq 0 ]] && args+=("--port" "$DEFAULT_PORT") | |
| export VIRTUAL_ENV="@LIBEXEC@/.venv" | |
| "$UV_PATH" "run" "--active" "@LIBEXEC@/run/generate_filemap.py" "--target_dir" "@SHARE@/voicevox/character_info" | |
| exec "$UV_PATH" "run" "--active" "$RUN_PATH" "${args[@]}" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| CLI より VOICEVOX CORE の C API 経由でテキスト音声合成を行い、 | |
| 音声ファイルを出力する。簡単なテスト目的のプログラムである。 | |
| Copyright (C) 2026 z80oolong (zool@zool.jpn.org) | |
| Permission is hereby granted, free of charge, to any person obtaining | |
| a copy of this software and associated documentation files (the | |
| "Software"), to deal in the Software without restriction, including | |
| without limitation the rights to use, copy, modify, merge, publish, | |
| distribute, sublicense, and/or sell copies of the Software, and to | |
| permit persons to whom the Software is furnished to do so, subject to | |
| the following conditions: | |
| The above copyright notice and this permission notice shall be | |
| included in all copies or substantial portions of the Software. | |
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
| NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
| LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
| OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
| WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| */ | |
| #include <stdio.h> | |
| #include <stdlib.h> | |
| #include <string.h> | |
| #include <unistd.h> | |
| #include <stdbool.h> | |
| #include "voicevox_core.h" | |
| #define HOMEBREW_PREFIX "/home/linuxbrew/.linuxbrew" | |
| #ifndef VOICEVOX_CORE_PREFIX | |
| #define VOICEVOX_CORE_PREFIX HOMEBREW_PREFIX "/opt/voicevox-core" | |
| #endif | |
| #ifndef DEFAULT_ONNXRUNTIME_PATH | |
| #define DEFAULT_ONNXRUNTIME_PATH VOICEVOX_CORE_PREFIX "/lib/voicevox/libonnxruntime.so" | |
| #endif | |
| #ifndef DEFAULT_OPENJTALK_DIC_DIR | |
| #define DEFAULT_OPENJTALK_DIC_DIR VOICEVOX_CORE_PREFIX "/share/voicevox/dic" | |
| #endif | |
| #ifndef DEFAULT_VVM_MODEL_PATH | |
| #define DEFAULT_VVM_MODEL_PATH VOICEVOX_CORE_PREFIX "/share/voicevox/model/sample.vvm" | |
| #endif | |
| #define DEFAULT_SPEAKER_ID 0 | |
| #define DEFAULT_OUTPUT_FILE "./audio.wav" | |
| static void print_usage(const char *progname) { | |
| fprintf(stderr, "Usage: %s [-x libonnxruntime_so] [-d open_jtalk_dic_dir] [-v vvm_model] [-i speaker_id] [-o output_wav] TEXT\n", progname); | |
| fprintf(stderr, " -x : ONNX Runtime .so ファイル (default: %s)\n", DEFAULT_ONNXRUNTIME_PATH); | |
| fprintf(stderr, " -d : OpenJTalk辞書ディレクトリ (default: %s)\n", DEFAULT_OPENJTALK_DIC_DIR); | |
| fprintf(stderr, " -v : VVMモデルファイル (.vvm) (default: %s)\n", DEFAULT_VVM_MODEL_PATH); | |
| fprintf(stderr, " -i : 話者ID (default: %d)\n", DEFAULT_SPEAKER_ID); | |
| fprintf(stderr, " -o : 出力WAVファイル (default: %s)\n", DEFAULT_OUTPUT_FILE); | |
| fprintf(stderr, " TEXT: 合成するテキスト\n"); | |
| } | |
| int main(int argc, char *argv[]) { | |
| const char *onnx_path = DEFAULT_ONNXRUNTIME_PATH; | |
| const char *dic_dir = DEFAULT_OPENJTALK_DIC_DIR; | |
| const char *vvm_path = DEFAULT_VVM_MODEL_PATH; | |
| VoicevoxStyleId speaker_id = DEFAULT_SPEAKER_ID; | |
| const char *output_file = DEFAULT_OUTPUT_FILE; | |
| char *text = NULL; | |
| int opt; | |
| while ((opt = getopt(argc, argv, "x:d:v:i:o:h")) != -1) { | |
| switch (opt) { | |
| case 'x': onnx_path = optarg; break; | |
| case 'd': dic_dir = optarg; break; | |
| case 'v': vvm_path = optarg; break; | |
| case 'i': speaker_id = (VoicevoxStyleId)atoi(optarg); break; | |
| case 'o': output_file = optarg; break; | |
| case 'h': | |
| print_usage(argv[0]); | |
| return 0; | |
| default: | |
| print_usage(argv[0]); | |
| return 1; | |
| } | |
| } | |
| if (optind >= argc) { | |
| fprintf(stderr, "Error: TEXT が指定されていません。\n"); | |
| print_usage(argv[0]); | |
| return 1; | |
| } | |
| text = argv[optind]; | |
| VoicevoxResultCode result = VOICEVOX_RESULT_OK; | |
| const VoicevoxOnnxruntime *onnxruntime = NULL; | |
| OpenJtalkRc *open_jtalk = NULL; | |
| VoicevoxSynthesizer *synthesizer = NULL; | |
| VoicevoxVoiceModelFile *model = NULL; | |
| uint8_t *wav = NULL; | |
| size_t wav_size = 0; | |
| /* ONNX Runtime ロード */ | |
| VoicevoxLoadOnnxruntimeOptions ort_opts = voicevox_make_default_load_onnxruntime_options(); | |
| ort_opts.filename = onnx_path; | |
| result = voicevox_onnxruntime_load_once(ort_opts, &onnxruntime); | |
| if (result != VOICEVOX_RESULT_OK) { | |
| fprintf(stderr, "ONNX Runtime ロード失敗: %s\n", voicevox_error_result_to_message(result)); | |
| goto cleanup; | |
| } | |
| /* OpenJTalk */ | |
| result = voicevox_open_jtalk_rc_new(dic_dir, &open_jtalk); | |
| if (result != VOICEVOX_RESULT_OK) { | |
| fprintf(stderr, "OpenJTalk 初期化失敗: %s\n", voicevox_error_result_to_message(result)); | |
| goto cleanup; | |
| } | |
| /* Synthesizer */ | |
| VoicevoxInitializeOptions init_opts = voicevox_make_default_initialize_options(); | |
| result = voicevox_synthesizer_new(onnxruntime, open_jtalk, init_opts, &synthesizer); | |
| if (result != VOICEVOX_RESULT_OK) { | |
| fprintf(stderr, "Synthesizer 作成失敗: %s\n", voicevox_error_result_to_message(result)); | |
| goto cleanup; | |
| } | |
| /* VVMモデルロード */ | |
| result = voicevox_voice_model_file_open(vvm_path, &model); | |
| if (result != VOICEVOX_RESULT_OK) { | |
| fprintf(stderr, "VVMモデルロード失敗 (%s): %s\n", vvm_path, voicevox_error_result_to_message(result)); | |
| goto cleanup; | |
| } | |
| /* VVMモデル適用(新旧両対応) */ | |
| #ifdef ENABLE_OLD_API | |
| /* 旧API(引数2つ) */ | |
| result = voicevox_synthesizer_load_voice_model(synthesizer, model); | |
| #else | |
| /* 新API(引数3つ) */ | |
| VoicevoxLoadVoiceModelOptions load_opts = voicevox_make_default_load_voice_model_options(); | |
| result = voicevox_synthesizer_load_voice_model(synthesizer, model, load_opts); | |
| #endif | |
| if (result != VOICEVOX_RESULT_OK) { | |
| fprintf(stderr, "モデル適用失敗: %s\n", voicevox_error_result_to_message(result)); | |
| goto cleanup; | |
| } | |
| /* 音声合成 */ | |
| VoicevoxTtsOptions tts_opts = voicevox_make_default_tts_options(); | |
| result = voicevox_synthesizer_tts(synthesizer, text, speaker_id, tts_opts, &wav_size, &wav); | |
| if (result != VOICEVOX_RESULT_OK) { | |
| fprintf(stderr, "音声合成失敗: %s\n", voicevox_error_result_to_message(result)); | |
| goto cleanup; | |
| } | |
| /* WAVファイル出力 */ | |
| FILE *fp = fopen(output_file, "wb"); | |
| if (fp == NULL) { | |
| fprintf(stderr, "出力ファイルを開けません: %s\n", output_file); | |
| goto cleanup; | |
| } | |
| if (fwrite(wav, 1, wav_size, fp) != wav_size) { | |
| fprintf(stderr, "ファイル書き込み中にエラーが発生しました: %s\n", output_file); | |
| fclose(fp); | |
| goto cleanup; | |
| } | |
| fclose(fp); | |
| printf("音声合成完了: %s (%zu bytes)\n", output_file, wav_size); | |
| cleanup: | |
| if (wav) voicevox_wav_free(wav); | |
| if (model) voicevox_voice_model_file_delete(model); | |
| if (synthesizer) voicevox_synthesizer_delete(synthesizer); | |
| if (open_jtalk) voicevox_open_jtalk_rc_delete(open_jtalk); | |
| return (result == VOICEVOX_RESULT_OK) ? 0 : 1; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| CLI より VOICEVOX CORE の Python API 経由でテキスト音声合成を行い、 | |
| 音声ファイルを出力する。簡単なテスト目的のプログラムである。 | |
| なお、本プログラムは C API 用のプログラムの移植版である。 | |
| Copyright (C) 2026 z80oolong (zool@zool.jpn.org) | |
| Permission is hereby granted, free of charge, to any person obtaining | |
| a copy of this software and associated documentation files (the | |
| "Software"), to deal in the Software without restriction, including | |
| without limitation the rights to use, copy, modify, merge, publish, | |
| distribute, sublicense, and/or sell copies of the Software, and to | |
| permit persons to whom the Software is furnished to do so, subject to | |
| the following conditions: | |
| The above copyright notice and this permission notice shall be | |
| included in all copies or substantial portions of the Software. | |
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND | |
| NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE | |
| LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
| OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION | |
| WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| """ | |
| import argparse | |
| import sys | |
| from pathlib import Path | |
| from voicevox_core.blocking import ( | |
| Onnxruntime, | |
| OpenJtalk, | |
| Synthesizer, | |
| VoiceModelFile, | |
| ) | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="VOICEVOX CORE Python API でテキストを音声合成してWAV出力" | |
| ) | |
| parser.add_argument("text", nargs="?", help="合成するテキスト") | |
| parser.add_argument("-x", "--onnxruntime", default=None, | |
| help="ONNX Runtime .so ファイルのパス") | |
| parser.add_argument("-d", "--dict-dir", default=None, | |
| help="OpenJTalk辞書ディレクトリ") | |
| parser.add_argument("-v", "--vvm", default=None, | |
| help="VVMモデルファイル (.vvm)") | |
| parser.add_argument("-i", "--speaker-id", type=int, default=0, | |
| help="話者ID (style_id)") | |
| parser.add_argument("-o", "--output", default="audio.wav", | |
| help="出力WAVファイルパス") | |
| args = parser.parse_args() | |
| if not args.text: | |
| print("Error: TEXT が指定されていません。", file=sys.stderr) | |
| parser.print_usage() | |
| sys.exit(1) | |
| HOMEBREW_PREFIX = Path("/home/linuxbrew/.linuxbrew") | |
| VOICEVOX_CORE_PREFIX = HOMEBREW_PREFIX / "opt/voicevox-core" | |
| onnx_path = args.onnxruntime or str(VOICEVOX_CORE_PREFIX / "lib/voicevox/libonnxruntime.so") | |
| dict_dir = args.dict_dir or str(VOICEVOX_CORE_PREFIX / "share/voicevox/dic") | |
| vvm_path = args.vvm or str(VOICEVOX_CORE_PREFIX / "share/voicevox/model/sample.vvm") | |
| output_path = Path(args.output) | |
| print(f"ONNX Runtime: {onnx_path}") | |
| print(f"OpenJTalk辞書: {dict_dir}") | |
| print(f"VVMモデル: {vvm_path}") | |
| print(f"話者ID: {args.speaker_id}") | |
| print(f"出力ファイル: {output_path}") | |
| print(f"テキスト: {args.text}") | |
| try: | |
| ort = Onnxruntime.load_once(filename=onnx_path) | |
| open_jtalk = OpenJtalk(dict_dir) | |
| synthesizer = Synthesizer(ort, open_jtalk) | |
| with VoiceModelFile.open(vvm_path) as model: | |
| # 新API対応(on_existing を明示) | |
| synthesizer.load_voice_model(model, on_existing="ERROR") # "RELOAD" や "SKIP" も可 | |
| wav_bytes = synthesizer.tts( | |
| text=args.text, | |
| style_id=args.speaker_id, | |
| ) | |
| output_path.write_bytes(wav_bytes) | |
| print(f"[OK] 音声合成完了: {output_path} ({len(wav_bytes):,} bytes)") | |
| except Exception as e: | |
| print(f"[ERR] エラー: {e}", file=sys.stderr) | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| prefix=@prefix@ | |
| exec_prefix=${prefix} | |
| libdir=${prefix}/lib | |
| includedir=${prefix}/include | |
| Name: voicevox_core | |
| Description: VOICEVOX CORE Library | |
| Version: 0.16.4 | |
| URL: https://voicevox.hiroshiba.jp/ | |
| Libs: -L${libdir} -lvoicevox_core -ldl | |
| Cflags: -I${includedir} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment