環境
- Android(minSdk 26 以上推奨)
- Kotlin 1.9+
-
AudioRecord(16bit PCM、16kHz、モノラル) - Silero VAD(ONNX Runtime 経由)
問題:会議室で動いた閾値が介護施設フロアで即死した
音声AIアプリを介護施設のデイサービスフロアに持ち込んだ初日、Logcat にこのようなログが流れ続けた。
// 誤検知の例(サーバーへの送信ログ)
VoiceAssistantService: WAV 送信 → STT 開始
STT: "令和の経済政策についてですが" ← テレビのニュース音声
STT: "次の天気予報をお伝えします" ← NHK の夕方ニュース
STT: "100円で3点セットもらえます" ← 通販番組の音声
全サーバー送信の 40% 以上がノイズ起因だった。
介護施設フロアには 3 種類の環境ノイズが常時存在している。
| ノイズ種別 | 特徴 |
|---|---|
| テレビ | 終日稼働。ニュース・ドラマ・通販番組が会話リズムで流れる |
| 他スタッフの会話 | 「シーツ替えて」「申し送り 5 時ね」など自然言語 |
| 入居者の呼びかけ | 「ちょっと来て」など。音声AIへの質問ではない |
この問題に対してRMSエネルギーゲートを実装したが、適切な閾値を見つけるまでに3段階の失敗を経た。本稿はその試行錯誤の記録だ。
RMSエネルギーゲートとは
RMS(Root Mean Square / 二乗平均平方根)エネルギーは、音声フレームの「音量の強さ」を1つの数値で表す指標だ。
計算式はシンプルで、各PCMサンプルの二乗平均の平方根を取るだけ。
// RMS エネルギーの計算 — AudioRecorderManager.kt の recordLoop() 内
// buffer: ShortArray(AudioRecord から取得した 16bit PCM フレーム)
val rms = kotlin.math.sqrt(
buffer.fold(0.0) { acc, sample ->
// 各サンプルを Double に変換して二乗し、累積加算する
acc + sample.toDouble() * sample.toDouble()
} / buffer.size // サンプル数で割って平均を取る
)
// 閾値未満のフレームはパイプラインに流さず捨てる
if (rms < ENERGY_GATE_RMS_THRESHOLD) {
gatedFrameCount++ // デバッグ用カウンタ(ゲートされたフレーム数)
} else {
audioRouter.routeFrame(buffer, frameCount) // VAD へ転送
}
RMSゲートの利点は計算コストが極小なことだ。1フレームあたりの処理は加算と平方根だけで完了する。フレームレートが高くてもリアルタイム処理に影響しない。
問題は「どの閾値を設定するか」だ。ここで3段階の失敗が起きた。
閾値 300 の大失敗
最初に設定した閾値は 300 だった。この数字には根拠があった。
会議室でマイクを口元に近づけて発話したとき、RMS は 800〜3000 程度だった。「環境ノイズのRMSは高くても100程度だろう、余裕を見て300に設定すれば通常発話は全て通る」と判断した。
// 第1版 — 机上設計値(動かなかった)
companion object {
// 会議室での発話 RMS 800-3000 を根拠に設定 → 実機で即死
private const val ENERGY_GATE_RMS_THRESHOLD = 300.0
}
実機テストの初日、ほぼ全フレームがゲートされた。音声は一切サーバーに届かなかった。
原因はデバイス本体マイクの収音レベルが口元マイクと大幅に異なることだった。
本システムはイヤホンマイクなしで、Androidデバイス本体のマイクに話しかける運用を想定していた。口元との距離が離れるため、収音レベルが会議室テストと比べて大幅に低い。実測してみると、通常発話のRMSは 50〜200 しかなかった。
閾値 300 は発話のピーク値をも超えていた。「発話」も「ノイズ」も同じようにゲートしていた。
閾値 20 でも不十分だった
300 の失敗を受けて、実測値(通常発話 50〜200)の下限である 50 を大きく下回る 20 に変更した。
// 第2版 — 実機失敗後の修正値(まだ不十分だった)
companion object {
// 通常発話の最小値 50 を根拠に「余裕を見て 20」と設定
// → 環境ノイズが想定より低く、まだテレビ音声が通過した
private const val ENERGY_GATE_RMS_THRESHOLD = 20.0
}
発話は問題なく通過するようになったが、誤検知は根絶されなかった。
現場でノイズのRMSを実測すると、以下の数値が出た。
| 音の種類 | 実測 RMS(デバイス本体マイク) |
|---|---|
| 静寂時の電気的ノイズ | 1〜3 |
| 環境ノイズ(空調・外部音) | 3〜7 |
| テレビ(通常音量) | 5〜12 |
| テレビ(音量大) | 10〜18 |
| ささやき声(デバイス本体マイク) | 15〜40 |
| 通常発話(デバイス本体マイク) | 50〜200 |
テレビの音量が大きい状態では RMS が 18 に達することがあった。閾値 20 との差は 2 しかなく、音量のわずかなばらつきでテレビ音声が通過してしまった。
閾値 5 で安定した理由
「もう少し下げれば完全にノイズを遮断できる」と考え、5 に設定した。
// 第3版(最終値)
companion object {
// デバイス本体マイクでの実測値に基づく閾値
// 環境ノイズ: RMS 3-10(大半は 3-7)
// ささやき声: RMS 15-40
// 通常発話: RMS 50-200
// → 5 は「環境ノイズの大半を遮断しながら、ささやき声を通す」ギリギリのライン
private const val ENERGY_GATE_RMS_THRESHOLD = 5.0
}
// フレーム単位のゲート処理(最終版)
val rms = kotlin.math.sqrt(
buffer.fold(0.0) { acc, sample ->
acc + sample.toDouble() * sample.toDouble()
} / buffer.size
)
if (rms < ENERGY_GATE_RMS_THRESHOLD) {
gatedFrameCount++ // ゲートされたフレームは後段に流さない
return@forEach // 次フレームへ
}
// 閾値以上のフレームのみ VAD へ転送する
audioRouter.routeFrame(buffer, frameCount)
RMS 5 という値は偶然に選んだ数字ではない。環境ノイズの分布を計測した結果に基づいている。
- テレビ音声(通常音量): RMS 5〜12 → 閾値 5 で多くを遮断できる
- ささやき声: RMS 15〜40 → 閾値 5 で確実に通過する
- 通常発話: RMS 50〜200 → 閾値 5 で確実に通過する
閾値を 10 にするとテレビ音声の一部が引き続き通過する。3 にすると電気的ノイズ(RMS 1〜3)が増加する。5 は「ノイズ遮断」と「ささやき声の通過」を両立するギリギリのラインだった。
閾値チューニングのデバッグ方法
閾値を決めるために実装したデバッグログは、計測フェーズで非常に役立った。
// デバッグ専用の RMS ヒストグラム収集 — 開発時のみ有効にする
// BuildConfig.DEBUG フラグで本番ビルドでは無効化する
private fun logRmsHistogram(rms: Double, isGated: Boolean) {
if (!BuildConfig.DEBUG) return
// バケットは実測値の分布を想定して設計する
val bucket = when {
rms < 3 -> "0-3(電気的ノイズ)"
rms < 5 -> "3-5(弱い環境ノイズ)"
rms < 10 -> "5-10(テレビ・空調)"
rms < 20 -> "10-20(テレビ大音量)"
rms < 50 -> "20-50(ささやき声)"
rms < 200 -> "50-200(通常発話)"
else -> "200+(大声・衝撃音)"
}
Log.d(
"RMSGate",
// ゲート状態と RMS 値をバケットと合わせてログ出力する
"[${if (isGated) "GATED" else "PASS "}] RMS=${"%.1f".format(rms)} bucket=$bucket"
)
}
実際のLogcatからのサンプル(閾値 5 設定時):
RMSGate: [GATED] RMS=2.3 bucket=0-3(電気的ノイズ)
RMSGate: [GATED] RMS=4.8 bucket=3-5(弱い環境ノイズ)
RMSGate: [GATED] RMS=7.1 bucket=5-10(テレビ・空調)
RMSGate: [PASS ] RMS=23.4 bucket=20-50(ささやき声)
RMSGate: [PASS ] RMS=87.6 bucket=50-200(通常発話)
このログを数分間収集すると、環境ノイズと発話の分布が可視化できる。閾値を「GATED の最大値と PASS の最小値の中間」に設定するのが基本戦略だ。
ハマりどころと注意点
閾値はデバイスとマイク使用方法に依存する
RMS 5 という値は本システムの特定のデバイス・運用方法での実測値に基づいている。以下の条件が異なると、適切な閾値も大きく変わる。
| 条件の違い | 閾値への影響 |
|---|---|
| イヤホンマイク使用 | 口元に近いため収音レベルが高い → 閾値を大きく上げる必要がある |
| 高感度外付けマイク | 同上 |
| デバイスの世代・モデル | マイク性能差が数倍〜10倍のRMS差を生む |
| 取り付け位置(胸元 vs 腰) | 距離が遠いほど発話RMSが下がる |
必ず実機・実環境で計測してから閾値を決定すること。 他のプロジェクトの値を流用するのは危険だ。
buffer のデータ型に注意する
AudioRecord が返す ShortArray(16bit PCM)の値域は -32768〜32767 だ。Float にキャストしてから二乗すると精度が落ちる可能性があるため、Double で計算する。
// 精度を保つため Double で計算する(Float では桁落ちの可能性がある)
val rms = kotlin.math.sqrt(
buffer.fold(0.0) { acc, sample ->
// sample は Short → Double に変換してから二乗する
acc + sample.toDouble() * sample.toDouble()
} / buffer.size
)
フレームサイズとRMS値の関係
AudioRecord の bufferSize パラメータとフレームサイズは別物だ。実際にRMS計算に使うフレームのサンプル数(buffer.size)が小さすぎると、RMS値の変動が大きくなり閾値判定が不安定になる。
Silero VAD の推奨フレームサイズ(512 サンプル / 16kHz)に合わせると、RMS計算の安定性も同時に確保できる。
結果
RMSゲートの閾値を 300→20→5 と修正した結果、ノイズ起因のサーバー送信比率が大幅に改善した。
| 状態 | ノイズ起因の誤送信 | 正常な送信 |
|---|---|---|
| RMS ゲートなし | 40% 以上 | 60% 未満 |
| RMS 300(全フレームゲート) | 送信ゼロ(発話も届かない) | - |
| RMS 5(最終値)+ 後段フィルタ | 3% 未満 | 97% 以上 |
RMSゲート単体では 30% 程度のノイズが残るが、後段のVAD(Silero VAD)・サーバーサイドSNRチェック・Voskフィルタと組み合わせることで 3% 未満まで抑えた。
まとめ
- 閾値の根拠は実測値のみ。机上で設計した値(RMS 300)は実機テスト初日に全フレームをゲートし動作不能になった
- デバイス本体マイクの収音レベルは口元マイクより大幅に低い。会議室テストのRMS値を実運用の閾値に使い回すのは危険
- 閾値のチューニング手順: まずRMSヒストグラムを収集し、環境ノイズと発話の分布を可視化してから中間値を設定する
- RMSゲートは計算コストが極小なため、フレーム単位のリアルタイム処理に適している。精度の不足は後段のVADやフィルタで補完する設計にする
全体像はnoteに → https://note.com/yamashita_aidev/n/nb325c29c62b3