話し言葉をきれいな文字列へ: Inverse Text Normalization の仕組み
音声認識モデルが出力するのは単語列です。しかし、 「the meeting is on January fifteenth at two thirty PM and the budget is three thousand dollars」と口述したとき、見たいのはそのままの単語列ではありません。 欲しいのは 「The meeting is on January 15th at 2:30 PM and the budget is $3,000.」 です。
この、話し言葉の形式を書き言葉の形式へ変換する処理を inverse text normalization、略して ITN と呼びます。text-to-speech が書き言葉を話し言葉へ変えるのに対し、その逆です。そしてこれは、 正しく動いていると存在を意識しないのに、壊れると非常に苛立つタイプの機能です。
ITN が扱うもの
ITN がカバーする変換は、多くの人が思っているより広い範囲に及びます。
- 数値。 "forty two" → "42", "three point one four" → "3.14", "negative seven" → "-7"
- 通貨。 "three thousand dollars" → "$3,000", "fifty euros" → "€50"
- 日付と時刻。 "January fifteenth twenty twenty six" → "January 15th, 2026", "two thirty PM" → "2:30 PM"
- 序数。 "the third item" → "the 3rd item"
- 単位。 "five kilometers" → "5 km", "twenty degrees celsius" → "20°C"
- 句読点。 "comma" → ",", "period" → ".", "question mark" → "?"
- 音声コマンド。 "new line" → 実際の改行, "colon" → ":"
個別に見ればどれも単純そうに見えます。複雑さは曖昧さから来ます。 "one" は数値の 1 なのか、代名詞なのか。"May" は月なのか、 助動詞なのか。"dash" はハイフンなのか、それとも単語としての "dash" なのか。
なぜ自前エンジンを作ったのか
多くの ASR プロバイダは、クラウドのパイプライン内に基本的な ITN を含めています。しかしオンデバイス処理をやるなら、ITN も オンデバイスで動かなければ意味がありません。ところが選択肢は限られています。 既存のオープンソース ITN ライブラリは、たいてい Python ベースで、 バッチ処理向けで、英語中心です。
私たちに必要だったのは別のものです。
- リアルタイム性能。 ITN はストリーミング文字起こしの 各チャンクで走ります。ミリ秒ではなくマイクロ秒単位で処理できる必要があります。
- CJK 対応。 中国語と日本語は、数の体系、句読点、 書式規則がまったく違います。「三千美元」も "three thousand dollars" と同じように "$3,000" へ変換できなければなりません。
- ネイティブ統合。 Python ランタイムを同梱したり、サブプロセスを経由したりせず、 Swift 製 macOS アプリ内でそのまま動くライブラリが必要でした。
有限状態トランスデューサ
私たちの ITN エンジンは finite state transducer、つまり FST の上に作られています。FST は入力列を読みながら出力列を生成する 状態機械です。ITN では、入力は話し言葉の単語列、出力は正規化された 書き言葉です。
FST が regex や単純なルールベース文字列置換より優れている点は、 合成可能性です。基数詞用、日付用、通貨用といった小さな FST を個別に作り、それらを合成して、ルールが衝突したときも決定的な優先順位を持つ 単一のトランスデューサにまとめられます。
FST ランタイムライブラリである libfst は Zig で書きました。 この用途で重要なのは、C ABI 互換性 (ライブラリを Swift アプリに直接リンクできる)、状態遷移に対する ゼロコスト抽象化、そしてメモリアロケーションの精密な制御です。 リアルタイムなテキスト処理の途中で、GC 停止を挟みたくありませんでした。
ルールコンパイルのパイプライン
ITN ルール自体は Python で、宣言的な変換仕様として記述しています。 各ルールはパターン(入力語列)と置換結果(出力テキスト)を定義します。 Python ツールチェーンがそれらをバイナリ FST ファイルへコンパイルし、Zig ランタイムがそのコンパクトで最適化済みの 表現をロードして実行します。
この分割により、ルール側は素早く改善できます。言語パターンを書くのは Python が得意です。一方でランタイムは高速なままです。新しい数値形式や 通貨記号を増やすなら、Python のルールファイルを編集して再コンパイルするだけ。 ランタイムのバイナリ自体は変わりません。
現在は中国語、日本語、英語向けのコンパイル済み FST ルールファイルを同梱しています。正規化規則がかなり異なるため、 各言語に専用ルールが必要です。日本語では全角句読点や助数詞があり、 中国語の数体系は 1,000 区切りではなく 10,000 区切りです。英語には 序数や 12 時間表記固有の癖があります。
音声コマンド
ITN は音声コマンドも扱います。これは、話し言葉が文字列ではなく キーボード操作に対応するケースです。ディクテーション中に "new line" と言ったら欲しいのは改行であって、 "new line" という文字列ではありません。"comma" と言ったら、 欲しいのは句読点です。
ここには面白い曖昧性があります。"new line" が音声コマンドなのか、 それとも "this is a new line of products" のような文の一部なのかを 判断しなければなりません。私たちは文脈で処理しています。音声コマンドは 特定の構文位置(たとえばポーズの直後や節境界)で認識しやすく、 FST の優先順位付けによって、曖昧な場面ではコマンド解釈を優先します。
CJK 言語では音声コマンドもローカライズされます。中国語話者は改行に 「换行」、カンマに「逗号」と言います。FST ルールは言語ごとに分かれているため、各ロケールに自然なコマンド語彙を 持てます。
目立たない機能こそ重要
良い ITN は目立ちません。自然に口述すれば、見た目が正しい文字列になる。 数値は数値になり、日付は日付になり、句読点はあるべき場所へ入る。 「$three thousand」を手で直したり、文中の "new line" という生文字列を削除したりし始めた瞬間に、その錯覚は壊れます。
私たちは、完璧に動いていると誰にも気づかれない機能に、 かなりの工学的労力を注いでいます。それで正しいと思っています。 最高の音声入力体験とは、「今はタイピングしていない」と 忘れられる体験だからです。
OnType を試す — 英語、中国語、日本語向けの 賢いテキスト正規化を備えたオンデバイス音声入力です。