Mac 上で動かしている bot のログ監視結果や通知を音声で受け取りたくなりました。定期的な読み上げや通知用途でテキスト→音声の変換基盤が必要だったので、ローカルで動く TTS を検討しました。「ローカル・永年無料」「日本語の流暢さを最優先」「カスタム性を重視」を条件に、VOICEVOX を主軸に、英語向けのフォールバックとして Kokoro を組み合わせる構成にしました。
設計
全体の流れは次のとおりです。
[テキスト入力]
↓
[言語判定] → 日本語 → VOICEVOX
↓ → 英語 → Kokoro
[音声合成]
↓
[再生: afplay] 候補として VOICEVOX、Kokoro、piper-plus、say、Edge TTS などを比較しました。say は追加インストール不要ですがカスタム性がなく基盤を作る意義が薄いと判断して除外。Edge TTS はオフライン不可のためローカル必須の条件に合わず除外しました。日本語の流暢さでは VOICEVOX が最上位で、英語は Kokoro が強みでした。
VOICEVOX は HTTP API 経由で、audio_query でクエリを取得してから synthesis で WAV を取得する 2 段階の流れです。
# 1. audio_query でクエリ取得
query_resp = requests.post(
f"{self.base_url}/audio_query",
params={"text": text, "speaker": self.speaker_id},
timeout=self.timeout,
)
audio_query = query_resp.json()
# 2. synthesis で WAV 取得
synth_resp = requests.post(
f"{self.base_url}/synthesis",
params={"speaker": self.speaker_id},
json=audio_query,
timeout=self.timeout,
)
wav_data = synth_resp.content
Kokoro は CLI 経由で呼び出すアダプターを実装しました。当初「実行時の cwd にモデルを置く」必要があると読んでいたのですが、--help を確認したら --model / --voices オプションがあり、任意ディレクトリから実行できることが判明しました。model_dir を指定すれば、kokoro-v1.0.onnx と voices-v1.0.bin のパスを渡して実行できます。
cmd = ["kokoro-tts", tmp_input, out_path, "--lang", self.lang, "--voice", self.voice]
if self.model_dir:
model_path = model_dir / "kokoro-v1.0.onnx"
voices_path = model_dir / "voices-v1.0.bin"
cmd.extend(["--model", str(model_path), "--voices", str(voices_path)])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
言語判定は「複数方式を試してから決めたい」という意向があったため、設計書では TODO として残し、まずは文字種比率による仮実装で進めました。日本語文字(ひらがな・カタカナ・漢字)の割合が threshold(デフォルト 0.3)以上なら日本語、未満なら英語と判定。英語判定時は extract_for_english_tts で日本語ブロックを除去し、Kokoro に渡すテキストとスキップした日本語のリストを返しています。後から差し替え可能な設計にしておいたので検証しやすかったです。
def detect(text: str, threshold: float = 0.3) -> Lang:
japanese_count = sum(1 for c in chars if _JAPANESE_PATTERN.match(c))
ratio = japanese_count / len(chars)
return "ja" if ratio >= threshold else "en"
def extract_for_english_tts(text: str) -> tuple[str, list[str]]:
skipped = _JAPANESE_BLOCK.findall(text)
text_for_tts = _JAPANESE_BLOCK.sub("", text).strip()
return text_for_tts, skipped
オーケストレーター(core.py)では、detect の結果に応じてアダプターを切り替え、英語判定時は VOICEVOX で「英語モデルで読み上げます」などの前置きを再生しつつ、Kokoro の合成を ThreadPoolExecutor で並列実行して待ち時間を短縮しています。
lang = detect(text)
if lang == "ja":
adapter = VoicevoxAdapter(...)
text_for_tts = text
else:
text_for_tts, skipped_japanese = extract_for_english_tts(text)
adapter = KokoroAdapter(model_dir=..., voice=..., speed=...)
# 英語時: VOICEVOX で前置き再生しつつ Kokoro 合成を並列実行 実装の工夫
pip ではなく uv を使う方針にしたので、pyproject.toml と README を uv 用に統一しました。uv run と uv tool install では実行コンテキストが異なり、.env の読み込み先も変わります。プロジェクトの .env に加えて ~/.config/voice-feed/.env も読み込むようにして、両方のパターンで動くようにしました。
Kokoro は日本語を "japanese character" と読んでしまうため、英語判定時に日本語が含まれていると不自然になります。日本語部分を除去して英語のみ Kokoro に渡し、スキップした内容は stderr でフィードバック。 さらに VOICEVOX で「英文内に含まれていた日本語をスキップしました」と音声で案内するようにしました。
実装した機能
実装した機能は、VOICEVOX アダプター(HTTP API・話者 ID 指定)、Kokoro アダプター(CLI・音声・速度オプション)、言語判定(文字種比率の仮実装・後から差し替え可能)、CLI(voice-feed "読み上げたいテキスト")、VOICEVOX Engine 単体(GUI なし・launchctl で自動起動)です。