M5Stack (ESP32-S3) で OpenJTalk によるオフライン日本語音声合成

この記事は約12分で読めます。

ネットワークに接続していない小型 IoT デバイスで、気温や湿度のようなセンサーの値を日本語の音声で読み上げたい、というのが今回の出発点です。クラウドの音声合成 API は使えませんし、デバイス上で動く一般的な日本語 TTS は MeCab 辞書 (数十 MB) を必要とするため、フラッシュ 8〜16MB の M5Stack には収まりません。

そこで OpenJTalk の音声合成エンジン (hts_engine) だけを ESP32-S3 に移植し、辞書を載せる代わりに「気温は{temp}度です」のようなあらかじめ決めたフレーズだけを対象にする、という割り切りで実装しました。数値の部分は実行時にセンサー値を桁ごとに読み上げます。本記事ではその仕組みと、リポジトリの構成を解説します。

Claude Code Pro (Opus4.8)を利用して作成しました。生成されたコードをみて、修正指示はしましたが、コードそのものの作成・修正はすべて Claude Codeが行いました。この記事自体も基本的にClaude Code が書いています。

目次

できること / 対応ボード

コードなどは https://github.com/kunsen-an/openjtalk-m5stack-onchip-tts にあります。

ESP32-S3 を搭載した M5Stack 3 機種に移植しています。ビルド時に -DBOARD= でターゲットを切り替えます (既定は cores3)。

ボード 指定 ディスプレイ オーディオ Flash
M5Stack CoreS3 -DBOARD=cores3 ILI9342 320×240 AW88298 (I2S) 16MB
M5StickS3 -DBOARD=sticks3 ST7789P3 135×240 ES8311 + AW8737 8MB
M5Stack Voice S3R -DBOARD=voices3r なし ES8311 (I2S) 8MB
  • 完全オンデバイス。ネットワーク接続も SD カードも不要です。録音再生ではなく、その場で波形を合成します。
  • 温度は -9.9〜60.0℃ を 0.1 刻み、CO2 濃度は 0〜5000ppm を 1 刻みなど、決められた範囲内の任意の数値を読み上げられます。
  • 音質は OpenJTalk 相当 (nitech 男性話者)。合成開始までの待ち時間は 1 文あたり数秒です。
  • ボード差分は main/board_config.h に集約してあり、ピン配置・LCD・オーディオ経路を 1 ファイルで切り替えます。ボード自体の判別と初期化 (電源 IC・コーデック・I2S・LCD) は M5Unified に任せています。

仕組み — なぜ辞書なしで読み上げられるのか

一般的な日本語 TTS は「テキスト → (辞書で形態素解析・読み付与) → フルコンテキストラベル → 音声合成」という流れです。重いのは前段の辞書部分で、ここが数十 MB を占めます。本プロジェクトではこの前段を PC 側で済ませてしまう方針を採りました。

具体的には、PC 上の pyopenjtalk で、固定文の断片 (「気温は」「度です」など) と、数字の読み片のフルコンテキストラベルだけを事前生成し、小さな辞書ファイル speech.bin (SPCF 形式) にまとめます。デバイス側は実行時に、センサーから得た数値を桁ごとの読み片に分解し、固定文の断片とつなぎ合わせ、つなぎ目の quinphone (前後 2 音素のコンテキスト) を張り替えて 1 回の合成にかけます。これにより辞書を増やさずに任意の数値を読み上げられ、データは 0.1MB 未満に収まります。

数字の読み片は、桁ごとの数字を 1 つずつ並べるのではなく、位ごとに読みを丸ごと 1 片として用意しています。一の位・小数桁 (ゼロ〜きゅう)、十の位 (じゅう〜きゅうじゅう)、百の位 (ひゃく・にひゃく・さんびゃく…)、千の位 (せん・にせん・さんぜん…)、そして万・億の単位と、小数点 (てん)・マイナスです。こうしているのは、日本語の数詞には「さんびゃく」「ろっぴゃく」「さんぜん」「はっせん」のような連濁・音便があり、単純な「さん+ひゃく」では正しく読めないためです。実行時には数値を「億・万・千・百・十・一」のグループに分解し、各位に対応する読み片を選んで連結します。

speech.yaml (発話テンプレート)
   │  python tools/gen_speech.py  ← PC 側で pyopenjtalk を実行
   ▼
speech.bin (SPCF: 固定文断片 + 数字読み片のラベル辞書)
   │  flash_all.ps1 で labels パーティションへ書き込み
   ▼  ── ここからデバイス上 ──
speech_list  : 実行時の数値を読み片に分解し、ラベルを連結
   ▼
hts_engine_API (ESP32-S3 へクロスコンパイル + float 最適化)
   │  音声モデル (htsvoice パーティション, 約1.2MB)
   ▼
PCM 音声 (PSRAM に蓄積)
   ▼
M5.Speaker (M5Unified) → I2S → アンプ → スピーカー

リポジトリ構成

音声モデルはリポジトリに同梱せず、セットアップスクリプトで取得します。

openjtalk-m5stack-onchip-tts/
├── README.md                 # 概要・セットアップ
├── LICENSE                   # 本プロジェクトのコード (MIT)
├── THIRD_PARTY_LICENSES.md   # hts_engine / 音声モデル等の権利表記
├── .github/workflows/build.yml   # ESP-IDF ビルド検証 (CI)
├── docs/                     # 解説・設計メモ
│
├── firmware/                 # ESP-IDF プロジェクト本体 (3 機種共通)
│   ├── CMakeLists.txt
│   ├── sdkconfig.defaults(.cores3/.sticks3/.voices3r)
│   ├── partitions_{cores3,sticks3,voices3r}.csv
│   ├── components/
│   │   └── hts_engine_api/   # 移植・float 最適化した hts_engine (+ COPYING)
│   └── main/
│       ├── main.c            # アプリ本体 (発話シーケンス)
│       ├── board_config.h    # ボード差分の集約点
│       ├── speech_list.c/.h  # 数値分解 + ラベル連結 (SPCF 読み出し)
│       ├── hts_wrapper.c/.h  # hts_engine ラッパ (合成)
│       ├── audio_out.cpp/.h  # M5.Speaker 出力 (PCM 蓄積)
│       ├── display.cpp/.h    # M5GFX によるステータス表示 (表示付き機種のみ)
│       ├── imu.cpp/.h        # IMU 読み出し (CoreS3/StickS3)
│       └── partition_vfs.c/.h
│
└── tools/                    # PC 側ツール
    ├── setup.sh              # 音声モデルを取得
    ├── speech.yaml           # 発話フレーズの定義
    ├── gen_speech.py         # speech.yaml → speech.bin (SPCF)
    ├── flash_all.ps1         # ファーム + モデル + speech.bin 一括書き込み
    └── capture_serial.py     # USB-Serial-JTAG のログ取得

セットアップとビルド

ESP-IDF v5.4 を前提とします。まず音声モデルを取得します (hts_engine の移植ソースはリポジトリに同梱済みです)。

# HTS 音声モデルを取得・展開 (tools/voice/ へ)
bash tools/setup.sh

次に、発話フレーズから辞書 speech.bin を生成します (全機種共通)。

pip install pyopenjtalk
python tools/gen_speech.py        # tools/speech.yaml → speech.bin

続いてボードを指定してビルドします。出力はボード別ディレクトリに分けます。

# ESP-IDF をアクティベート (Windows / PowerShell の例)
. C:\Espressif\frameworks\esp-idf-v5.4\export.ps1


cd firmware
idf.py set-target esp32s3
idf.py -DBOARD=cores3   -B build_cores3   build   # CoreS3 (既定)
idf.py -DBOARD=sticks3  -B build_sticks3  build   # StickS3
idf.py -DBOARD=voices3r -B build_voices3r build   # Voice S3R

最後に、ファームウェア・音声モデル・speech.bin の 3 要素を一括で書き込みます。

.\tools\flash_all.ps1 -Port COM3                  # CoreS3 (既定)
.\tools\flash_all.ps1 -Port COM3 -Board sticks3   # StickS3
.\tools\flash_all.ps1 -Port COM3 -Board voices3r  # Voice S3R

フラッシュのパーティションは次のように配置しています。

パーティション オフセット サイズ 内容
factory 0x010000 3MB ファームウェア (約 350KB 使用)
htsvoice 0x310000 2MB HTS 音声モデル (約 1.2MB)
labels 0x510000 7MB 発話辞書 speech.bin (SPCF)

発話フレーズの定義 (speech.yaml)

読み上げたい文は tools/speech.yaml に列挙します。{名前} が数値プレースホルダで、値は YAML には書きません — デバイスが実行時にセンサーから取得した値を当てはめます。

sentences:
  - text: "エックス軸加速度は{accx}です"
  - text: "ワイ軸加速度は{accy}です"
  - text: "ゼット軸の角速度は{gyrz}度毎秒です"
  - text: "気温は{temp}度、二酸化炭素濃度は{co2}ピーピーエムです"

変数名は自由で、1 文に複数・文ごとに異なる名前を使えます。数値は万・億までの整数、小数、負数 (マイナス) に対応します。ファームウェア側は main.cspeech_set_var("temp", 値, 小数桁) のように値を供給します。フレーズに含まれていない語彙は読み上げられない、というのがこの方式の制約です。

実装のポイント

double → float 化による高速化

hts_engine は内部が double 演算だらけで、ESP32-S3 の FPU は float 専用のためソフトエミュレーションになり非常に遅い (1 文 50〜83 秒) という問題がありました。エンジン内部を float 化し、-fsingle-precision-constant などのコンパイルオプションを加えることで大幅に短縮できました。ただし MLPG (パラメータ生成) の再帰計算だけは float だと誤差が蓄積して音質が落ちるため、ここだけ double に戻しています。PC 上で double 版 / float 版の波形を比較して劣化が聴感上無視できる (SNR 約 39dB) ことを確認した上での判断です。

合成した PCM の再生

合成された PCM サンプルは PSRAM 上のバッファに蓄積し、1 文ぶんがそろった時点で M5Unified の M5.Speaker (playRaw) へ渡して再生します。エンジン側にはボコーダ出力を 50ms 単位で受け取るチャンク機構があり、合成と再生をオーバーラップさせる余地は残していますが、現状の M5.Speaker バックエンドは蓄積してから一括再生する実装です。

ボード初期化と日本語表示は M5Unified / M5GFX に任せる

電源 IC・オーディオコーデック・アンプ・I2S・(あれば) LCD の初期化は、M5.begin() がボードを自動判別して行います。3 機種でコードを共有できるのはこのおかげです。日本語のステータス表示も、別途フォントを用意せず M5GFX 内蔵の日本語フォント (efontJA) をそのまま使っています。表示を持たない Voice S3R では BOARD_HAS_DISPLAY=0 で描画 API を no-op にし、進捗や結果はシリアルログへ出します。

Voice S3R は internal_mic = true が必須

Voice S3R はスピーカーとマイクが ES8311 / I2S を共有しており、M5Unified は internal_mic = true のときだけスピーカー側のピンを初期化します。これを false にすると (マイクを使わなくても) 無音になります。ハマりやすいので注記しておきます。コンソールは USB 直結 (USB-Serial-JTAG) のため、ログ取得には tools/capture_serial.py が便利です。

動作の流れ

アプリ本体 (main.c) は起動後におおよそ次の順で動きます。

  1. display_init() — M5Unified でハードを初期化し「起動中」を表示 (表示なし機種はログのみ)。
  2. hts_wrapper_init() — htsvoice パーティションから音声モデルをロード。
  3. speech_list_init("labels") — labels パーティションの speech.bin を読み込み。
  4. audio_out_init() — M5.Speaker (I2S・アンプ) を初期化。
  5. センサー値 (実機 / 乱数) を取得して各変数へ供給し、定義した文を順に発話。
  6. 各文の合成所要秒数を記録し、最後に完了サマリを表示・ログ出力。

センサー未接続でもデモが回るよう、IMU 非搭載 (または読み取り失敗) のときは各変数の意味に応じた範囲の乱数を供給するようにしています。

デモ動画

M5StickS3 での音声合成完了までの全体

まとめ / 参考

辞書という最も重い部分を PC 側へ追い出し、「決め打ちのフレーズ + 実行時の数値読み上げ」に割り切ることで、フラッシュ 8〜16MB の ESP32-S3 でも OpenJTalk 品質の日本語 TTS をオフラインで動かせました。センサー値のローカル読み上げのように、語彙が限られていても十分役立つ用途は多いはずです。なお hts_engine や音声モデルなど利用した OSS の権利表記は、リポジトリの THIRD_PARTY_LICENSES.md にまとめています。

コメント

タイトルとURLをコピーしました