feat(android): Android システム TTS エンジンを追加 - #604
Conversation
Android のシステム TTS エンジンとして piper-plus を提供する設計。 sherpa-onnx 経由での実現可能性を PoC で検証した結果、ONNX の入力 シグネチャと metadata は解決できる一方、sherpa-onnx に日本語 G2P が 存在しないため生テキストからの合成が成立しないことが判明した。 このため自前の TTS エンジンを提供する方針とする。 既存の android/piper-plus AAR (JNI + libpiper_plus.so) に依存し、 Kotlin 側は TextToSpeechService とモデル管理 UI に限定することで、 推論・G2P の実装が C++ と二重化することを避ける。
TDD の 9 タスクに分解。各タスクは失敗するテスト → 最小実装 → 検証 → コミットのサイクルを持ち、独立してレビュー可能な単位にした。 設計書の §9.1 / §12 もあわせて修正。CMakeLists.txt が libpiper_plus.so を IMPORTED として宣言しているため、M1 の本質は「リリース配布」ではなく 「CI 内での jniLibs への供給」だった。リリースアセットとしての配布は 外部の AAR 利用者向けの別価値なので M5 に移した。 モデル管理 UI (M4) と配布 (M5) は後続計画として範囲外に切り出し、 M3 完了時点で「Android で 6 言語が喋る」ことを検証できる構成にしている。
つくよみちゃんはキャラクター固有の利用規約を持つため、初回起動時に 同意フローが必要になる。既定を css10-6lang にすることで導線を短くできる。 モデル ID は piper-core の組込みレジストリ (model_download.rs) の モデル名と揃え、端末上のディレクトリ名にもそのまま使う。 ライセンス同意フローはつくよみちゃん固有の記述から、モデルごとの ライセンス表示という一般形に書き換えた。
.gitignore には android/piper-plus-g2p/ の成果物しか登録されておらず、 新モジュールと android/piper-plus/ のビルド生成物、およびマシン固有の local.properties が git に入る状態だった。 あわせて AGP 8.x が JDK 17 以上を要求する点を Step 8 の前提として明記。
Task 9: 既存の package-android job が jniLibs レイアウト (<abi>/*.so) の 結合 artifact を既に作っていたため、ABI ごとの手動コピーは不要だった。 download-artifact の展開先を指定するだけで済む形に書き換えた。 Task 6: .so が無いと Gradle が native ビルドをスキップし、C++ の構文 エラーが CI まで発見できない。NDK の clang による構文チェック手順を ステップとして追加した。
Action SHA drift reportCollected pins (3 actions) — expected_total_pins=3
Summary: total=3, ok=3 |
Android TTS エンジンの設計書と実装計画に含まれるコードブロックが audit に登録されていなかったため drift していた。
Required status-check gate (deferred)Head SHA |
Bundle size gate
Summary: 0 fail / 6 skip / 0 ok SKIP means the artifact was not built in this job, or the baseline is a placeholder. The gate never fails on SKIP. |
MD040: 図表用のコードフェンスに text 言語を指定。 MD032: **Files:** / **Interfaces:** 直後のリストに空行を追加 (18 箇所)。 あわせて設計書のモデル一覧で、編集時に重複していたリード文を統合した。
TDD 充足度の監査で、テストの分布がリスクの分布と逆になっていることが判明した。 30 件のユニットテストは純関数に集中する一方、最大の新規複雑度である PiperPlusTtsService (156 行) はテストコード中に識別子すら現れず、 壊れると音が壊れる境界 (PCM のバイト詰め / 8 引数 JNI / callback プロトコル / manifest 配線) はすべてカバレッジ 0 だった。その未検証領域から production バグが 2 件見つかったため、修正とテストを併せて入れる。 production バグ: - 打ち切られた stream がネイティブ iterator を解放せず、以降の全合成が ERR_BUSY になる。synth_start は engine を busy にしたまま返り、解放するのは synth_next が終端に達したときだけだった。読み上げ停止を 1 回押すと プロセスが死ぬまで TTS が沈黙する。piper_plus_synth_abort() を新設し PiperPlus.synthesizeStream の finally から呼ぶ - System.loadLibrary の失敗は LinkageError なので catch (Exception) を すり抜け、非対応 ABI の端末でサービスごとクラッシュしていた。 設計書 §8 が「クラッシュさせない」と規定した経路 CI: - android-build.yml の paths が android/** を含まず、Kotlin / JNI だけを 変更した PR ではテストが 1 件も実行されていなかった。マージ後は既存の 30 件も二度と走らない状態だった - JVM ユニットテストを kotlin-unit-tests job に分離し、3 ABI の ネイティブビルド (45-60 分) への従属を外した テスト可能性のための分割: - SynthesisRequest は final かつ全 getter がスタブのため、Service を直接 呼ぶ形では JVM テストが 1 分岐にしか到達できない。合成本体を SynthesisSession と PcmEmitter に切り出し、停止フラグの所有権も移した - JNI 呼び出しと NativeEngine を named argument 化。末尾 4 引数がすべて Float で、位置引数のままだと並べ替えても型検査を通る - lateinit を by lazy に変更し、super.onCreate() の順序依存を構造的に解消 テスト (30 → 61 件、新規 dependency ゼロ): - PcmEmitterTest / SynthesisSessionTest — 中核ロジック - PiperPlusNativeBridgeTest — Kotlin external 宣言と C++ 定義をソース照合 - ManifestWiringTest — Service 配線と intent-filter - SynthOptionsTest — 既定値を piper_plus_default_options() の実ソースと突き合わせ (従来は Kotlin リテラルを Kotlin リテラルで読み返していた) - C++ 統合テスト 2 件 — 放棄された iterator の解放と冪等性 いずれも production に変異を入れて落ちることを確認済み (23/23 検出)。 削除: - androidTest の PiperPlusTtsServiceTest — Service を一度も参照せず LocaleResolverTest と assert が重複。CI ではコンパイルもされず、 モデル配置済みの端末では fail する - ModuleSanityTest — assertTrue(true)
Public ABI snapshot diffBootstrap mode: baseline is empty, recording first observation. No ABI changes detected. |
docs/spec/language-id-map-contract.toml は「Drift is catastrophic — every multilingual checkpoint becomes unrecoverable」と警告し、9 つのランタイム ミラーを gate しているが、本ブランチが追加した Kotlin の表だけが どの gate にも載っていなかった。 check_language_id_map_contract.py の sweep は src/ しか walk しないため、 android/ は forbidden-pattern sweep の対象にもならない。さらに Kotlin 側は Android framework が ISO-639-3 を渡してくる関係でキーが "jpn" 形式であり、 sweep が探す '"ja": 0' 形式とは構造的に一致しない。 通り抜ける drift の例: 将来モデルが言語を途中挿入した場合 (ko=2 で zh→3…pt→6)、canonical 側の不変条件 (ja_is_zero / en_is_one / values_consecutive_from_zero / values_unique) はすべて満たすため契約検査は 通り、登録済み 9 ミラーは gate に従って更新される。一方 Kotlin だけが "zho" to 2 のまま残り、中国語が韓国語の emb_lang に流れる。クラッシュせず 「それっぽいが違う」音声になるため、発見が遅れる。 - kotlin_iso3_map extractor を追加。mapOf リテラルを括弧の対応をとって 切り出し、[iso639_3_aliases] で canonical キーに正規化してから比較する - 未知の ISO-639-3 コード、および zho/cmn が異なる id を指す状態を拒否する - parity-hub.yml の paths と pre-commit の files 正規表現に LocaleResolver.kt を追加。canonical 側だけを変更する PR でも発火するよう、gate 登録を JVM テストではなく contract 側で行う 変異 5 種 (spa/fra 取り違え / cmn だけ別 id / jpn 削除 / 未知コード追加 / シンボル rename / pt を連番から外す) がすべて検出されることを確認済み。
Runtime Parity Deep — audio (informational tier)Pairs compared: 15, failing: 14, runtimes skipped: 0.
|
SynthesisSession が callback.start() に 22050 を固定で申告していた。 22050Hz 以外のモデルを配置すると全発話がピッチのずれた音で再生されるが、 エラーにも警告にもならないため「なんとなく声が高い」としか気付けない。 PiperPlusEngine に sampleRate を追加し、実際の合成では engine.sampleRate を 渡すようにした。空文字の経路だけは音声を 1 バイトも出さないため、モデルを ロードせずに FALLBACK_SAMPLE_RATE を申告する従来の挙動を保つ。 監査ではこの項目を「interface を広げる production 変更が必要」として 別 issue 送りにしていたが、同種の seam 変更を既に入れているため本 PR に含める。 定数に戻す変異でテストが落ちることを確認済み。
Distroless trial: webui / cpp-inferenceEach canonical Dockerfile is UNCHANGED; the trial Dockerfile sits beside it as (
|
| metric | canonical | distroless trial | delta |
|---|---|---|---|
| image size (linux/amd64) | 237MB | 232MB | -2.2% |
Smoke entrypoint: /usr/local/bin/piper-plus — passed.
webui
Trial Dockerfile: docker/webui/Dockerfile.distroless. Canonical docker/webui/Dockerfile is UNCHANGED.
| metric | canonical | distroless trial | delta |
|---|---|---|---|
| image size (linux/amd64) | 928MB | 764MB | -17.7% |
Smoke entrypoint: /usr/bin/python3 — passed.
Not in these trials (follow-up promotion PRs)
- linux/arm64 buildx coverage is handled by
docker-build.yml(build-distroless-trialsmatrix). - CVE Trivy diff lives in
trivy-container-scan.yml(per-image SARIF upload). - For
webui: full Gradio cold-start under distroless (a realdocker run -p 7860:7860fromwebui-test.yml) is the promotion gate. - For
cpp-inference: end-to-endpiper-plus --model ... --output_file ...with a real model fixture is the promotion gate.
Multi-Runtime RTF BenchmarkPolicy: warn-only (thresholds: RTF +/-10%, P50 +/-10%, P95 +/-15%) Model:
|
マージ前の確認で、ktlint と detekt が :piper-plus-g2p にしか適用されておらず、 :piper-plus と :piper-plus-tts-engine の Kotlin が一度も lint されていないことが 判明した。pre-commit の "ktlint --android (Kotlin)" が Passed と表示されていたのは g2p モジュールだけを見た結果で、本ブランチが追加した約 700 行は素通しだった。 - 両モジュールに ktlint を適用 (g2p と同じ plugin 12.3.0 / engine 1.3.1 / android=true) - ktlintFormat を適用。既存コードを含め 22 ファイルが整形された - pre-commit hook を 3 モジュールすべてに拡張 - android-build.yml に blocking な ktlint step を追加 (pre-commit 側は non-blocking なため) あわせて、確認の過程で見つかった残りのドリフトを解消: - 設計書 §6.2 が synthesize(text, speakerId) に @deprecated を付けると 規定していたが実装は付けていない。単一話者モデルを既定設定で鳴らす用途では 今も最短の書き方であり、非推奨にしても既存利用者に移行の実益がないため、 実装側の判断を採用してドキュメントを実態に合わせた。 あわせて options に既定値を付けられない理由 (speakerId 版と併存するため synthesize("text") が両方に解決してコンパイルエラーになる) を明記した - examples/dart の FFI binding に piper_plus_synth_abort を追加 C API の消費者 (JNI / Dart / Godot / C++ テスト) を網羅的に確認し、Godot の iterator ループは常に DONE まで drain するか synth_next 自身が解放する エラー経路で抜けるため、abort の追加漏れがないことを確認済み。
ktlint に続き、静的解析も g2p モジュールにしか掛かっていなかった。 本ブランチが追加した Kotlin を対象に含める。 検出された 17 件はすべて対応済み (閾値の引き上げや baseline での 黙らせは行っていない): - PcmEmitter の 0xFF / 8 を BYTE_MASK / BITS_PER_BYTE に命名 - SynthesisParams.lengthScaleOf / ModelPaths.isInstalled の長すぎる行を分割 - PiperPlusNativeBridgeTest.bodyAfter の return を 3 → 2 に整理 - PiperPlus.extractDictIfNeeded のネストと return を整理し、 アセット複製を copyAssetsTo に切り出し (isDirectory は exists() を含意し、 isNullOrEmpty() は null と空の両方を覆うため挙動は同一) - EngineHolderTest の throw IllegalStateException を error() に - LocaleResolver の language_id (MagicNumber)、PiperPlusNative の 関数数 (TooManyFunctions)、SynthesisSession のガード節 (ReturnCount) と ネイティブ例外の総取り (TooGenericExceptionCaught) は理由付きで @Suppress あわせて: - detekt.yml の ComplexMethod を CyclomaticComplexMethod に改名 (1.23 で改名済み。旧名は deprecation 警告を出していた) - android-build.yml に detekt step を追加 :piper-plus-g2p は plugin を適用済みだが CI gate には含めない。dev 時点で 既に 47 件 (大半が DictionaryDownloader.kt の detekt-formatting 系) を 報告しており、本 PR とは無関係の既存負債のため。件数が本 config 変更の 前後で同一 (47 → 47) であることを確認済み。経緯と「閾値で消すな」の 方針を detekt.yml に明記した。
Summary
Android のシステム TTS エンジンとして piper-plus を提供する。任意のアプリやアクセシビリティ機能から、オフラインで 6 言語 (ja/en/zh/es/fr/pt) の音声合成を利用できるようになる。
背景として、Android で piper-plus のモデルを sherpa-onnx 経由で使おうとしたユーザーが断念した事例がある。調査の結果、sherpa-onnx には日本語 G2P が存在せず、ONNX の入出力を互換化しても生テキストからの合成が成立しないことが判明したため、自前のエンジンを提供する方針を採った。
実装後にテスト充足度を監査したところ、テストの分布がリスクの分布と逆になっていた。壊れると音が壊れる境界 (PCM のバイト詰め、8 引数の JNI ブリッジ、
SynthesisCallbackの呼び出しプロトコル、manifest 配線) がいずれもカバレッジ 0 で、そこから production バグが 2 件見つかった。修正とテストを併せて含む。Affected Components
android/piper-plus/src/main/cpp/) の変更を含むType
Risk Level
既存 API は追加のみ。
PiperPlus.synthesize(text, speakerId)はそのまま維持し、オプション指定版をオーバーロードとして追加している。Contract Impact
docs/spec/*.tomlの変更なし。音素化・推論・文分割はすべて既存のlibpiper_plus.soに委譲しており、契約に関わるロジックを Kotlin 側で再実装していない。変更内容
PiperPlusTtsServiceTextToSpeechServiceを実装し、Android の TextToSpeech API から piper-plus を呼べるようにするLocaleResolverlanguage_idに解決し、可用性 (LANG_AVAILABLE/LANG_MISSING_DATA/LANG_NOT_SUPPORTED) を判定するSynthesisParamsspeechRate(100 = 等速) をlength_scaleに変換するSynthOptions(AAR)language_id/length_scale/noise_scale/noise_w/sentence_silence_secを指定できるspeakerIdしか通しておらず、多言語合成と速度制御が原理的に不可能PiperPlusSynthOptionsを組み立てて C API に渡す (_reserved[5]はゼロ埋め)ModelPathsEngineHolderPiperPlusインスタンスをキャッシュし、切替時に確実に解放するabiFiltersをarm64-v8a/armeabi-v7a/x86_64に拡張jniLibsに展開し、APK ビルドまで実行piper_plus_synth_abort()synth_startは engine を busy にしたまま返り、解放するのはsynth_nextが終端に達したときだけERR_BUSYで失敗するcatch (LinkageError)System.loadLibraryの失敗を捕まえてERROR_SERVICEにするpathsにandroid/**を追加し、JVM テストを native ビルドに従属しない独立 job に分離SynthesisSession/PcmEmitterSynthesisRequestが final + 全 getter スタブのため、JVM テストが 1 分岐にしか到達できないPiperPlusNativeBridgeTestexternal宣言とpiper_plus_jni.cppの C++ 定義をソース照合Floatで、並べ替えても JNI シグネチャが変わらずリンクも通る (length_scaleとnoise_scaleが入れ替わると常時 2.5 倍速)ManifestWiringTestandroid:nameが実在のTextToSpeechServiceに解決すること、intent-filter が正しいことassembleDebugは成功し、端末の設定画面にエンジンが現れないSynthOptionsTestの実質化piper_plus_default_options()の実ソースと突き合わせ設計判断
docs/spec/*.tomlの契約を C++ と 2 箇所で守る必要が生じる。実装の二重化を避けることを優先した。PiperPlusEngineインターフェースを挟んだ。PiperPlusを直接持つと JVM ユニットテストで JNI のロードが走ってしまう。この境界により、エミュレータなしで生存管理のロジックを検証できる。css10-6langにした。 つくよみちゃんはキャラクター固有の利用規約を持つため初回起動時に同意フローが必要になる。既定を規約なしのモデルにすることで導線を短くした。モデル ID はpiper-coreの組込みレジストリ (model_download.rs) の名前と揃え、端末上のディレクトリ名にもそのまま使っている。pitchを渡してくるが、VITS に対応するパラメータがない。無視する旨をドキュメントに明記し、設定画面でも示す方針とした。takeWhileを使った。collect内の早期 return では上流の生成が止まらず、onStop()後もチャンクを作り続けてしまう。package-androidjob の結合 artifact を使う。 この job が既に<abi>/*.soという jniLibs レイアウトへ再編成しているため、ABI ごとの手動コピーは不要だった。CMakeLists.txtがlibpiper_plus.soをIMPORTEDとして参照する以上、AAR のビルド前に配置されている必要がある。piper_plus_synth_abort()を新設した。 Kotlin のfinallyで残りを drain する案は、takeWhileが節約しようとした CPU をそのまま焼くので却下した。C API に解放専用の入口を作り、何も進行していないときに呼んでも安全にすることで、無条件のクリーンアップ経路に置けるようにした。catch (Throwable)にはしなかった。runBlockingを囲む位置でThrowableを握るとOutOfMemoryErrorまで飲み込み、キャンセルの制御も曖昧になる。ExceptionとLinkageErrorの 2 節に分けた。android.speech.tts.SynthesisRequestは final かつ全 getter がRuntimeException("Stub!")を投げるスタブで、reflection でも中身を詰められない。Robolectric や mockk を入れる案もあったが、request をほどいた形の seam を切れば手書きの fake で足りるため、依存を増やさない方を選んだ。本 PR で追加したテストの新規 dependency はゼロ。takeWhileの書き換え、停止フラグのリセット除去、JNI 引数の並べ替え、manifest のタイポなど 23 種類の変異を production に入れ、対応するテストが実際に落ちることを確認している (23/23 検出)。androidTestを削除した。PiperPlusTtsServiceTestは名前に反してPiperPlusTtsServiceを一度も参照せず、assert がLocaleResolverTestと重複していた。CI ではassembleDebugAndroidTestすら呼ばれずコンパイルもされないため rot しており、さらにモデルを配置済みの端末では fail する。実行されないテストは、あるだけで「守られている」という誤った印象を与える分、無いより有害と判断した。Test Plan
cd android ./gradlew :piper-plus:testDebugUnitTest :piper-plus-tts-engine:testDebugUnitTesttest_c_api123 件 /test_c_api_integration23 件)Build TTS engine APKjob が green であることを確認する (ネイティブビルド + 3 ABI の jniLibs 配置 + APK ビルド)Checklist
設計書
docs/design/android-tts-engine-design.mdと実装計画docs/design/android-tts-engine-plan.mdを追加。CHANGELOG.mdの Unreleased に追記済み。Related Issues
なし