最初に「発語支援AIロボットあいくん」と今回の試みの説明
- 自作の発語支援AI「あいくん」(PC側でSTT・ローカルLLM・VOICEVOX音声合成)の顔・字幕・選択肢・音声出力を、リビングのSony BRAVIA(Android TV 10 / KJ-43X8000H)に出せるようにした。
- 一番ハマったのは BRAVIAのOS標準「証明書インストーラー」が壊れていて、ユーザーCAを一切追加できなかったこと。→ アプリ専用の信頼ストアにCAを入れる方式で回避した。
- おまけの発見: この機種は本体マイク非搭載(Sony公式で確認)なのに、
AudioSource.VOICE_RECOGNITIONを使うと付属リモコンのマイク音声を生PCMで取得できた。子ども向けに「押して話す+合図音」のタップ発話UIにした。 - 子ども向けなので、会話・音声・プロフィールをTVに一切保存しない設計を最初から徹底した。
- そして一番伝えたいのは、「AIはローカルのサーバー側、TVは薄いインターフェース」という分離の手ごたえが良かったこと。型落ちTVでも導入でき、将来のホームオートメーション向けAIコンシェルジュのUIとしても相性が良さそう、という手応えと期待(詳しくは後半の考察で)。
※ この記事のURL・トークン・IPアドレス等はすべてプレースホルダです。実際の値は載せていません。
1. なにを作ったか
「あいくん」は、発語支援が必要な子ども向けに自作しているAIロボット(ソフトウェア)です。普段はPCとスマホ(ブラウザ)で動かしていて、
- PC側: 音声認識(STT)、会話制御、ローカルLLM、VOICEVOXによる音声合成
- 表示側: あいくんの顔・表情・字幕・選択肢を出して、子どもがD-padやタップで選ぶ
という構成です。これを、大画面のTVでも使えるようにしたいというのが今回のテーマ。
役割分担はシンプルに切りました。
Sony BRAVIA / Android TV Windows PC(サーバー)
┌────────────────────┐ ┌──────────────────────────┐
│ 顔・表情・字幕 │ WSS(状態) │ 会話制御・安全フィルタ │
│ D-padで選択 │ ◀────────── │ STT・ローカルLLM │
│ WAV音声をメモリ再生 │ ──────────▶ │ VOICEVOX │
│ 再生ACK・停止 │ │ プロフィール・記録 │
└────────────────────┘ └──────────────────────────┘
同じ信頼済みLAN上でHTTPS/WSS(8444番)
「状態の正」は常にPC。TVは表示と音声出力に徹して、会話ログや音声、子どものプロフィールは一切持たせません(後述のプライバシー設計)。
TV側はKotlin + Jetpack Compose for TV で、依存を最小にした独立Androidプロジェクトとして作りました。
2. 動作環境
| 項目 | 値 |
|---|---|
| TV | Sony BRAVIA KJ-43X8000H(Android TV 10 / API 29) |
| TVアプリ | Kotlin + Compose for TV、minSdk 23 |
| 通信 | OkHttp(HTTPS/WSS)、平文は不許可 |
| PC | Windows、Python(FastAPI系サーバー) + VOICEVOX |
| 接続 | 同一LAN、TV用に8444番のHTTPS/WSSを新設 |
3. 難関1: BRAVIAの証明書インストーラーが「壊れていた」
TVアプリからPCへHTTPSで安全につなぐには、自己署名ではなくローカルCAで署名したサーバー証明書を使い、そのCAをTVに信頼させたい。ふつうのAndroidなら「設定 → セキュリティ → 証明書をインストール」でユーザーCAを入れられます。
ところがKJ-43X8000Hでは、これがことごとく失敗しました。実機ログで確認できた事象:
-
ACTION_OPEN_DOCUMENT/GET_CONTENTがcom.google.android.tv.frameworkpackagestubsのスタブへ解決され、ファイル選択が即キャンセルされる。 - 証明書ファイルのURIで標準インストーラーを開くと、高さ0の空画面になる。
-
KeyChain.createInstallIntent()の確認ダイアログは出せるが、OKを押すとcom.android.settings/.security.CredentialStorageが存在せず、ActivityNotFoundException→credential not savedで終了。
要するに、このファームウェアには「ユーザーCAを保存する画面」が無い。ユーザーの操作ミスではなく、仕様でした。
解決: OSではなく「アプリ専用の信頼ストア」に入れる
OS全体の信頼ストアを変更するのを諦め、あいくんTVアプリだけが信頼するCAをアプリ専用領域に保存する方式にしました。
- USBメモリ直下に置いた公開CA
aikun-ca.crt1個だけを、アプリが読み取る - 読み取り時に 容量・X.509・CA属性(BasicConstraints CA=true / keyCertSign)・自己署名・識別名を検証
- 検証済みCAをアプリ専用領域へ保存し、OkHttpのTrustManagerをそのCAだけで構成
- ホスト名検証は無効化しない(OkHttp標準のまま)
イメージとしてはこういう構成です(抜粋・簡略化)。
// アプリ専用に保存した公開CAだけを信頼するOkHttpClientを組む
val ca: X509Certificate = loadVerifiedAppCa(context) // 検証済みのCAをアプリ領域から
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
setCertificateEntry("aikun-ca", ca)
}
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
init(keyStore)
}
val trustManager = tmf.trustManagers[0] as X509TrustManager
val client = OkHttpClient.Builder()
.sslSocketFactory(sslContextFor(trustManager).socketFactory, trustManager)
// hostnameVerifier は差し替えない(標準の検証を維持)
.build()
ポイントは、「証明書警告を無視」や「ホスト名検証を無効化」といった危ない近道を一切使っていないこと。信頼範囲はこのアプリの通信だけに閉じ、Android全体や他アプリには影響しません。アプリのデータを消せばCAも接続設定も一緒に消えます。
実機結果は App-scoped CA registration: PASS。壊れたインストーラーを迂回して、正しくTLSを張れました。
4. 難関2: 既存の8443を壊さず、TV用に8444番を新設
もともとスマホ用に 8443番の自己署名HTTPSが動いていました。これを壊すわけにはいかないので、TV用に8444番を分離し、専用ローカルCAで署名したサーバー証明書で待ち受けるようにしました。
- スマホ用8443(従来の
server.crt/server.key)はそのまま - TV用8444は、あいくん専用ローカルCA(
aikun-ca)が署名した証明書 - PCのLAN IPが変わったときは、CAは維持したままサーバー証明書だけ自動再発行(SANにIPを入れ直す)→ TVへCAを再登録しなくてよい
「既存を壊さず、新しい経路を横に足す」形にしたことで、移行が安全になりました。
5. 難関3: ペアリングURLで秘密を漏らさない
ペアリングは GET /pair?token=... で署名Cookie(aikun_device)を受け取り、同じoriginの WSS /ws へCookieつきでつなぐだけのシンプルなものにしましたが、トークンを画面・ログ・履歴に残したくない。
ところがここでもうひと悶着。Sony IMEが、ADBの input text や英字KEYCODEを受け付けない。テスト中に長いペアリングURLを流し込む手段が塞がれました。
そこで実機試験ビルド(braviaTest)限定で、次のようにしました。
- PCローカルAPIからペアリングURLをメモリ内だけで取得
- ADBの
am start --esで一時的なIntent extraとして渡す -
MainActivityは受け取ったらextraを即座に削除してペアリング - URL/tokenをコンソール・ファイル・ADBの表示に一切出さない
- release/debugビルドではこの経路自体を無効(
BuildConfigフラグでゲート)
配布ビルドには載せない、テスト専用の裏口として割り切っています。
6. TV↔PCプロトコル(WSS)
状態は type を持たない完全スナップショットとして流します。再接続時はこのスナップショット一発で画面を復元できるようにしました(差分同期にしない)。
{
"state": "sleeping|idle|listening|thinking|speaking",
"expression": "neutral|happy|joy|surprised|sleepy|blush",
"text": "字幕",
"questions": [{ "q": "しつもん" }],
"feelings": [{ "name": "うれしい", "image": "/contents/..." }]
}
音声は「メタ情報 → バイナリ本体」の順で送ります。
{ "type": "audio_prepare", "audio_id": "...", "generation": 12 }
(続くWebSocketバイナリ)
AIKUNAV1 8 bytes ASCII
generation uint32 little-endian
WAV 残りのバイト
TV側は started / ended / 失敗時 error を返し、generation より古い音声は再生しません。停止時は準備中・再生中の音声を破棄します。
また、「入力する端末」と「音声を鳴らす端末」を分離する audio_sink_v1 を入れました。スマホやPCで発話しても、返答音声はTVで鳴らせます。
7. ハマりどころ: PC再起動後に「音が出ない」
一通り動いた後で見つかった地味なバグ。PCサーバーを再起動すると、再接続はできるのに最初の音声だけ鳴らない。
原因は、サーバー側で音声の世代番号(generation)が0から振り直されたのに、TV側が「前のセッションの続き」として持っていた大きい世代番号と比較し、新しい音声を**「古い」と誤判定**して捨てていたこと。
対策は、新しいWSS接続が確立するたびに、pending音声・再生中トラック・世代番号を接続単位でリセットする処理を入れること。地味ですが、「再接続後の1発目が無音」という体験を潰せました。
8. おまけの発見: 本体マイクが無いのに、リモコンのマイクが取れた
TVから直接話しかけられたら便利だな、と思って調べ始めたのですが、ここが一番面白かったところです。
braviaTest 限定でマイク診断画面を作り、実機で計測しました。
-
PackageManager.FEATURE_MICROPHONE→ なし -
AudioManager.getDevices(GET_DEVICES_INPUTS)→TV_TUNER/HDMI/LINE_ANALOGの3つだけで、マイク種別のデバイスが無い -
AudioSource.MICでAudioRecordを作ると初期化失敗 - ところが
AudioSource.VOICE_RECOGNITIONだと成功し、音量に反応する16kHzの生PCMが取れた(声を出すとRMS/ピークが跳ね、静かだと下がる)
「本体マイクがあるのか?」と疑って Sony公式ヘルプで確認したところ、本体マイクを使う(本体マイク搭載モデルのみ) とあり、この機種は本体マイク非搭載。つまり消去法で、取れていた音声は付属Bluetoothリモコンのマイクでした。
- リモコンのMICボタン(音声検索)を押すとGoogleアシスタントが起動してアプリは背景に回る
- 一方、MICボタンを押さずに
VOICE_RECOGNITIONでAudioRecordすると、第三者アプリでもリモコンのマイク音声が生PCMで取れる - リモコンのLEDが光らないのは、LEDがアシスタント側の制御で、生PCM取得(AudioRecord)では点かないため
判定としては「第三者アプリがリモコンのマイクを生PCMで使える(近距離集音)」。リモコンを子どもの手元に置く運用が前提になります。
子ども向けの「タップ発話」UI
発語支援が必要な子どもが使うので、UXはかなり作り込みました。
- 押して話す: 「はなす」ボタン → 「はなしちゅう」→ 話す
- 自動で戻る: PC側が発話を認識して考え始めたら、自動でマイクOFF、ボタンは「はなす」に戻る
- フォーカス固定: 会話中はマイクボタンにフォーカスを当て続け、「とめる」ボタンには奪われない
- 合図音: 子どもの番になったら、やさしい2音のチャイム(その場で合成)で発話を促す
- エコー回避: あいくんが話している間はマイク送信を止める(TVが自分のVOICEVOX出力を拾わないように)。合図音も録音に混ざらないようゲート
PCへ送るフレームは、ブラウザ実装と同じ float32・16kHz mono・1600サンプル(6400バイト)単位にそろえ、{"type":"mic","on":true} → サーバーの mic_state:on を待ってから送信開始、という握手も再現しました。
なお、このマイク機能は実機試験ビルド限定(BuildConfig フラグ + RECORD_AUDIO を試験ビルドのManifestにのみ宣言)で、配布ビルドには入れていません。
9. プライバシー設計(子ども向けなので最優先)
- 字幕・音声・プロフィールをTVに永続保存しない
- WAVはメモリ内で再生し、キャッシュファイルを作らない
- 絵カード等の画像もメモリ内のみで表示し、保存しない(取得は「ペアリング先と同一ホストのHTTPS」に限定)
- ログに字幕・音声・トークン・Cookieを出さない
- 字幕やペアリングURLがスクリーンショットに残らないよう
FLAG_SECUREを有効化 - Androidバックアップを無効化
- 保存するのは「サーバーの基点URL」と「署名済み端末Cookie」だけ。設定削除でどちらも消える
「便利だけど、子どもの声や会話がどこかに残る」を避けるのが、この手のアプリでは一番大事だと考えています。
10. 実機で動いたもの
- スマホ/PCの音声入力 → PC処理 → TVに選択肢表示 → リモコンのD-padで選択 → PCへ結果送信
- VOICEVOX音声をTVスピーカーで再生、字幕更新、表情変化
- 発話途中の停止と、停止後の会話復帰
- PCサーバー再起動 → TVが自動でWSS再接続(再ペアリング不要)
- Cookie失効 → ペアリング要求へ安全に戻る → 新URLで再ペアリング回復
- 絵カード・きもち画像のTV表示(メモリ内のみ)
- TVマイク(リモコン)でのタップ発話(近距離集音・合図音つき)
11. 考察と展望: 「ローカルLLM × TVインターフェース」という構成の手ごたえ
技術的なディテールよりも、いちばん伝えたいのはここです。この構成の手ごたえが、思いのほか良かった。
- AIの重い処理(STT・ローカルLLM・音声合成)は常時稼働のPC側に置き、TVは薄い表示・入出力クライアントに徹する
- ローカルLLMなのでクラウドに依存せず、子どもの声や会話を外に出さずに済む(プライバシー面でも都合がよい)
この「AIはサーバー、TVはインターフェース」という分離が、想像以上にしっくりきました。
なぜTVとAIは相性が良いと感じたか
- TVは家の中心にあって、家族の誰もが見る大画面。離れた位置からでも顔・字幕・選択肢が見やすい
- 付属リモコン(D-pad+マイク)という、すでに家にある入力手段がそのまま使える
- 「話しかける・選ぶ・見る」がリビングで完結する
あいくんは発語支援AIとして作っていますが、同じ構成は、たとえばホームオートメーションのAIコンシェルジュのUIにもそのまま応用できそうだ、という愚考と希望を持っています。「今日の予定は?」「エアコンつけて」「録画を見せて」といった“家の司令塔”を、TVの大画面と音声で扱う――というのは、スマートスピーカー単体よりも情報量を出せて、家族で共有もしやすい。TVとAIは、意外と本命の組み合わせなんじゃないかと感じています。
コスト面の期待
TV本体にAI機能(大きなメモリやNPU)を積もうとすると、どうしても高価になります。一方この構成なら、
- AIは別の常時稼働マシン(ミニPCやサーバー)に集約し、TVは薄いクライアント
- だから手持ちの(少し古い)TVでも導入できる(今回まさにAndroid TV 10の型落ち機で動いた)
- 賢さを上げたいときはサーバー側だけ強化すればよく、TVを買い替えなくても拡張が効く
「AIを各家電に内蔵して単価を上げる」のではなく、「AIは一箇所に集約して、家中のインターフェース(まずはTV)から使う」という方向は、導入のしやすさと拡張性の両面で、けっこう可能性があるんじゃないか――というのが、今回いじってみての率直な感想です。
12. まとめ・学び
- 家電のファームウェアは平気で機能が欠けている。「証明書インストーラーが動く」前提を捨て、アプリ専用の信頼ストアに逃がすことで、危ない近道(検証無効化)を使わずに解決できた。
- 既存を壊さず横に足す(8443はそのまま、8444を新設)と移行が安全。
- 再接続まわりは世代番号・状態スナップショットの設計が肝。「再接続後の1発目が無音」のような細かい体験劣化は、接続単位のリセットで潰す。
-
やってみないと分からないの代表がマイク。「本体マイク非搭載」なのに
VOICE_RECOGNITIONでリモコンのマイクが取れる、というのは公式ドキュメントと実機計測を突き合わせて初めて確信できた。 - 子ども向けは、保存しない・録音を促す合図・明示的な開始/停止・非AIの代替経路を最初から設計に入れるのが結局いちばん効く。
「古いAndroid TVを、子ども向けAIの表示・音声端末として安全に使い倒す」ための知見として、どなたかの役に立てば幸いです。
(この記事は個人開発の実装メモです。特定の製品の仕様は機種・ファームウェアで異なる場合があります。証明書やマイクの挙動は自分の環境での実測に基づきます。)
