日本語の音声認識を、自分の音声で測る方法をまとめます。題材は、ローカルで動く音声認識の Moonshine Voice(日本語の現行モデル Small Streaming)と、比較用の faster-whisper です。
測った結果と考察は、本家のブログにまとめました。
この記事は、その測定に使ったコードと、つまずいた点の実装編です。コードは、そのままコピーして動かせるように、最小の形にしています。
準備
python3 -m venv venv
./venv/bin/pip install moonshine-voice faster-whisper numpy
測る音声は、16 kHz・モノラル・16 bit の WAV にします。手元の音声は、ffmpeg で変換できます。
ffmpeg -y -i input.m4a -ar 16000 -ac 1 -c:a pcm_s16le sample.wav
動作確認に使ったバージョンは、moonshine-voice 0.1.5 と faster-whisper 1.2.1 です。
Moonshine でストリーミング認識して、応答速度を測る
「応答速度」を、次の時間として測ります。
最後の行が確定した時刻 − その行の発話が終わった時刻
Moonshine は、音声を少しずつ受け取りながら認識します。そこで、音声を実時間の速さで流し込み、行が確定した時刻を記録します。
import json, sys, time, wave
import numpy as np
from moonshine_voice import Transcriber, TranscriptEventListener
from moonshine_voice.download import get_model_for_language
def load_wav(path):
"""16 kHz・モノラル・16bit の WAV を float32 の配列にする"""
with wave.open(path) as w:
assert w.getframerate() == 16000 and w.getnchannels() == 1
raw = w.readframes(w.getnframes())
return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768
def transcribe_stream(path):
audio = load_wav(path)
model_path, arch = get_model_for_language("ja") # 初回にモデルをダウンロード
moon = Transcriber(model_path, arch, options={"max_tokens_per_second": 13})
done = [] # (確定した時刻, 行の開始, 行の長さ, 文字)
t0 = 0.0
class Listener(TranscriptEventListener):
def on_line_completed(self, e):
done.append((time.perf_counter() - t0, e.line.start_time, e.line.duration, e.line.text))
stream = moon.create_stream(update_interval=0.2)
stream.add_listener(Listener())
stream.start()
t0 = time.perf_counter()
for i in range(0, len(audio), 1600): # 100 ms ずつ、実時間の速さで流し込む
stream.add_audio(audio[i:i + 1600].tolist(), 16000)
wait = t0 + (i + 1600) / 16000 - time.perf_counter()
if wait > 0:
time.sleep(wait)
stream.stop()
text = "".join(d[3] for d in done)
# 応答速度 = 最後の行が確定した時刻 − その行の発話が終わった時刻
latency = max(0.0, done[-1][0] - (done[-1][1] + done[-1][2])) if done else None
return text, latency
if __name__ == "__main__":
text, latency = transcribe_stream(sys.argv[1])
print(json.dumps({"text": text, "latency": latency}, ensure_ascii=False))
実行例です。
./venv/bin/python measure.py sample.wav
{"text": "医師である田中先生の意思決定はいつも迅速で的確です", "latency": 0.24}
日本語は max_tokens_per_second を 13 にする
開発元のドキュメントでは、max_tokens_per_second の既定値は 6.5 です。日本語のように、1 秒あたりのトークン数が多くなる言語では、約 13 にするよう推奨されています。上のコードでは、options で 13 を指定しています。
筆者の測定(合成音声 16 文、公開データセット FLEURS 20 件、自分の声 8 文)では、6.5 と 13 で、文字誤り率(CER)は小数第 3 位まで同じでした。今回の音声では差が出ませんでしたが、ドキュメントの推奨に従っておく方が安全です。
Whisper(faster-whisper)の時間を測る
Whisper は、クリップ全体を渡して、結果が出るまでの時間を測ります。transcribe() はジェネレーターを返すので、文字を取り出すまで処理が進みません。時間は、join で取り出した後に測ります。
import sys, time
from faster_whisper import WhisperModel
from measure import load_wav
model = WhisperModel("small", device="cpu", compute_type="int8")
model.transcribe(load_wav(sys.argv[1]), language="ja") # ウォームアップ(結果は捨てる)
audio = load_wav(sys.argv[1])
t0 = time.perf_counter()
segments, _ = model.transcribe(audio, language="ja", beam_size=5)
text = "".join(s.text for s in segments) # ここで実際の処理が終わる
print(text, round(time.perf_counter() - t0, 2))
初回はモデルの読み込みで遅くなるので、1 回ウォームアップしてから測ります。
CER(文字誤り率)を計算する
CER は、正解文との編集距離を、正解文の文字数で割った値です。句読点と空白は除き、全角と半角は揃えます。
import re, unicodedata
def normalize(t):
t = unicodedata.normalize("NFKC", t)
return re.sub(r"[、。,.\s!!??「」『』()()【】\-—・::;;]", "", t)
def levenshtein(a, b):
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
def cer(ref, hyp):
ref, hyp = normalize(ref), normalize(hyp)
return levenshtein(ref, hyp) / len(ref)
print(round(cer("医師である田中先生の意思決定はいつも迅速で的確です。",
"医師である田中先生の医師決定はいつの迅速で的確です"), 3)) # 0.12
注意点が 1 つあります。「三百二十八万五千円」を「328万5000円」と書かれると、意味は合っているのに、CER は大きくなります。数字の表記を揃える後処理を入れてから測らないと、この種の誤りが、比較を歪めます。
つまずいた点
同じプロセスで連続して処理すると、空の結果が返ることがあった
20 件の音声を、1 つの Python プロセスで続けて処理したところ、3 件で、Moonshine の結果が空になりました。同じ音声を単独で処理すると、正しく認識されました。
原因は特定していません。クリップごとに別のプロセスで処理すると、空の結果は出なくなりました。
for f in clips/*.wav; do ./venv/bin/python measure.py "$f"; done
長時間動かすアプリに組み込むときは、連続処理で問題が出ないかを確認してください。
日本語は、短い行に分かれる
Moonshine は、息継ぎや間で行を区切ります。日本語の文章をそのまま読み上げると、短い行が並びます。文単位で扱いたいときは、後処理が要ります。上のコードは、行を単純に連結しています。
ライブラリの表示とライセンス
非英語のモデルを読み込むと、「非商用のライセンスのモデルを使っています」という表示が出ます。公式リポジトリの LICENSE では、現行のストリーミングモデルは、日本語を含めて MIT で、非商用のライセンスが残っているのは旧型の一部だけです。表示と LICENSE が食い違っているので、業務で使う前に、公式に確認してください。
測定結果の目安
Linux の CPU(16 コア)で、筆者が測った値です。詳しい条件と考察は、本家の記事にあります。
| 音声 | Moonshine の CER | Whisper small の CER | Moonshine の応答速度 | Whisper small の応答速度 |
|---|---|---|---|---|
| 合成音声(8 文) | 0.092 | 0.104 | 0.18 秒 | 1.31 秒 |
| FLEURS の読み上げ(20 件) | 0.110 | 0.155 | 0.30 秒 | 1.55 秒 |
| 筆者の声(8 文) | 0.104 | 0.106 | 0.21 秒 | 2.25 秒 |
サンプル数が少ないので、傾向の目安として読んでください。また、Moonshine は音声を実時間で流した値、Whisper はクリップ全体の処理時間で、測り方が違います。

