macOS 26 の新音声認識 API SpeechTranscriber を試す

初めに

macOS 26 / iOS 26 で Speech framework に SpeechAnalyzerSpeechTranscriber が追加されました。Apple の説明では Notes・ボイスメモ・Journal といった純正アプリが使っているモデルがそのまま開放されたもので、完全オンデバイス・通信量ゼロ・モデルのダウンロード実装も不要です。

developer.apple.com

developer.apple.com

SpeechTranscriber とは

SFSpeechRecognizer SpeechTranscriber
実行場所 端末 or Apple サーバー 完全オンデバイス固定
発話長の上限 実質 1 分程度 なし
音声認識の TCC 権限 必要 不要
結果の型 String AttributedString(信頼度・時間範囲つき)
対応ロケール 63 30(DictationTranscriber は 43)
必要 OS iOS 10 〜 macOS 26 / iOS 26 以降

開発環境

  • macOS 26.2 (25C56)
  • Xcode 26.0.1
  • Swift 6.2 / SDK は MacOSX26.sdk
  • Apple M4 Max (64GB)

動かしてみる

SwiftPM パッケージも Xcode プロジェクトも作らず、単一ファイルの @mainswiftc に直接渡す形で試しました。

xcrun --sdk macosx swiftc -parse-as-library -swift-version 6 transcribe-file.swift -o /tmp/transcribe
/tmp/transcribe path/to/audio.wav ja-JP

-parse-as-library@main を使うために必須です。あと xcrun --sdk macosx を付けないと、素の swiftc が CommandLineTools 側の SDK を見に行って失敗しました。テスト音声は say -v Kyoko -o out.aiff "テキスト" ですぐ作れます。

中身は SpeechAnalyzerSpeechTranscriber を挿して音声を流し込むだけです。

guard let locale = await SpeechTranscriber.supportedLocale(
    equivalentTo: Locale(identifier: "ja-JP")) else { return }

let transcriber = SpeechTranscriber(
    locale: locale,
    transcriptionOptions: [],
    reportingOptions: [.volatileResults],
    attributeOptions: [.audioTimeRange, .transcriptionConfidence]
)

// アセットが無ければ取得する。入っていれば 1 秒未満で返る
if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
    try await req.downloadAndInstall()
}
_ = try await AssetInventory.reserve(locale: locale)

let analyzer = SpeechAnalyzer(modules: [transcriber])

// 結果の収集は analyzeSequence より「先に」立てる
let consumer = Task { () -> [String] in
    var finals: [String] = []
    for try await result in transcriber.results where result.isFinal {
        finals.append(String(result.text.characters))
    }
    return finals
}

_ = try await analyzer.analyzeSequence(from: AVAudioFile(forReading: url))
try await analyzer.finalizeAndFinishThroughEndOfInput()
let finals = try await consumer.value

ハマったところ

ハマったこと 対処
結果が空になる transcriber.results は解析中に流れるので、収集タスクは analyzeSequence より先に立てる
最後の結果が出ず、ループも終わらない finalizeAndFinishThroughEndOfInput() を必ず呼ぶ
sending risks data races で落ちる Swift 6 の strict concurrency。Task { () -> T in ... return ... } の戻り値で受ける
アセットが入っているのに .supported が返る AssetInventory.status ではなく、assetInstallationRequest(supporting:)nil かどうかで見る
結果が String で取れない AttributedString なので String(result.text.characters)。信頼度と時間範囲は run の属性
マイク入力が実行時に SIGTRAP で即死する 下記

マイクだけは少し厄介でした。ファイル入力が動いたので同じノリで書いたら、コンパイルは通るのに再生した瞬間に落ちます。

libdispatch           _dispatch_assert_queue_fail
libswift_Concurrency  _swift_task_checkIsolatedSwift
<自分のバイナリ>       closure #1 in static Main.main()
AVFAudio              AVAudioNodeTap::TapMessage::RealtimeMessenger_Perform()

@mainstatic func main() は MainActor 隔離されるので、installTap に渡したクロージャに隔離チェックが挿入され、実時間オーディオスレッドから呼ばれた瞬間にアウト、という理屈でした。回避には nonisolated static func main() にする・クロージャを { @Sendable buf, _ in ... } にする・状態を Sendable なクラスに閉じ込める、の 3 つが全部必要です。

参考

API の一次情報は SDK 同梱の .swiftinterface を読むのが早いです。公式 Web ドキュメントは JS レンダリングで取得しづらいことが多いので、こちらを直接見ていました。

/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/Speech.framework/Versions/A/Modules/Speech.swiftmodule/arm64e-apple-macos.swiftinterface

使ったコーパスは次の 2 つです。音声と参照テキストは再配布条件があるため、この記事には結果の数値と、説明に必要な範囲の引用だけを載せています。

tyc.rei-yumesaki.net

huggingface.co

英語のストリーミング音声認識モデル moonshine-streaming-small を日本語化して kodama-ja-streaming-small を作成する

初めに

日本語のリアルタイム音声認識を自分のアプリに載せようとすると、毎回同じところで詰まります。 CPU でオフラインに動く・確定までの遅延が短い・商用利用できる、この3つが同時に揃った選択肢が薄いのです。

モデル サイズ ストリーミング ライセンス
openai/whisper-large-v3 1.54B params Apache-2.0
reazon-research/reazonspeech-nemo-v2 RNN-T・未検証 Apache-2.0
moonshine-base-ja(公式日本語) 61.5M params Moonshine Community License(非商用・撤回可能)
vosk-model-ja-0.22 1GB Apache-2.0
vosk-model-small-ja-0.22 48MB Apache-2.0
moonshine-streaming-small(英語) 140.1M params MIT

Whisper 系は精度も汎用性も強いのですが、大きいうえにそもそもネイティブなストリーミング ASR ではありません。 音声インターフェースのループに入れるには重すぎます。

Moonshine は逆側に振った設計で、Encoder の受容野が有界なので確定遅延を詰められます。 表の最終行がそれで、3つの条件をすべて満たしています。ただし英語専用です。

公式には日本語版(moonshine-base-ja / moonshine-tiny-ja)も出ていますが、こちらは Moonshine Community License(非商用・撤回可能)扱いになります。同じ土俵に立てるオープンな選択肢は 実質 Vosk だけ、という状態でした。

なお reazonspeech-nemo-v2 の遅延はこちらでは測っていません。CER は測りましたが、 ストリーミング動作と確定遅延は未検証なので「○」とは書けませんでした。

そこで、MIT で公開されている英語版 moonshine-streaming-small を日本語で追加学習し、 Apache-2.0 で公開することにしました。

作ったもの

huggingface.co

項目
パラメータ数 140,135,225(実測。ベースのモデルカード表記は 123M)
ベースモデル moonshine-ai/moonshine-streaming-small(MIT・英語)
学習データ ReazonSpeech v2 全量 35,000 時間 × 2 epoch(CDLA-Sharing-1.0)
配布形式 safetensors + ONNX / ORT deploy 資産
ONNX deploy サイズ 324MB(.ort は 319.6MB)
offline RTF 0.19(CPU)
ライセンス Apache-2.0

総合力では公式日本語 Moonshine base に届いていません。勝っているのは 感情表現・キャラクター音声・対話であって、汎用(FLEURS)・短発話・固有名詞・雑音では負けています。 特に雑音が最大の弱点で、SNR 10dB の条件では CER が +0.1033 も悪いです。

一方、同じ土俵(CPU オフライン・ストリーミング・パーミッシブライセンス)にいる Vosk に対しては、 48MB 版に測定 6 セット全勝、1GB 版にも 6 セット中 5 セットで優位に立てました。

向いている用途

  • キャラクターボイス・感情表現のある音声の書き起こし
  • 音声対話アプリのリアルタイム入力(CPU のみ・クラウド送信なし)
  • 商用プロダクトへの組み込み(Apache-2.0)
  • 学習データの由来を説明する必要がある案件(ReazonSpeech v2 単独で、素性が開示できます)

向いていない用途

  • 雑音のある環境(最大の弱点です。SNR 10dB で CER 0.2363)
  • ニュース読み上げのような汎用・高精度用途(FLEURS では公式日本語 Moonshine に負けます)
  • 固有名詞や短い発話が多い用途
  • 確定までの遅延がシビアな用途(Vosk big のほうが速いです。遅延の節を参照してください)
  • 無音区間で何も出力してほしくない用途。hallucination_rate が 0.10〜0.12 あり、 学習データを 35倍にしても値が変わらなかったので、VAD と組み合わせる前提で使ってください。

使い方

配布しているのは ONNX / ORT の deploy 資産です。3グラフで1セット・1モデルになっています。

  • onnx/encoder.onnx + .onnx.data(207.9MB・float)— 音響エンコーダ
  • onnx/cross_kv_prefill.onnx + .onnx.data(32.5MB・float)— cross-attention KV の前計算
  • onnx/decoder_step_crosskv.int8a.onnx(83.1MB・int8)— 1ステップ decode

呼び出しは encoder → cross_kv_prefill → decoder_step の順で、最後の decoder_step を トークンごとに繰り返します。ストリーミングで使う場合は、encoder に有界文脈窓 (左 160 フレーム・右 16 フレーム)を与えて block ごとに回し、確定した分だけを凍結していきます。

.ort 版(ort/・計 319.6MB)はモバイル/組込み向けの事前最適化形式です。

⚠️ 同梱の .ort は公式 moonshine-voice の6グラフ束とは非互換です(3グラフ分割で I/O 名と cache レイアウトが異なります)。公式ランタイムにそのまま差し込むことはできません。

⚠️ 遅延や RTF は必ず ONNX Runtime 経路で測ってください。PyTorch / Transformers からも (MoonshineStreamingForConditionalGeneration で)読み込めますが、Transformers 実装は 効率的なストリーミング経路が未完成だとベースモデル側が明記しています。この記事の遅延値は すべて ONNX Runtime 経路の実測です。オフライン推論のコード例はモデルカードに載せています。

なぜ Moonshine Streaming Small を土台にしたのか

アーキテクチャ側の理由

Moonshine Streaming の Encoder は受容野が有界で、あるフレームの出力は 左 160 フレーム・右 16 フレームの範囲にしか依存しません。右 16 フレームが先読みにあたります。

この性質のおかげで、有界な文脈窓を切って逐次 encode しても full-utterance の結果と一致します。 実際に検証したところ、両者の差は max|Δ| で 4.8e-5 でした。streaming 化のために再 export が要らない という、実装上かなり効く特性です。

先読み量については注意が要ります。公式モデルカードは「約 80ms」と書いていますが、 自分の ONNX 実装で測ると右文脈 16 フレーム= 320ms でした。この記事の数値は後者です。

パラメータ数も同様で、モデルカードの 123M に対し、safetensors のヘッダから数えた実測は 140,135,225(262 テンソル・全 F32・560,571,228 bytes)でした。アーキテクチャも語彙も変えていないので、 日本語化後もこの数字は変わりません。

ライセンス側の理由

Moonshine の LICENSE は 1ファイルに2つのライセンスが同居しています。

The code in this repo ... is licensed under the MIT License. The English-language models are also released under the MIT License. See SECTION 1 for terms.

Models for other languages are released under the Moonshine Community License, which is a non-commercial license. See SECTION 2 for terms.

Community License は非商用・撤回可能で、Derivative Work の定義に fine-tune / LoRA / 量子化を 明示的に含みます。すると当然こういう論点が立ちます。

MIT の英語モデルを日本語で追加学習した派生物は、「他言語モデル」として Community License に落ちるのか?

落ちるなら、このプロジェクトは成立しません。結論としては落ちないと判断しました。根拠は3つあります。

  1. 文言の主語です。"Models for other languages are released under..." は、Moonshine AI が 自ら公開するモデルの分類を述べた文であって、第三者が MIT 許諾済みの重みから作った派生物に 後から条件を追加する文言ではありません。
  2. Community License 自身の適用範囲です。第V条の定義が "Moonshine AI Materials" ... made available under this Agreement と限定しています。 moonshine-streaming-small は MIT のもとで提供されていて、この Agreement のもとでは提供されていません。
  3. Moonshine AI 自身による明示的な除外です。Acceptable Use Policy にこう書いてあります。

    These restrictions do not apply to models that have been explicitly released by Moonshine AI under a different license, such as the MIT license.

    AUP は Community License 第 IV(b) 条から参照により本文へ組み込まれています。その AUP が MIT 公開モデルを適用対象外と明言している以上、権利者自身が「MIT で出したものは Community License 系の規律の外にある」と述べていると読めます。

学習データ側も確認しました。ReazonSpeech v2 は CDLA-Sharing-1.0 ですが、§3.5 が This Agreement imposes no obligations or restrictions on Your Use or Publication of Results と定めており、学習済み重み(Results)に義務は及びません。帰属表示は良い作法として NOTICE に書きましたが、 これは利用者に CDLA の条件を課すものではありません。

なお公式日本語 Moonshine は、初期重み・tokenizer・decoder のいずれにも一切使っていません。 ベンチマーク比較のためだけに動かしています。

監査中に、license: mit と宣言しながら実体は公式日本語モデル (Community License 系譜)の量子化物、というモデルが 4件見つかりました。Community License 第V条は Derivative Work に量子化を含むので、この表記は成立しません。HF の license タグは信用せず、 base_model を辿って根元の一次ライセンスまで遡る必要があります。

方法

1. 学習より先に評価を作った

最初に手を付けたのは学習ではなく評価ハーネスです。

  • 主指標は CER です(日本語なので WER で最適化しません)。正規化は学習側と評価側で単一実装を共有します。
  • CER だけでは気付けない失敗があるので、hallucination_rate(無音区間に何か出力する率)と 診断セット short / noise / propn(短発話・SNR 10dB 雑音・固有名詞)を足しました。
  • per-utterance のダンプを必ず取り、比較は paired bootstrap CI で判定します(n_boot=1000・seed=0)。 端点だけを見て「改善した」と書かない、というルールを自分に課しました。
  • 評価は原則1プロセスずつ。2プロセス並列で回すと CUDA error: unspecified launch failure で発話が落ち、 その分をモデルの性能として読みかけたことがあります。推論エラー件数を結果に残し、採用前に必ず見ています。

対象ドメインの評価セット(キャラクター音声・感情音声、以下 dom_g)も自前で組みました。 n=629・61.1 分・6話者を manifest hash で凍結してあります。音声は再配布できないので、 公開したのは集計値と hash だけです。

2. レシピは組み合わせたときだけ効いた

warmup の追加と cosine スケジューラへの変更を、単体と組み合わせで試しました。 単体ではどちらも無効で、両方入れたときだけ CER が 0.4131 → 0.4050 に動きます。効果は小さく、 この時点では「レシピはもう伸びしろがない」と判断していました。

3. ピーク LR が低すぎた

Phase 3 以降ずっと lr 2e-5 で固定していて、その値を一度も振っていませんでした。 検証していたのはスケジュールの形だけです。

medium(1,000時間)×3epoch・レシピ固定・batch 128 で、LR だけを振りました。

LR 1e-5 2e-5(従来) 4e-5 8e-5 1.6e-4 3.2e-4
FLEURS 0.4733 0.4050 0.3535 0.3109 0.2791 0.2710
dom_g 0.4330 0.3674 0.3246 0.3040 0.2671 0.2578

従来値の 8倍まで一貫して改善します。medium 1,000時間 × 高 LR は、データ量が 1/5 でありながら large 5,000時間 × lr 2e-5 を両セットで有意に上回りました (FLEURS Δ-0.0375 CI=[-0.0496, -0.0256]/dom_g Δ-0.0475 CI=[-0.0640, -0.0321])。

それまで「medium の頭打ちはデータ不足」と判断して large に進んでいましたが、これは誤診でした。 データ不足と LR 不足は同じ形の頭打ちを示すため、LR を振らない限り切り分けられません。

採用値は 1.6e-4 です。3.2e-4 とは両セットで有意差がなくプラトーに入るため、低いほうを採りました。 LR 修正後にデータを増やしても効果は残っており(5,000時間で FLEURS 0.2186)、 LR とデータは独立に効きます。

4. 35,000 時間まで伸ばした

最終的に ReazonSpeech v2 の全量 35,000 時間 × 2epoch まで回しました。公式日本語 Moonshine base との dom_g の差(正なら負けている)はこう動いています。

世代 dom_g の差 判定
large 5,000h × lr 2e-5(LR 修正前) +0.1582 有意に劣位
medium 1,000h × 高 LR +0.1106 有意に劣位
large 5,000h × lr 1.6e-4 +0.0684 有意に劣位
all 35,000h(最終) +0.0095 有意差なし

データ ×7 でも両セットで有意に改善しており(FLEURS Δ-0.0706/dom_g Δ-0.0588)、まだ飽和していません。

実行環境は vast.ai の RTX 5090 ×4 の DDP です。batch は 128 が最適で、192 は VRAM を 19→26GB 使うのに 6% 遅く、256 は OOM でした。VRAM が余っている=性能が余っている、ではないということです。 2.3TB の tar 前ロードは hf_cdn から並列 16 で引くと 580MB/s 出て、20 時間が 1.2 時間で済みました。

所要時間は事前見積 23.1 時間に対し、実測 86.75 時間でした。根拠にした「4GPU の並列効率 90%」は 80 step のスループット測定の値で、長時間ランでは再現しません(実効 847 audio-h/h = ほぼ単 GPU 相当)。 原因は特定できていません。短時間のベンチは長時間ランの前提には使えません。

ちなみに tokenizer はベースの英語版のままです(語彙 32,768)。日本語 tokenizer への差し替えも 試しましたが、次の節のとおり回収できませんでした。

効かなかった介入

介入 結果
音声データ拡張(SpecAugment 風+速度摂動) 有意差なし(FLEURS Δ+0.0033・dom_g Δ+0.0094、いずれも CI が 0 を跨ぐ)
label_smoothing 実用不可。全 logits を Python 側へ materialize する必要があり約14倍遅いため打ち切り
LM rescoring 前提が成立しない。カタカナ集中度がベースラインと CI 重複= gap は正書法由来ではなかった
日本語 tokenizer への差し替え 1,000時間では head 再初期化のコストを回収できず(維持 0.4194 < 差し替え 0.4639)
多様性データの混合(+Common Voice / +moe-speech) 汎用・対話がむしろ悪化(FLEURS 0.409→0.457・SpeechBSD 0.279→0.355)。in-domain だけ改善(JVNV 0.292→0.227)。⚠️ lr 2e-5 固定下の比較なので、正しい LR では未検証

音声データ拡張の「無効」判定には留保があります。これはクリーン音声での評価であって、 雑音が最大の弱点だと分かった今、雑音耐性の観点では再検証の余地があります。

評価

評価セットと測り方

8セット(fleurs / dom_g / propn / short / noise / speechbsd / jvnv_regular / jvnv_free)を、 同一ハーネス・同一正規化・貪欲デコードで測っています。すべて逐次実行で、n_errors=0 を確認済みです。 比較は paired bootstrap CI(n_boot=1000・seed=0)で判定しています。

なお dom_gjvnv は独立ではない(dom_g の 227 発話が JVNV 由来)ので、並べるときは 内訳を併記しています。

公式日本語 Moonshine base との比較

比較相手は moonshine-base-ja(61.5M・非ストリーミング・こちらの 44% のサイズ)です。 Δ は ours − baseline なので、負ならこちらが良いことになります。

評価セット n moonshine-base-ja ours Δ 95%CI 判定
jvnv_free 240 0.1121 0.0638 -0.0483 [-0.0698, -0.0271] ours が有意に優位(-43%)
jvnv_regular 800 0.1415 0.0968 -0.0447 [-0.0578, -0.0319] ours が有意に優位(-32%)
speechbsd 800 0.1320 0.1108 -0.0213 [-0.0363, -0.0069] ours が有意に優位(-16%)
dom_g 629 0.1472 0.1567 +0.0095 [-0.0063, +0.0245] 有意差なし
short 130 0.1168 0.1448 +0.0281 [+0.0046, +0.0517] ours が劣位
propn 248 0.1354 0.1695 +0.0341 [+0.0134, +0.0534] ours が劣位
fleurs 650 0.1091 0.1480 +0.0390 [+0.0286, +0.0515] ours が劣位
noise(SNR10dB) 400 0.1330 0.2363 +0.1033 [+0.0855, +0.1239] 最大の弱点

「公式日本語 Moonshine を超えた」とは言えません。唯一 dom_g が有意差なしまで来ましたが、 点推定では依然ベースラインが上(0.1472 対 0.1567)ですし、「有意差なし」は同等と区別できないという 意味であって優位ではありません。総合力ではまだ負けています。

雑音の弱さは構造的なものです。clean からの劣化率は ours が +60%、ベースラインが +22% で、 学習データ(ReazonSpeech)が比較的クリーンなことが効いていると考えられます。

「有意差なし」は話者間の相殺だった

dom_g の合計値だけを見ていると一様に同等に見えますが、話者ごとに分解すると打ち消し合っていました。

話者 n moonshine-base-ja ours Δ 95%CI 判定
あみたろ 302 0.1120 0.1908 +0.0788 [+0.0596, +0.0981] 有意に劣位
つくよみちゃん 100 0.2532 0.2879 +0.0347 [+0.0030, +0.0661] 有意に劣位
JVNV 227 0.1281 0.0644 -0.0637 [-0.0868, -0.0432] 有意に優位
合計 629 0.1472 0.1567 +0.0095 [-0.0063, +0.0245] 有意差なし

キャラクター音声の2話者では負けています。35,000 時間でも埋まりませんでした。合計値だけを出すと 実態より良く見えるので、モデルカードにもこの内訳を併記してあります。

もうひとつ留保があります。dom_g の素材は ITA / JVS の公開台本の読み上げで、比較相手は学習データを 開示していません。したがって汚染の可能性は排除できず、判定は fail-closed で UNKNOWN のままです。 差が縮んだ今、この留保の重要度はむしろ上がっています。JVNV だけ突出して良いのも、 汚染の疑いを強める方向の所見ではあります(どちらが汚染されているかは判定できません)。

Vosk との比較

同じ土俵(CPU オフライン・ストリーミング・パーミッシブライセンス・Apache 2.0)にいる直接競合が Vosk(Alpha Cephei・Kaldi hybrid)です。n を揃えて測りました。

モデル サイズ fleurs dom_g propn speechbsd jvnv_regular jvnv_free
ours 324MB 0.1480 0.1567 0.1695 0.1108 0.0968 0.0638
vosk-model-ja-0.22 1GB 0.1703 0.1776 0.2012 0.1303 0.1459 0.0747
vosk-model-small-ja-0.22 48MB 0.2248 0.2478 0.2625 0.1712 0.1551 0.1000
  • 48MB 版には 6 セット全勝で、相対 34〜38% の改善です。すべて CI が 0 を跨ぎません。
  • 1GB 版には 6 セット中 5 セットで有意に優位です。サイズは 1/3 になります。 ⚠️ ただし jvnv_free だけは有意差なし(Δ-0.0109 CI=[-0.0257, +0.0040])なので、 「1GB に全セット勝った」とは書けません。

雑音条件では Vosk と比較していません。Kaldi hybrid は雑音に強い可能性がありますし、 こちらは雑音が最大の弱点なので、上の「全セット優位」を雑音環境に一般化してはいけません。

推論性能

精度とは測定条件が違うので、表は分けています。

ONNX 化 — 3グラフに分けて decoder だけ int8

最終的な deploy 構成は float encoder + float cross_kv prefill + int8a decode step の3グラフです。 565MB → 324MB(.ort で 319.6MB)に落ちました。

  • PyTorch 経路との一致は、float 経路で token-id 24/24 が bit-exact でした。
  • decoder の int8 化は offline CER が無劣化です(float 0.1058 = int8a 0.1058)。
  • encoder の int8 化は不可でした。careful な static QDQ でもゲートを通らず、 float 維持の 208MB が下限になります。ここが「もっと小さくできないか」に対する構造的な答えです。

ストリーミングは精度と確定遅延のトレードオフ

逐次デコードをそのままやると AED では出力が壊れる(CER 0.78)ので、 bounded-tail revision(local-agreement)で「後から書き換わらないと確信できた分だけ凍結する」方式にしました。 どこまで待って凍結するかが margin で、これを振ると CER と Finalization が逆方向に動きます。

実効 margin CER offline との差 Finalization
12 0.2159 +0.1101 608ms
24(採用) 0.1596 +0.0538 883〜957ms
凍結なし 0.1288 +0.0230 2227ms

ここで前提をひとつ撤回しています。以前は「streaming でも CER 無劣化」と結論していましたが、 それは古い重みに限った所見でした。当時は offline 自体が 0.369 と悪く、streaming の劣化が埋もれていたのです。 offline が 0.106 まで改善したことで失うものが増え、同じ margin 12 のまま測ると +0.1101 も劣化します。

採用値を margin 24 に変えて劣化を半分に抑え、Finalization を 1 秒以内に収めました。それでも CER は +0.0538 劣化します。offline の精度をそのまま streaming の性能として出してはいけない、 というのがこの節の結論です。

遅延の実測

block 500ms・同一マシン・CPU で測っています。n は行ごとに異なります(ours は 24、Vosk は 8)。

モデル サイズ TTFP(初回部分結果) Finalization(確定まで) offline RTF
ours(margin 24) 324MB 1091〜1138ms 883〜957ms 0.19
vosk-model-small-ja-0.22 48MB 5997ms 29ms 0.62
vosk-model-ja-0.22 1GB 1082ms 133ms 0.21
  • 48MB の Vosk は「小さいが低遅延ではない」という結果でした。TTFP で約 5.3倍、RTF で 3.3倍こちらが速いです。 サイズが小さいことは低遅延を意味しません。
  • 1GB の Vosk とは TTFP・RTF は同等ですが、Finalization で 6.6〜7.2倍負けています。 AED は全 encoder フレームに cross-attend するので、Kaldi の増分デコードには構造的に及びません。 パラメータ調整では埋まらない差です。対話 UI の体感を最も左右する指標なので、 そこが重要な用途では Vosk big のほうが適しています。
  • Vosk 側の endpointing は無効のまま測っています(有効なら Vosk の Finalization はさらに前倒しになります)。

参考

公開したモデル

huggingface.co

土台にした実装

使ったデータ

  • 学習: ReazonSpeech v2(CDLA-Sharing-1.0)
  • 評価: FLEURS / SpeechBSD / JVNV / ITA・JVS コーパス

比較対象

  • moonshine-base-ja(Moonshine Community License・ベンチマーク比較にのみ使用)
  • vosk-model-small-ja-0.22 / vosk-model-ja-0.22(Apache 2.0)

ストリーミング音声変換モデル MeanVC2 を Windows + uv で動かしてみる

初めに

今回は、低遅延ストリーミングのゼロショット音声変換モデル MeanVC2 を、Windows ネイティブ環境 + uv で動かして、マイク入力のリアルタイム変換まで試してみます。

公式リポジトリは conda + pip 前提の手順になっているので、そこを uv に置き換えつつ、実際に詰まったところも合わせて書いていきます。

MeanVC2 とは

MeanVC2 は、西北工業大学の ASLP@NPU が公開しているストリーミング向けのゼロショット音声変換モデルです。前作 MeanVC の課題(学習効率・小チャンク時の品質劣化・低品質な参照音声への弱さ)を改善したもの、という位置づけになっています。

特徴としてはこのあたりです。

  • エンドツーエンドで 110ms の遅延(40ms チャンク時)。前作 MeanVC(160ms) の 211ms からほぼ半減
  • Future-Receptive Chunking (FRC): DiT の層ごとに過去/未来の受容野を明示的に配分することで、短いチャンクでも安定して変換できる。GPU のピークメモリも約 60% 削減
  • Universal Timbre Token Encoder (UTTE): 話者埋め込みを K 個の key-value ペアに変換し、BNF を query とした cross-attention で音色を引いてくる。参照音声の品質が低くても崩れにくい
  • Mean Flows + 1-NFE 推論: ODE を 1 ステップで解く
  • 18M パラメータと軽量

構成は認識-合成(recognition-synthesis)パラダイムで、ストリーミング ASR(Fast-U2++)が内容表現(BNF)を抽出し、DiT ベースの CFM デコーダがターゲットのメルスペクトログラムを生成、最後に Vocos ボコーダで 16kHz の波形にする、という流れです。

開発環境

  • Windows 11
  • uv 0.11.8
  • Python 3.11.10
  • NVIDIA GeForce RTX 4070 Ti SUPER (16GB)
  • torch==2.5.1+cu121

環境構築

uv で環境を作る

公式 README は conda で環境を作って pip で入れる手順ですが、uv に置き換えます。PyTorch だけ CUDA 12.1 の公式 wheel index から取ってくる必要があるので、pyproject.toml でインデックスを明示的に分けます。

[project]
requires-python = ">=3.11,<3.12"
dependencies = [
    "torch==2.5.1",
    "torchaudio==2.5.1",
    # ... 以下略
]

[tool.uv]
package = false

[[tool.uv.index]]
name = "pytorch-cu121"
url = "https://download.pytorch.org/whl/cu121"
explicit = true

[tool.uv.sources]
torch = { index = "pytorch-cu121" }
torchaudio = { index = "pytorch-cu121" }

explicit = true にしておくと、[tool.uv.sources] で明示したパッケージだけがこのインデックスを見に行きます。これを付けないと他のパッケージまで PyTorch のインデックスを探しに行って遅くなります。

あとは同期するだけです。

git clone https://github.com/ASLP-lab/MeanVC2.git
cd MeanVC2
uv sync --locked

requirements.txt の依存関係でハマった点

そのままだと依存解決が通らなかったので、requirements.txt を何点か直しています。

1. fairseq が omegaconf と衝突する

requirements.txt には fairseq>=0.12omegaconf>=2.3 の両方が入っていますが、fairseq 0.12 は omegaconf<2.1 を要求するので解決できません。コードを追った限り fairseq を直接 import している箇所は無かったので、外しました。

2. jiwer のバージョン上限

同じく解決を通すため jiwer>=3.0,<4.0 に制限しました。

3. 実際に import されているのに書かれていないパッケージがある

safetensorssounddevicerequirements.txt に無いのに実行時に import されます。リアルタイムモードは sounddevice が無いと動かないので、両方追加しました。

-fairseq>=0.12
-jiwer>=3.0
+jiwer>=3.0,<4.0
+safetensors>=0.5
+sounddevice>=0.5

モデルのダウンロード

前処理用・VC 本体・ボコーダのチェックポイントを一括で落とします。

uv run python initialization.py --task all

実行

ファイル変換

ソース音声とターゲット話者の音声を渡すだけで変換できます。事前の特徴量抽出は不要です。

uv run python src/infer/infer_e2e.py --model 120ms `
    --source-wav source.wav `
    --target-wav target.wav `
    --output-wav output.wav --steps 3

モデルは 120ms(品質重視)と 40ms(低遅延)の 2 種類が選べます。

リアルタイム変換(マイク入力)

runtime/run_rt.py を使うとマイク入力をそのまま変換できます。

uv run python runtime/run_rt.py --mode realtime --model 120ms `
    --device cuda --target-spk target.wav

--device のデフォルトが cpu になっているので、GPU で回したい場合は明示的に --device cuda を渡す必要があります。ここは最初素で実行して「やたら遅いな」と気づきました。

参照話者にはつくよみちゃんコーパスの音声を 1 ファイル指定しています。

起動するとこんなログが出ます。

==================================================
MeanVC Real-Time Streaming Voice Conversion
==================================================
  Device:     cuda
  Model:      120ms (src/config/config_120ms_40ms.json)
  Mode:       realtime
==================================================
[Init] Loading ASR encoder (JIT)...
[Init] Loading VC model...
[VC] Total parameters: 17,748,688
[Init] Loading vocoder...
[Init] Loading speaker model...
[Init] Speaker embedding shape: torch.Size([1, 256])

[Stream] Using sounddevice for real-time I/O
[Stream] Warming up...
[Stream] Running. Press Ctrl+C to stop.

パラメータ数は 17,748,688 で、README にある約 18M と一致しています。話者埋め込みは 256 次元です。

実行結果

リアルタイム処理のレイテンシ

実行中は 1 チャンクごとの処理時間がログに出ます。120ms モデルのランタイムでは 1 チャンク = 160ms なので、処理時間がこれを超えると音が途切れます(WARN OVERRUN)。

[1055] 83.2ms / 160ms OK  buf=40ms
[1065] 76.9ms / 160ms OK  buf=0ms
[1075] 94.5ms / 160ms OK  buf=80ms
[1126] 169.8ms / 160ms WARN OVERRUN  buf=80ms
[1136] 77.9ms / 160ms OK  buf=40ms

約 5 分間(1600 チャンク以上)連続で回したときの結果がこちらです。

項目 1 回目 2 回目
処理チャンク数 1796 (約 287 秒) 1618 (約 259 秒)
処理時間 中央値 71.7ms 75.7ms
処理時間 最小 55.0ms 55.3ms
オーバーラン発生率 0.39% 1.17%
オーバーラン時の最大値 217.0ms 207.7ms

160ms のチャンクに対して中央値 70〜80ms 程度なので、RTX 4070 Ti SUPER なら余裕を持って間に合っています。オーバーランも 1% 前後で、体感でも途切れはほとんど気になりませんでした。

なお、たまに 160ms を超えるのは他プロセスの GPU 使用と重なったタイミングのようでした。バッファ(buf=)が 0〜80ms の範囲で振動しているので、そこで吸収できている状態です。

cp932 で落ちる問題

Windows で標準出力をファイルにリダイレクトすると、リアルタイムモードが途中で落ちます。

UnicodeEncodeError: 'cp932' codec can't encode character '⚠' in position 21: illegal multibyte sequence

オーバーラン時のログに (U+26A0) が使われているためです。コンソールに直接出している間は問題ないのですが、リダイレクトすると出力先のエンコーディングが cp932 になって落ちます。しかもこれが sounddevice のコールバック内で起きるので、原因が分かりにくいです。

ここは ASCII に置き換えて回避しました。

-            flag = "⚠ OVERRUN" if proc_ms > chunk_ms else "OK"
+            flag = "WARN OVERRUN" if proc_ms > chunk_ms else "OK"

環境変数 PYTHONUTF8=1 を立てるか、PYTHONIOENCODING=utf-8 でも回避できるはずです。

まとめ

MeanVC2 を Windows ネイティブ + uv で動かして、マイク入力のリアルタイム変換まで確認できました。

  • requirements.txt の fairseq を外し、safetensorssounddevice を足せば uv で環境が作れる
  • --device cuda の指定を忘れない
  • RTX 4070 Ti SUPER で 160ms チャンクに対して処理時間の中央値は 70〜80ms 程度、オーバーラン率は 1% 前後
  • Windows でログをリダイレクトすると cp932 で落ちるので、ASCII 化するか PYTHONUTF8=1 が必要

zero shotのリファレンスなので、音声の類似度はそこまで高くないです

音声を聞きながら 4 状態のターン検出をする「FastTurn」の日本語モデルを作る

初めに

音声対話システムで地味に難しいのが、相手が話し終わったかどうかの判定です。VAD は音が止まったことしか教えてくれないので、無音の閾値を短くすると言い淀んだだけで割り込み、長くすると応答が遅くなります。

前回、これを4状態で判定する Easy Turn の日本語対応モデルを作りました。

ayousanz.hatenadiary.jp

モデルは以下で公開をしています

huggingface.co

状態 意味 システムの取るべき動作
Complete 話し終わった 応答を開始する
Incomplete まだ続く 黙って聞き続ける
Backchannel 相槌(「うん」「へー」) 自分の発話を止めない
Wait 「ちょっと待ってください」 長めに待つ

ただし Easy Turn は VAD の後段に置く再判定器で、判定の前に ASR の転写を出し切る必要があります。実測で P95 291ms かかっていました。

FastTurn は、同じ4状態を VAD 非依存のストリーミングで判定する論文です(arXiv:2604.01897、Easy Turn と同じ ASLP-lab / Lei Xie グループ)。全文の転写を待たず、ストリーミング CTC が吐く途中の文字列とエンコーダの中間表現の両方を使います。論文の報告値は Accuracy 79.62% / 平均遅延 120.1ms で、同じ表の Easy Turn が 78.05% / 297.1ms です。精度は同等で遅延が 2.5 分の1、という位置づけになります。

前回作ったものとの違いを並べると、こうなります。

観点 Easy Turn 日本語版(exp11a) FastTurn 日本語版(今回)
音声エンコーダ Whisper-Medium(オフライン) Nemotron FastConformer(ストリーミング)
意味の取り出し方 全文を転写してから判定する CTC の途中の文字列を逐次受け取る
LLM Qwen2.5-0.5B-Instruct Qwen3-0.6B
判定の出し方 LLM が状態トークンを生成する 3層 MLP(Unified の場合)
音響情報 暗黙(Whisper の特徴のみ) 明示的に融合する(Acoustic Adapter)
動作 VAD の後段に置く再判定器 VAD 非依存のストリーミング
判定する状態 4状態 同じ4状態

違いの本質は3つです。

  • エンコーダ: オフライン → ストリーミング(遅延と VAD 非依存性の根拠)
  • 意味の取り出し: 全文の転写 → 途中の文字列(同上)
  • 音響情報: 暗黙 → 明示的な融合(論文の新規性)

これを日本語で作りました。ただし FastTurn は公式の checkpoint も推論コードも公開されていないので、論文から組み直しています。

作ったもの

5つの系統を実装して、同じ評価セット(実会話150件+合成 wait 50件)で並べました。

系統 構成 Macro-F1 C/I Youden J 相槌 F1
VAD-only(τ=550ms) 無音の長さのみ 0.150 0.10 0.000
Cascaded ASR の文字列を LLM へ 0.538 0.02 0.600
Semantic 音声表現を LLM の入力に足す 0.639 0.36 0.521
Unified 音響情報を出力側でも融合 0.718 0.44 0.727
exp11a(前回の Easy Turn 日本語版) Whisper と Qwen2.5、VAD の後段 0.810 0.46 0.898

結果は3点です。

  • 再現できた — 論文が示す積み上がり方(VAD-only < Cascaded < Semantic < Unified)
  • 勝った — 遅延。エンコーダ込みで P95 110.2ms(エンコーダ 59.6 + 判定器 50.6)。前回の 291ms の約 2.6 分の1
  • 届かなかった — 精度。前回のモデルに 0.718 対 0.810

狙いは「精度は同等、勝つのは遅延」だったので、半分の達成です。論文の中心にある「音響融合で最良になる」も検証できていません。当初は Unified が Semantic を 0.079 上回ったのですが、学習の乱数を変えて12回ずつ測ると +0.019(z=+1.25)まで縮みました(後半に書きます)。

数値の比較範囲について2点あります。論文の報告値(中国語と英語で 22,432件)とこの表(日本語で 200件)は、別のデータセットなので比較していません。前回の記事も 824件の別セットで測っていて、同じ exp11a が 0.649 と出ています。比較が成立するのは、5系統を同じ 200件で測ったこの表の中だけです。

論文が規定していること、していないこと

公式リポジトリにあるのは README と構成図、それにテストセットだけです。Easy Turn のときは checkpoint.pt が 3.45GB で降ってきたので動かして確かめられましたが、今回はできません。

論文を読んで、構造について明確に規定されているのは5点でした。Conformer の中間 hidden を使うこと、Acoustic Adapter は4層 Transformer であること、それを LLM の hidden と融合すること、3層 MLP の Turn Detector を置くこと、Fusion 段で Adapter と Detector を学習すること。

逆に、音響の系列をどう固定長にするのか、融合をどの演算でやるのかは書かれていません。ここは自分で決めました。

組み上がった全体像です。

音声 (16kHz)
  → Nemotron-3.5-asr-streaming-0.6b (24層 FastConformer, 320ms chunk)
     ├─ CTC の途中の文字列 → Qwen3-0.6B ────────────→ Cascaded
     └─ 中間 hidden (T, 1024)
          ├─ LLM Adapter (4層 Transformer, 34M) → Qwen3-0.6B → Semantic
          └─ Acoustic Adapter (4層 Transformer)
                    └→ LLM hidden と融合 → 3層 MLP Turn Detector → Unified

エンコーダは中間表現を取り出せるかで選ぶ

エンコーダに求められる条件は構成から決まります。CTC の途中の文字列と、Acoustic Adapter に渡す中間 hidden の両方を、ストリーミングで出せることです。

日本語で使える候補として ReazonSpeech の2種類を実機で比べました。

k2 Zipformer NeMo (nemo-v2)
途中の文字列 出る 出る
中間 hidden 取得不可 [1, 1024, 112] float32
最終転写 完全一致(CER 0.0) 前半が脱落

転写がいちばん正確だった k2 Zipformer が、この時点で失格しました。ONNX 配布で torch のエンコーダが存在せず、中間層の出力を取り出す先がありません。推論はできても Acoustic Adapter が作れません。論文どおりのアーキテクチャを組むなら、ASR の精度だけで選ぶと、あとから作れないものが出てきます。

採用したのは nvidia/nemotron-3.5-asr-streaming-0.6b(24層 FastConformer、OpenMDW-1.1)です。論文は 12層の Conformer を中英3万時間超で事前学習していますが、そこは学習済みの多言語ストリーミングモデルで置き換えました。

中間 hidden の取り出しは、公式スクリプトを書き換えず、モデルをロードした直後に hook を差し込む形にしました。

def _ft_hook(_m, _i, o):
    t = o[0] if isinstance(o, (list, tuple)) else o
    if hasattr(t, "shape"):
        _FT["hidden"].update(captured=True, shape=list(t.shape), dtype=str(t.dtype))
asr_model.encoder.register_forward_hook(_ft_hook)

asr_model.encoder に1つ仕掛けるだけで取れます。取れた形は [1, 1024, N] で、N はチャンク内のフレーム数です。次元が 1024 と分かったので、Adapter の入力側はここで決まりました。

先読み幅を変えて測った結果です。

lookahead ≒遅延 処理時間 median P95
0 80ms 11.8ms 12.2ms
3 320ms 12.8ms 14.2ms
13 1120ms 16.0ms 19.2ms

80ms しか先を見ていない設定でも、日本語の転写は句点が1つ落ちる程度でした。チャンクあたり 12〜19ms なので、1チャンクの予算 150ms に対して十分軽い数字です。VRAM はロード時 2.596GB、ピーク 5.15GB(fp32)でした。

論文が書いていない部分を決める

LLM Adapter は論文の指定どおり4層の Transformer Encoder です。

layer = nn.TransformerEncoderLayer(
    d_model=1024, nhead=8, dim_feedforward=2048,
    dropout=0.1, batch_first=True, activation="gelu", norm_first=True)

ffn_dim を 2048 にしたのはパラメータ数を論文の記述(約34M)に合わせるためで、4096 だと 50M になります。位置エンコードは学習可能なパラメータにしました。cache-aware streaming ではチャンクの長さが可変で、hidden の時間方向の長さが 1 にも 4 にもなるためです。

Acoustic Adapter は「4層 Transformer」としか書かれていないので、LLM Adapter と同型のものを再利用しています。問題はその先で、時間方向に長さのある音響系列と、固定長の LLM last-hidden をどう混ぜるかが論文にありません。決めたのは2点です。

  1. 音響系列は masked-mean で時間方向に潰して固定長にする
  2. LLM hidden と連結してから線形射影する

加算にしなかったのは次元を揃える制約が強いからで、連結なら音響と意味の寄与を層が学習できます。

あとから別方式も試せるよう切り替え式にしましたが、そうすると新しい問題が出ます。どの方式で学習したかが checkpoint から分からないと、推論時に別の計算をしてしまいます。そこで構成キーを checkpoint 自体に書き込み、復元は必ずその値を通すようにしました。load_state_dict は strict のままなので、取り違えるとキーの過不足で必ず落ちます。静かに別のモデルになりません。

Turn Detector は指定どおり3層の MLP です。

self.net = nn.Sequential(
    nn.Linear(1024, 512), nn.GELU(), nn.Dropout(0.1),
    nn.Linear(512, 512),  nn.GELU(), nn.Dropout(0.1),
    nn.Linear(512, 4),
)

Cascaded と Semantic は LLM が状態トークンを生成してその logit から確率を出しますが、Unified はこの4つの logit を softmax します。

学習データを作る

論文の学習は4段階です。省いたのは1段階目の ASR 事前学習だけで、あとは実施しました。

段階 設定
Modality Alignment(Adapter を ASR 目的で) epochs 2 / batch 16 / lr 5e-4
Joint(Adapter と LLM をターンデータで) epochs 3 / batch 8 / lr 2e-5 / prompt dropout 0.35
Fusion(Acoustic Adapter と Turn Detector) epochs 6 / batch 8 / lr 1e-4

データ側で効いたのは3点です。句読点を落とすこと、クラス分布を揃えること、実会話データを混ぜることです。

句読点を落とす

合成テキストで4状態データを作ると、句読点が完結と未完結を分ける手がかりになります。実会話の書き起こしには句読点が無いため、そのまま学習したモデルは実会話で判別できません(合成テキストでの Macro-F1 0.864 に対し、実会話では 0.450)。

平均長 句読点を含む割合
学習(合成) 13.4字 18.7%
実会話の書き起こし 12.7字 0.0%

テキスト生成の段階で句読点を落とすか、実会話の表記に寄せます。

ASR を経由する構成では、転写の際に元テキストの句読点が失われるため、この影響は小さくなります(C/I Youden J はテキスト直渡しの 0.380 に対し、ASR 経由で 0.460)。

クラス分布を揃える

音声経路を新しく学習させる場合、学習データ中の比率が小さいクラスは学習されません。相槌が 400件中4件(1%)の構成では、評価時に相槌を一件も予測しませんでした。

4状態をおおむね均等にした 1825件(complete 650 / incomplete 675 / backchannel 250 / wait 250、相槌 13.7%)で学習すると回復します。

指標 相槌 1% 相槌 13.7%
相槌 F1 0.000 0.916
Macro-F1 0.282 0.552

学習の前にクラス分布を数え、単一クラスが 5% を切っていたら止めるようにしています。

実会話データを混ぜる

台本から作った合成音声だけで学習すると、実音声での C/I 判別が伸びません。実会話コーパス(J-CHAT)から 1841件を足し、エンコーダは凍結したまま Adapter と判定側だけを再学習しました。

条件 追加前 追加後
clean +0.140 +0.380 +0.240
雑音 snr10 −0.080 +0.120 +0.200
重なり +0.020 +0.060 +0.040

5条件すべてで同じ方向に動きます。実会話の incomplete は、文法的には完結して聞こえるものが多く(語尾に「けどね」が付いているだけで話が続くなど)、台本から作ったデータにはこの形があまり入っていません。

ただし J-CHAT は wait と相槌が乏しいので、混ぜると学習データの構成が偏ります。クラス頻度の逆数で重みを付けると(実測で 0.291 / 0.287 / 1.611 / 1.811)wait の取りこぼしは 0.680 から 0.820 に戻ります。相槌は戻りません。クラス重みは比率の偏りを補正できますが、絶対数の不足(281件)は補正できません。

効かなかった介入

complete の判定は 0.589 で頭打ちになり、ここから先は動きませんでした。原因の切り分けとして、誤りのない書き起こしをそのまま与えた条件を測ってあります。

与えたテキスト complete F1 相槌 F1
ASR の出力 0.5895 0.727
正解の書き起こし 0.5934(p=1.0) 0.923(p=0.0018)

complete の頭打ちはテキスト由来ではないので、ASR を差し替えても上がりません。一方で相槌は大きく上がるため、ASR の改善は相槌の判定に効きます。

ほかに試して効果が無かったものです。

  • 学習データに無い末尾パターン(倒置・接続助詞終わりの complete)を257件生成して置換 → complete F1 0.589 → 0.422
  • 直前の発話を文脈として与える(3手法)→ 改善なし。正解を与えるオラクル条件でも −0.056
  • 発話末尾だけをプーリング → −0.007(全体の平均が既に AUC 0.894)
  • 重み平均(SWA)→ 標準偏差 0.0555 → 0.0526(F=1.11)で誤差内

評価

評価セットと測り方

比較の下限に VAD-only、参照上限に前回の exp11a を置き、評価セットは exp11a に合わせました。complete / incomplete / 相槌が各50件(exp11a と同一の実 TalkBank 音声、電話品質)、wait は実会話に出てこないので合成で50件。これを clean・雑音2段階・重なり・雑音と重なりの5条件に展開しています。

読み込み時にスキーマ検証を挟み、ラベル分布・正解テキストの切り詰め・音声ファイルの解決・学習データとの重複を機械的に確認します。最後の1つはコミット前のフックにも入れました。GPU 上で書いた変換スクリプトが正解ラベルを一律 complete に固定していたことがあり、人間の注意に頼らない形にしてあります。

遅延は end-to-end で エンコーダ 59.6ms + 判定器 50.6ms = P95 110.2ms。GPU 計測なので前後に torch.cuda.synchronize() が要ります。ASR の最終文字列をチャンク数で等分割して途中経過を作る方式は、時刻がデコードに対応しないため測定になりません(150件中 87件が 0.0、63件が null になります)。

測れる差の大きさ

比べたい差を、この測定系が見分けられるか。学習側の揺れと、評価セット側の分解能の両方を先に測ります。

まず学習の揺れです。構成もデータもハイパーパラメータも変えず、乱数の種だけを変えて12回学習しました。

指標 12回の平均 標準偏差
Macro-F1 0.6664 0.0510
相槌 F1 0.6461 0.1143

分散を減らす手として効いたのは学習率スケジュールでした。cosine の減衰でエポックを12に増やすと標準偏差が 0.0555 → 0.0293(−47%、F=3.59、p=0.022)、平均は下がりません。相槌も 0.1675 → 0.0863 と半減します。この条件で Unified と Semantic を12回ずつ比べました。

条件 標準誤差 z
1回対1回(最初の公表値) +0.079
12回ずつ +0.0306 0.0218 +1.41
分散を減らしてから12回ずつ +0.0190 0.0152 +1.25

判定の境界は z が 2 以上なので、音響融合の優位は未検証としました。符号は2条件とも正で、否定されたわけではありません。相槌の誤り方を見ると Semantic の 50件中30件が incomplete に流れていたのが Unified では10件に減っており、韻律を入れた効果そのものは観測できています。

分散があっても大きい差は検出できます。同じ12回で、末尾パターン生成の失敗(−0.167)は +3.98σ と明確に出ました。1回対1回の比較が意味を持つのは、この規模からです。

次に評価セット側です。200件でどれくらいの差を検出できるのかを、層別ブートストラップ 4000回で測ります。

指標 実測 95% 信頼区間 閾値
Macro-F1 0.718 [0.655, 0.775] 0.75
C/I Youden J 0.440 [0.260, 0.600] 0.40
complete F1 0.589 [0.465, 0.695] 0.65

信頼区間が閾値をまたぐので、通っているように見える項目も決着していません。必要な件数は Macro-F1 で約700件、C/I Youden J で約1800件でした。

評価セット自体の質も6種類で検査しました。判定器の出力を見る前に、単純な特徴量だけで当てられないかを確かめるものです。

  • wait とドメインが 100% 交絡。5〜8kHz の帯域だけで分離でき AUC 0.999(合成で足した wait を音質で当てられる)
  • 相槌50件は音声長 1.08秒の単一閾値に負ける(判定器 0.742 に対し 0.860)
  • 相槌の異なり表層形が7種しかない。86% が「うん」
  • 誤切断の対象100件のうち50件は、無音が最大 0.400秒で閾値 0.550秒では発火しない

このうち3点はモデルを改善しても動きません。参照上限の exp11a 自身が complete F1 0.6415 で閾値 0.65 に届いていないことも、ここで分かりました。この検査を先に通すと、モデル側の伸びしろと物差し側の欠陥を切り分けられます。

まとめ

  • 論文どおりの系統の序列(VAD-only < Cascaded < Semantic < Unified)と、エンコーダ込み P95 110.2ms は再現できました
  • 精度は前世代のモデル(0.810)に 0.718 で届いていません
  • 音響融合の優位は、12回ずつ測ると z=+1.25 で検証できていません

参考

話し終わり判定モデル Easy Turn の日本語対応モデルを作成する

初めに

音声対話システムを作るときに地味に難しいのが、「相手が話し終わったかどうか」の判定です。VAD(音声区間検出)は「音が止まった」ことしか教えてくれないので、少し言葉に詰まっただけなのに割り込んでしまったり、逆に相槌を打たれただけで「話し終わった」と判断して喋り出したりします。

Easy Turn は、この判定を4状態で行うモデルです。構成は Whisper-Medium の音声 Encoder + Adapter + Qwen2.5-0.5B-Instruct です。

状態 意味 システムの取るべき動作
Complete 話し終わった 応答を開始する
Incomplete まだ続く 黙って聞き続ける
Backchannel 相槌(「うん」「へー」) 自分の発話を止めない
Wait 「ちょっと待ってください」 長めに待つ

ただし対応言語は中国語と英語で、日本語を入れてもまったく動きません(前編で確認しました)。

この記事は、その公式 checkpoint を日本語に継続適応させた記録です。作ったモデルは ayousanz/easy-turn-ja で公開しています(CC BY-NC-SA 4.0・非商用)。

作ったもの

VAD を置き換えるものではなく、VAD の後段に置く再判定器です。VAD が「音が止まった」と判定した区間に対して、その止まり方が4状態のどれなのかを判定し直します。

VAD 単体だと「無音が N ミリ秒続いたら応答開始」というルールしか書けません。N を短くすると言い淀みで割り込み、長くすると応答が遅くなります。このトレードオフを、無音長ではなく発話の中身で解こうというのが4状態判定の狙いです。

同じターン検出でも VAP(Voice Activity Projection)は二話者の連続ステレオ音声から将来の音声活動を予測し続ける方式で、VAD に依存せず常時走る点がこの構成とは根本的に違います。

出力はこの形式です。

{
  "transcript": "えーっと、それでですね",
  "state": "incomplete",
  "state_probabilities": {
    "complete": 0.04, "incomplete": 0.91,
    "backchannel": 0.03, "wait": 0.02
  },
  "latency_ms": 241
}

状態確率は生成された文字列を parse するのではなく、最終ラベル token の logit から4状態を正規化して算出しています。

学習した重みは ayousanz/easy-turn-ja で公開しています。学習データに CC BY-NC-SA 系のコーパスを含むため、非商用に限ったライセンス(CC BY-NC-SA 4.0)です。

方法

公式の学習は2段階です。Stage 1 で音声 Encoder + Adapter + LLM を ASR で整合させ、Stage 2 で4状態のターン判定データを学習させます。日本語版もこの順序をなぞりました。以下の 1 から 5 は実際に進めた順です。

1. 日本語 ASR 再整合 — 全解凍が必須だった

ReazonSpeech から10時間(6,189件)を用意して、まず「日本語が文字起こしできる」状態を作りにいきました。

最初は Adapter だけを学習対象にする構成(公式実装の fire_module: link、学習対象 56.6M / 凍結 800M)です。これがまったく通りませんでした。

構成 結果
Adapter-only(3 epoch) loss 12.5 → 11.0。日本語出力率 0%
Adapter-only(20 epoch) loss 12.5 → 10.6 で頭打ち。「田田田」のような縮退した繰り返し
Encoder + Adapter 解凍(20 epoch) 日本語 CER 1.563 / 日本語出力率 0%。これも不成立

loss 10.6 は語彙15万の一様分布(≒11.9)に近い水準で、ターゲット分布をほとんど学習できていません。

「10時間では足りない」と片付ける前に、100サンプルの暗記テストで原因を切り分けました。100件だけを何十周も回して、それすら覚えられないならデータ量の問題ではない、という診断です。

構成 データ loss 挙動 結論
Encoder + Adapter 日本語100件 × 39周 10前後で停滞 データ量の問題ではない
Encoder + Adapter 中国語100件 初期 0.56 パイプラインは正常
全解凍(LLM を含む) 日本語100件 × 8周 12.8 → 4.2 LLM 解凍が必要条件

凍結された Qwen は「音声埋め込み = 中国語」という強い事前分布を持っていて、音声側(Encoder + Adapter 計 362M)をいくら学習しても越えられない、という構造でした。公式の Stage 1 の既定値が全解凍なのは、おそらくこのためです。

全解凍に切り替えた結果です。

指標 ゼロショット Adapter-only Encoder追加 全解凍
日本語 CER 1.031 4.137 1.563 0.535
日本語出力率 0% 0% 0% 94.5%
中国語 Accuracy 96.63% 61.9% 63.0% 93.87%

音声 LLM の言語適応は、LLM を学習対象に含める構成(全解凍か LoRA)が必須です。

2. 4状態 fine-tuning

ASR が通ったので、次は4状態の判定を教えます。この時点では ASR だけで学習したせいで Wait の recall が 0、Backchannel も 0.23 まで落ちていました。

  • 4状態の合成データ 2,000件 — テキストは LLM 生成(complete 700 / 躊躇型 incomplete 350 / 切断型 incomplete 350 / backchannel 300 / wait 300)、音声は Irodori-TTS v3 と VoiceDesign v3 で作った架空話者10名で合成
  • 検品 — anime-whisper と Qwen3-ASR-1.7B の2系統でクロスチェック。フィラーや相槌を削除してしまう ASR は検品に使えません
  • ASR 混合 — ReazonSpeech 30.0時間 / 18,507件
  • 学習は全解凍・6 epoch・57分(H100 PCIe)、loss 3.3 → 0.44

合成テストでの Macro-F1 は 0.9596(complete 0.970 / incomplete 0.971 / backchannel 0.962 / wait 0.936)、日本語 CER 0.152、日本語出力率 100% でした。

ところが TalkBank CABank の CallHome 日本語(電話音声)から実会話クリップを切り出して測ると、Macro-F1 は 0.51 でした。相槌と待機は取れているのに、complete と incomplete の区別、つまり「話し終わったのか、まだ続くのか」がまったく取れていません。この記事では以降これを話し終わり判定と呼び、Youden J(感度 + 特異度 − 1。0 があてずっぽう、1 が完璧)という指標で測ります。

このときの値は −0.363 でした。マイナスは偶然以下、つまり complete と incomplete を逆に当てているということです。

合成データで作ったテストセットは過学習をまったく検出できません。合成テストは回帰ガードにしか使えず、改善の証拠には使えない、というのがここでの結論です。

3. 評価の脱漏洩

学習データを直す前に、評価の土俵を確認しました。切り出したクリップの末尾無音の長さが「次の発話までの間隔」と一致していて、モデルがそこを見れば当たってしまう状態だったためです。

末尾を固定余白でタイトに切り直し、デコード経路も forced フォールバック(タグが出なければ末尾に < を teacher-forcing して次トークンの logit を読む)付きに修正しました。complete と incomplete のあいだの末尾無音の差は 1300ms → 8ms になり、ラベル欠落も 0 になりました。

そのうえで測り直しても J はマイナスのままで、原因は評価の測り方ではなく学習データだと確定しました。

4. 実会話データの導入

方針を決める前に、公式モデルを自分と同じ評価コードで測り直しました。論文には実録音と合成を分けた性能が載っていないので、公式テストセットを実会話だけに絞って自分で集計します。

対象 Youden J
公式・中国語実会話(150+150) 0.927(感度 0.953 / 特異度 0.973)
公式・中国語 全体(実会話+合成 600) 0.947

同じデータで「発話長だけから complete / incomplete を当てる」と AUC 0.597 にしかなりません。長さでは解けないタスクを公式モデルは解いているので、「音声だけでは話し終わりかどうかを原理的に区別できない」という線は消えます。あとは公式レシピとの差分を埋める作業です。

差分のうち最大のものは、公式が complete / incomplete を実会話コーパスから取っているのに対し、こちらは合成で代用し、しかも incomplete の半分が「波形をぶつ切りにした」もの(実会話には無い切れ方)だった点でした。

介入は2つに絞りました。

  1. TalkBank CallHome の評価に使っていない22会話から 2,612ターンを切り出して学習データに追加(会話ID単位で train / dev / test を完全分離)
  2. 合成 incomplete の波形切断型を廃止し、その穴を実会話 incomplete で埋める

話し終わり判定の Youden J は −0.332 → 0.059(+0.391)。単独で最大の改善でした。長さ帯別の complete recall が全帯で同時に向上しており、長さに依らない判別を獲得したことを意味します。学習は6 epoch で約16分でした。

5. J-CHAT で増量する

この時点の実会話 incomplete は実質406件しかなく、規模が足りません。そこで J-CHAT(CC BY-NC 4.0、69,000時間、YouTube + Podcast、話者分離とターン分割つき)を使いました。

ただし J-CHAT には完結性のラベルがありません。公式レシピを写経して、2つの LLM によるクロスアノテーションでラベルを付けました。

  • アノテータA: 統語的完結性(文として閉じているか)で判定
  • アノテータB: 継続意図(話者が続けるつもりか)で判定
  • 両者が一致したものだけを採用

2者の一致度は Cohen's κ で 0.875(一致率 94%)でした。κ は偶然の一致を差し引いた一致度の指標で、1.0 が完全一致、0.8 以上が「ほぼ一致」とされる水準です。人手でラベルを付けた CallHome に匹敵します。ここから 2,166 クリップを抽出しました。

話し終わり判定の Youden J は 0.059 → 0.205(+0.146)。J-CHAT は広帯域の Podcast なのに電話帯域の評価セットで効いており、この判別が音響の表面的な特徴ではなく言語的な完結性に根ざしていることの傍証になります。

なお J-CHAT の音声は、WebDataset shard の .data ファイルの中に Lhotse MonoCut の pickle があり、そのさらに中に FLAC(22.05kHz)が入っているという構造でした。ドキュメントから想像した形式とは違ったので、GPU を借りる前に実物を検証しておいて正解でした。

効かなかった介入

上の1から5と前後して試したもののうち、話し終わり判定に対して有意な改善が出なかったものも並べておきます。いずれも会話単位のクラスタブートストラップで検定しています。

やったこと 結果
会話体の合成データを第2世代に作り直して増量 改善せず
テキスト長の分布を再配分(長さショートカットの解消) 長さ依存は弱まった(モデルの予測を音声長から当てる AUC 0.882 → 0.767)が、話し終わり判定は動かず
wait を公式定義(停止・中断要求)で再合成 + ラベル改訂 有意に悪化。ただし wait → complete の誤判定は 5件 → 1件に改善
DuplexChat から 9,217ターンを追加(学習データ 6,165 → 15,382行) 有意差なし
podcast 音声を電話帯域に劣化させて評価と揃える(電話帯域 17% → 91%) 話し終わり判定は動かず。ただし CER は有意に改善(後述)

DuplexChat では、future-window VAD による自動ラベル(次の発話までの間隔から機械的に complete / incomplete を決める)も試しましたが、人手相当のラベルとの一致率が 0.516 とほぼコイン投げでした。間の長さは語用論的なラベルの代用になりません。結局こちらも2つの LLM で付け直しています(κ 0.898)。

評価

評価セットの作り方

日本語で4状態のラベルが付いた公開の評価セットは無いので、実際の会話の録音を切り出して自分でラベルを付けました。素材を人間どうしの自然な会話に限っているのは、合成音声で作ったテストが当てにならないと分かったためです(前述のとおり、合成テストで Macro-F1 0.96 のモデルが実会話では 0.51 でした)。

  • 素材: TalkBank CABank の CallHome / CallFriend 日本語 — 知人どうしの電話会話の録音
  • 規模: 824件 / 118会話(complete 291 / incomplete 261 / backchannel 272)
  • ラベル: 学習データと同じく2つの LLM に別々の観点で判定させ、食い違ったものは第三の判定で裁定。2者の一致度は κ 0.9023
  • wait は自然会話にほとんど出現しないため、このセットには含めず、合成50件で別に測定

検定はクリップ単位と会話単位のクラスタブートストラップの両方で行い、両方で 0 をまたがない場合のみ有意としています。1会話あたり平均7.0件あり、同一会話のクリップは話者・チャネル・話題を共有して誤りが相関するため、クリップを独立と仮定すると検出力を過大評価するからです。

全世代の数値

モデル やったこと 話し終わり判定 J Macro-F1 complete F1 bc F1 CER
exp3a 4状態FT −0.363 0.511 0.424 0.718 0.557
exp7a 長さ再配分 −0.332 0.522 0.435 0.743 0.582
exp10a CallHome 追加 0.059 0.642 0.503 0.805 0.470
exp11a J-CHAT 追加 0.205 0.664 0.536 0.768 0.468
exp12a wait 再定義 + 再ラベル 0.057 0.608 0.418 0.764 0.518
exp15a wait テキストのみ差替 0.160 0.656 0.482 0.793 0.504
exp15b ラベルのみ差替 0.164 0.643 0.454 0.777 0.504
exp16a DuplexChat 追加 0.251 0.630 0.522 0.684 0.467
exp17a 帯域整合 0.261 0.649 0.552 0.717 0.447
exp17b wait 定義やり直し 0.142 0.631 0.467 0.758 0.490

Macro-F1 は評価セットに実際に含まれる3状態の平均です(wait はこのセットに無いので、4状態で平均すると実力を約1/4 過小評価します)。

最終的に残った候補は3つです。学習データの作り方が違うもので、話し終わり判定の J で互いに有意差がなく、824件では順位を決められませんでした。

  • 実会話データ版(exp11a)— CallHome と J-CHAT の実会話ターンを入れたところまで
  • DuplexChat 版(exp16a)— そこへ DuplexChat から 9,217ターンを足したもの
  • 帯域整合版(exp17a)— ポッドキャスト音声を評価と同じ電話帯域に揃えたもの

3候補の主要指標です。

指標 実会話データ版 DuplexChat 版 帯域整合版
Macro-F1(実会話3状態) 0.664 0.630 0.649
complete F1 0.536 0.522 0.552
incomplete F1 0.688 0.685 0.680
backchannel F1 0.768 0.684 0.717
日本語出力率 0.993 0.996 0.996
ラベル出力率 1.000 1.000 1.000
Wait recall(合成50件で別測) 0.88 0.92 0.86

開始時点(4状態 fine-tuning 直後、実会話データを1件も入れていない段階)と最終の比較です。

指標 開始時 帯域整合版
話し終わり判定の判別力(Youden J) −0.363 0.261
complete F1 0.424 0.552
incomplete F1 0.390 0.680
backchannel F1 0.718 0.717
Macro-F1(実会話3状態) 0.511 0.649
実会話 CER 0.557 0.447

日本語の出力とラベルの出力はほぼ完全で、incomplete・backchannel・wait も 0.68〜0.92 に収まりました。いちばん低いのは complete の F1 で、3候補とも 0.52〜0.55 です。Macro-F1 を押し下げているのはこの1項目でした。

CER では帯域整合版が全モデル中ただ一つ有意に勝っています(0.4465、実会話データ版との差 −0.0216、95%CI [−0.036, −0.007])。帯域を揃えることは話し終わり判定には効かず、転写品質に効いていました。

VAD-only との比較

F1 だけでは使い勝手が分からないので、音声エージェントに載せたときの誤り率に翻訳した実測も出します。比較対象は VAD-only(無音が τ ms 続いたら応答する素朴なルール)です。

τ を短くすれば取り逃しは減りますが割り込みが増えるので、1点だけ切り取った比較には意味がありません。モデルと同じ「話し終わりの取り逃し率」になる τ を探して並べました。

話し終わりの取り逃し 途中で割り込む 相槌に割り込む
実会話データ版 48.1% 28.0% 17.6%
VAD-only(τ=1060ms) 47.8% 25.7% 62.5%

話し終わり判定の軸では VAD-only と同等でした。取り逃しを揃えると、途中での割り込みも 25.7% 対 28.0% でほぼ並びます。差がついたのは相槌だけで、VAD が 62.5% 割り込むところモデルは 17.6% です(差 −44.9%、95%CI [−53.4%, −35.8%] で有意)。「うん」「へえ」に応答しないという判断は、無音の長さからは原理的に出せません。残る2候補でも同じ傾向でした。

「話し終わりの4割を取り逃す」は良い数字ではありませんが、このモデルは VAD の後段に置く再判定器なので、complete を取り逃したときは従来どおり VAD のタイムアウト待ちに落ちるだけです。早く返せないだけで、余計な割り込みは足しません。相槌を潰さない VAD の加速器として使う、というのがこのモデルの位置づけです。

VAD 側の無音長は、元の会話音声に戻って silero VAD で実測しました。チャネル分離が完全でない録音なので、転写タイムスタンプ由来の理想的な gap でも同じ集計をしていますが、読み取りは変わりません。

推論性能

RTX 4090・n=200 での実測です。

指標
P50 レイテンシ 111.6ms
P95 レイテンシ 291.2ms
RTF 0.0389
VRAM ピーク 2.69GB

VRAM が 2.69GB なので、対話パイプラインの中に他のモデルと同居させても余裕があります。

実装上の落とし穴をひとつ。状態トークンは Qwen2.5 の tokenizer 上で単一トークンではなく < / 識別語 / > に分割され、先頭の < は4状態で共通です。2番目のトークンが4状態を一意に識別します。しかもモデルが実際に出すのは小文字タグなので、大文字の token id で softmax すると4状態がほぼ一様分布(各0.25)になります。id はハードコードせず tokenizer から動的に導出するのが安全です。

参考

公開したモデル

関連記事

土台にした論文と実装

使ったデータ

使ったツール

.NET 10とWindows MLでCAT-Translate-1.4bをC#から動かす

初めに

前回の記事では、CAT-Translate-1.4bをONNX Runtime GenAIで扱える形式へ変換しました。

今回は、変換したモデルを.NET 10のC#アプリケーションから実行します。

実行には、Windows MLとONNX Runtime GenAIを使います。Windows MLがExecution Providerを管理し、ONNX Runtime GenAIが生成モデルのロードやトークン生成を担当します。

今回確認した結果は次のとおりです。

  • .NET 10のC#コンソールアプリから翻訳できた
  • FP32 CPU、INT4 CPU、FP16 DirectMLの3種類で実行できた
  • 元モデルのSentencePiece tokenizerをC#側で利用した
  • DirectMLでは警告が出たが、推論は完了した
  • PythonとC#で出力が完全一致しない点が残った

開発環境

項目 バージョン / 構成
OS Windows 11
.NET 10
Target Framework net10.0-windows10.0.17763.0
Platform x64 / win-x64
ONNX Runtime GenAI 0.15.2
Windows App SDK ML 2.1.1
Microsoft.ML.Tokenizers 2.0.0

使用したNuGetパッケージは次の3つです。

  • Microsoft.ML.OnnxRuntimeGenAI.WinML
  • Microsoft.WindowsAppSDK.ML
  • Microsoft.ML.Tokenizers

C#からONNXモデルを動かす

プロジェクトを作成する

コンソールアプリケーションを作成します。

dotnet new console -n CatTranslate.Poc
cd CatTranslate.Poc

.csprojを次のように設定します。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
    <PlatformTarget>x64</PlatformTarget>
    <Platforms>x64</Platforms>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.ML.OnnxRuntimeGenAI.WinML" Version="0.15.2" />
    <PackageReference Include="Microsoft.WindowsAppSDK.ML" Version="2.1.1" />
    <PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
  </ItemGroup>
</Project>

Windows MLとONNX Runtime GenAIの役割

今回の処理は次のように分かれています。

C#の入力文字列
        │
        ▼
翻訳プロンプトを作成
        │
        ▼
SentencePiece tokenizerでトークン化
        │
        ▼
ONNX Runtime GenAIのGeneratorへ入力
        │
        ▼
Windows ML / CPU / DirectMLで生成
        │
        ▼
SentencePiece tokenizerでデコード

Windows MLは、Windows上で利用するExecution Providerを登録するために使います。モデルの生成ループそのものはONNX Runtime GenAIのModelGeneratorParamsGeneratorで実行します。

tokenizerとプロンプトを処理する

CAT-Translateのプロンプト

CAT-Translate-1.4bには、翻訳方向を含むinstructionを渡します。

Translate the following Japanese text into English.

🐈はとてもかわいいの。おててがまるくてふわふわなの。

チャットテンプレートを適用した最終的なプロンプトは、次の形式になります。

<|user|>Translate the following Japanese text into English.

🐈はとてもかわいいの。おててがまるくてふわふわなの。</s><|assistant|>

元のSentencePiece tokenizerを使う

前回の記事で説明したように、Model Builderが生成したtokenizerと元モデルのtokenizerは形式が異なります。

そのため、C#ではONNX Runtime GenAIのTokenizerを使わず、元モデルのtokenizer.modelSentencePieceTokenizerで読み込みます。

特殊トークンのIDは、モデルの設定に合わせて明示的に指定します。

var specialTokens = new Dictionary<string, int>(StringComparer.Ordinal)
{
    ["<unk>"] = 0,
    ["<s>"] = 1,
    ["</s>"] = 2,
    ["<pad>"] = 3,
    ["<sep>"] = 4,
    ["<mask>"] = 5,
    ["<cls>"] = 6,
    ["<|system|>"] = 7,
    ["<|assistant|>"] = 8,
    ["<|user|>"] = 9,
    ["<|available_tools|>"] = 10,
    ["<|tool_calls|>"] = 11,
    ["<|tool_results|>"] = 12,
    ["<|code|>"] = 13,
    ["<|file|>"] = 14,
    ["<|prefix|>"] = 102397,
    ["<|suffix|>"] = 102398,
    ["<|middle|>"] = 102399,
};

using var tokenizerStream = File.OpenRead(
    Path.Combine(modelDirectory, "tokenizer.model"));

var tokenizer = SentencePieceTokenizer.Create(
    tokenizerStream,
    addBeginningOfSentence: false,
    addEndOfSentence: false,
    specialTokens);

翻訳する

Windows MLのExecution Providerを登録する

まず、Windows MLのExecution Providerを登録します。

var catalog = ExecutionProviderCatalog.GetDefault();
catalog.RegisterCertifiedAsync().GetAwaiter().GetResult();

環境によっては登録に失敗する可能性があるため、サンプルでは例外を捕捉して処理を続けるようにしています。

try
{
    var catalog = ExecutionProviderCatalog.GetDefault();
    catalog.RegisterCertifiedAsync().GetAwaiter().GetResult();
    Console.WriteLine("Windows ML execution providers registered.");
}
catch (Exception ex)
{
    Console.Error.WriteLine(
        $"Windows ML provider registration failed: {ex.Message}");
    Console.Error.WriteLine(
        "Continuing to test the model with the providers available to the runtime.");
}

モデルをロードして生成する

genai_config.jsonがあるディレクトリをModelへ渡します。

using var model = new Model(modelDirectory);

using var parameters = new GeneratorParams(model);
parameters.SetSearchOption("max_length", 256);
parameters.SetSearchOption("batch_size", 1);
parameters.SetSearchOption("do_sample", false);

using var generator = new Generator(model, parameters);
generator.AppendTokens(inputIds);

while (!generator.IsDone())
{
    generator.GenerateNextToken();
}

生成されたシーケンスには入力プロンプトも含まれるため、入力トークン数以降だけを取り出します。

var outputSequence = generator.GetSequence(0);
var generatedSequence = outputSequence[inputIds.Length..].ToArray();
var translation = tokenizer.Decode(generatedSequence).Trim();

実行する

プロジェクトのビルドは次のコマンドで行います。

dotnet build src\CatTranslate.Poc\CatTranslate.Poc.csproj `
  --configuration Release

実行時にはモデルフォルダー、入力言語、出力言語、原文を指定します。

dotnet run `
  --project src\CatTranslate.Poc\CatTranslate.Poc.csproj `
  --configuration Release `
  -- artifacts\cat-translate-1.4b-int4-cpu `
  Japanese `
  English `
  "🐈はとてもかわいいの。おててがまるくてふわふわなの。"

Execution Providerの登録をスキップして、ランタイムに利用可能なプロバイダーだけで確認する場合は、次の環境変数を設定します。

$env:CAT_TRANSLATE_SKIP_EP_REGISTRATION = "1"

CPUとDirectMLで実行する

同じC#プログラムから、モデルフォルダーを切り替えて3種類のモデルを実行しました。

モデル 実行先 ロード時間 生成時間
FP32 CPU 11.096秒 10.457秒
INT4 CPU 4.579秒 1.825秒
FP16 DirectML 4.931秒 1.065秒

実行環境や初回実行の状態で時間は変わるため、上記は今回の環境での参考値です。

出力例は次のとおりです。

The plush toy 1 is very cute. Its hands are round and fluffy.

INT4 CPUでは、次のような出力になりました。

The plush toy Re1 is very cute. Its hands are round and fluffy.

DirectMLの警告

DirectML実行時には、DirectX 12 Agility SDKに関する警告が表示されました。

Unable to create a device from version 1.614.0 of the DirectX 12 Agility SDK...

今回は警告が出ても生成処理は完了しました。ただし、実際にアプリケーションへ組み込む場合は、Windowsのバージョン、GPUドライバー、Windows MLの配布方法を確認する必要があります。

うまくいかなかったこと

ORT tokenizerをそのまま使えなかった

最初はONNX Runtime GenAIのTokenizerApplyChatTemplateを使う構成を考えていました。

しかし、元モデルのtokenizerはSentencePiece Unigramで、Model Builderが出力したtokenizerはBPEでした。元のtokenizer.jsonをコピーしてORTのTokenizerで読む方法も試しましたが、形式の違いによるエラーになりました。

そのため、最終的には次の構成にしました。

  • tokenizer: Microsoft.ML.Tokenizers.SentencePieceTokenizer
  • プロンプト: C#で手動作成
  • 生成: ONNX Runtime GenAIのGenerator
  • デコード: SentencePieceTokenizer

PythonとC#の出力が異なった

同じ入力文を使っても、PythonとC#で出力が完全には一致しませんでした。

Python側の出力例:

🐈 is very cute. Its hands are round and fluffy.

C#側の出力例:

The plush toy 1 is very cute. Its hands are round and fluffy.

C#側でもモデルのロードと生成は成功していますが、現時点では完全な再現性を確認できていません。

考えられる要因は次のとおりです。

  • PythonとC#で利用しているtokenizer実装の違い
  • CPUとDirectMLなどExecution Providerの違い
  • FP32、INT4、FP16の量子化形式の違い
  • ONNX Runtime GenAIとWindows MLのランタイム差

正式に品質評価を行う場合は、複数の固定文でPythonとC#の出力を比較する必要があります。

まとめ

.NET 10のC#コンソールアプリから、CAT-Translate-1.4bをWindows ML / ONNX Runtime GenAIで実行できました。

今回のポイントは次のとおりです。

  • .NET 10とx64を指定したC#プロジェクトを作成する
  • Microsoft.ML.OnnxRuntimeGenAI.WinMLを利用する
  • Windows MLのExecution Providerを登録する
  • 元モデルのSentencePiece tokenizerをC#側で使う
  • Generator.AppendTokensへ入力IDを渡す
  • CPUとDirectMLでモデル形式を比較する

今回のコードはコンソールアプリのPoCです。今後は、翻訳サービス化、ストリーミング出力、キャンセル処理、モデルのキャッシュなどを追加していきたいと思います。

参考

CAT-Translate-1.4bをONNXへ変換してWindows MLで使えるモデルを作る

初めに

日本語と英語の翻訳モデルである CAT-Translate-1.4b を、Windows上のC#アプリケーションから動かしたくなりました。

最終的には.NET 10とWindows MLを使う予定ですが、Windows MLから直接Safetensorsのモデルを読むことはできません。そこで、まずONNX Runtime GenAIで扱える形式へ変換します。

この記事では、Pythonをモデル変換と検証に限定して使い、uvで環境を管理します。

今回確認した結果は次のとおりです。

  • CAT-Translate-1.4bをONNX Runtime GenAI形式へ変換できた
  • FP32 CPU、INT4 CPU、FP16 DirectMLの3種類を作成できた
  • 変換後のモデルをPythonから検証できた
  • tokenizerは元モデルと変換後モデルで形式が異なり、追加対応が必要だった

開発環境

項目 バージョン / 構成
OS Windows
Python 3.13
Python環境 uv
PyTorch 2.13.0
Transformers 5.15.0
ONNX Runtime GenAI 0.15.2
Tokenizers 0.22.2
ONNX IR 1.0.0

依存関係はプロジェクトのpyproject.tomlで管理しています。

[project]
name = "cat-translate-cs-tools"
version = "0.1.0"
requires-python = ">=3.13,<3.14"
dependencies = [
    "huggingface-hub>=0.30",
    "onnx-ir>=1.0.0",
    "onnxruntime-genai>=0.11,<0.16",
    "tokenizers>=0.22.2",
    "torch>=2.13.0",
    "transformers>=5.15.0",
]

[tool.uv]
package = false

環境の作成と依存パッケージのインストールはuv syncで行います。

uv sync

ONNXへ変換する

Model Builderを使う

今回は、ONNX Runtime GenAIに含まれているModel Builderを使いました。

通常のONNXモデルでは、入力テンソルを渡して出力テンソルを受け取るだけで済む場合があります。しかし、CAT-Translate-1.4bのような生成モデルでは、KV cacheや生成ループなども必要です。

Model Builderを使うと、ONNXモデルだけではなく、ONNX Runtime GenAIが利用するgenai_config.jsonやtokenizer関連ファイルを含むモデルフォルダーを作成できます。

INT4 CPUモデルを作成する

まず、CPU向けのINT4モデルを作成します。

uv run python -m onnxruntime_genai.models.builder `
  -m cyberagent/CAT-Translate-1.4b `
  -o artifacts/cat-translate-1.4b-int4-cpu `
  -e cpu `
  -p int4 `
  -c artifacts/hf-cache

-mはHugging Faceのモデル名、-oは出力先、-eはExecution Provider、-pは重みの形式です。-cにはHugging Faceのキャッシュディレクトリを指定しています。

FP32 CPUモデルを作成する

品質確認用として、FP32のCPUモデルも作成しました。

uv run python -m onnxruntime_genai.models.builder `
  -m cyberagent/CAT-Translate-1.4b `
  -o artifacts/cat-translate-1.4b-fp32-cpu `
  -e cpu `
  -p fp32 `
  -c artifacts/hf-cache

FP16 DirectMLモデルを作成する

DirectMLでの実行候補として、FP16モデルも作成しました。

uv run python -m onnxruntime_genai.models.builder `
  -m cyberagent/CAT-Translate-1.4b `
  -o artifacts/cat-translate-1.4b-fp16-dml `
  -e dml `
  -p fp16 `
  -c artifacts/hf-cache

今回の変換では、以下の3種類のモデルフォルダーを作成できました。

出力先 実行対象 形式
artifacts/cat-translate-1.4b-fp32-cpu CPU FP32
artifacts/cat-translate-1.4b-int4-cpu CPU INT4
artifacts/cat-translate-1.4b-fp16-dml DirectML FP16

出力フォルダーには、主に次のようなファイルが含まれます。

cat-translate-1.4b-fp32-cpu/
├── genai_config.json
├── model.onnx
├── model.onnx.data
├── tokenizer.json
├── tokenizer.model
├── tokenizer_config.json
└── chat_template.jinja

実際のファイル分割は変換形式やModel Builderのバージョンによって異なります。C#から読み込むときは、少なくともgenai_config.json、ONNXモデル本体、tokenizer.modelが必要になります。

変換したモデルを検証する

まず、Pythonから変換後のモデルを読み込みます。

今回の検証スクリプトでは、生成されたtokenizer.jsontokenizers.Tokenizerで読み込み、入力IDを作ってからONNX Runtime GenAIへ渡しています。

uv run python scripts/validate_manual_tokenizer.py `
  --model artifacts/cat-translate-1.4b-int4-cpu `
  --source-language Japanese `
  --target-language English `
  --text "🐈はとてもかわいいの。おててがまるくてふわふわなの。"

プロンプトは次の形にしています。

<|user|>Translate the following Japanese text into English.

🐈はとてもかわいいの。おててがまるくてふわふわなの。</s><|assistant|>

この入力に対して、Python側では次のような出力を確認できました。

🐈 is very cute. Its hands are round and fluffy.

この段階では、ONNXへの変換と生成モデルとしてのロードは成功しています。

Transformersの非推奨警告に対応する

変換前の検証コードでは、次のような警告が表示されました。

[transformers] `torch_dtype` is deprecated! Use `dtype` instead!

現在のTransformersでは、pipelineなどへモデルのデータ型を渡す場合、torch_dtypeではなくdtypeを使います。

from transformers import pipeline
import torch

translator = pipeline(
    "text-generation",
    model=model,
    tokenizer=tokenizer,
    dtype=torch.bfloat16,
)

古い引数を残したままにせず、最新のAPIに合わせてdtypeへ変更しました。

tokenizerで詰まったこと

元モデルと変換後モデルで形式が違う

CAT-Translate-1.4bの元モデルに含まれるtokenizerはSentencePiece Unigramです。

一方、Model Builderが出力したtokenizer.jsonはBPE形式になっていました。

tokenizer モデル形式
元モデルのtokenizer.json Unigram
Model Builder出力のtokenizer.json BPE

そのため、元モデルのtokenizer.jsonを変換後のフォルダーへコピーして、ONNX Runtime GenAIのTokenizerで読み込もうとするとエラーになります。

type must be object, but is array

これは、Unigramの語彙が配列形式で保存されているのに対し、ORT側がBPE形式のオブジェクトを期待しているためです。

C#では元のSentencePiece tokenizerを使う

この問題への対応は、記事2で詳しく説明します。方針としては、C#側で元のtokenizer.modelMicrosoft.ML.Tokenizers.SentencePieceTokenizerに読み込ませます。

その後、C#側で次の処理を行います。

  1. CAT-Translateのプロンプトを組み立てる
  2. 元のSentencePiece tokenizerで入力IDへ変換する
  3. Generator.AppendTokensでONNX Runtime GenAIへ渡す
  4. SentencePiece tokenizerで生成結果をデコードする

つまり、ONNXモデルの生成部分はONNX Runtime GenAIで動かし、tokenizerだけは元モデルのものを使う構成です。

まとめ

CAT-Translate-1.4bをONNX Runtime GenAIのモデル形式へ変換できました。

今回作成したモデルは次の3種類です。

  • FP32 CPU
  • INT4 CPU
  • FP16 DirectML

ONNXモデルの変換自体は問題なく完了しました。一方で、tokenizerは元モデルがSentencePiece Unigram、変換後がBPEとなるため、C#側では元のtokenizer.modelを使う必要があります。

次の記事では、今回作成したモデルを.NET 10とWindows MLを使ってC#から実行します。

参考