はじめに
音声ファイルの発話内容をカナで認識するカナASRを複数の方法で試してみました。
背景
普通の音声認識(ASR)では文脈も考慮して自然な文章が出力されることが多いですが、固有名詞の聞き取りなど、とにかく聞こえたままに書き起こしてほしい場面もあります。
そこで、認識結果をカナで得るカナASRを以下の2つの方法で試してみました。
- 後処理でカナ変換: 通常のASRモデルで認識した結果を後処理でカタカナに変換
- トークン制限: ASRモデルのデコード時にカタカナトークンのみに制限
それぞれの手法について、通常のASRモデルとひらがなASRモデルの両方で検証し、精度を比較してみます。
準備
認識対象サンプルとして、統計的に生成したカタカナのでたらめ文字列(gibberish)をVOICEVOXで音声合成したものを用いました。これにより、実際の単語に依存しない純粋な音韻認識性能を評価しやすいと考えました。
後処理でカナ変換
まず最も簡単にできる方法として、普通のASR出力を後処理でカナに変換してみます。
- ASRモデルは"reazon-research/japanese-wav2vec2-base-rs35kh"と"AndrewMcDowell/wav2vec2-xls-r-1b-japanese-hiragana-katakana"を試します。前者は普通のASRモデル、後者はカナ化されたスクリプトで学習されたカナを出力するASRモデルです。
- 認識結果をカナにする後処理にpyopenjtalk-plusを用います。
コードは以下のような感じです。
"""
音声認識してpyopenjtalkでカタカナに変換するスクリプト
Supported models:
- AndrewMcDowell/wav2vec2-xls-r-1b-japanese-hiragana-katakana
- reazon-research/japanese-wav2vec2-base-rs35kh
Usage:
python recognize_kana_by_conversion.py path/to/audio.wav
python recognize_kana_by_conversion.py path/to/audio.wav --model reazon
"""
import argparse
import re
import sys
import librosa
import numpy as np
import pyopenjtalk
import torch
from transformers import AutoProcessor, Wav2Vec2ForCTC
# モデル設定
MODEL_CONFIGS = {
"andrewmcdowell": {
"model_id": "AndrewMcDowell/wav2vec2-xls-r-1b-japanese-hiragana-katakana",
},
"reazon": {
"model_id": "reazon-research/japanese-wav2vec2-base-rs35kh",
},
}
TARGET_SR = 16_000
def text_to_katakana(text: str) -> str:
"""テキストをpyopenjtalkでカタカナに変換し、カタカナのみを抽出"""
# 読み仮名を取得
phonemes = pyopenjtalk.g2p(text, kana=True)
# カタカナ(ア-ヴ)と長音記号(ー)のみを抽出
katakana_pattern = r"[ア-ヴー]+"
katakana_matches = re.findall(katakana_pattern, phonemes)
return "".join(katakana_matches)
def load_audio_mono_16k(path: str, pad_sec: float) -> np.ndarray:
"""音声ファイルを16kHzモノラルで読み込み、オプションでパディング追加"""
wav, _ = librosa.load(path, sr=TARGET_SR, mono=True)
if pad_sec and pad_sec > 0:
pad = int(pad_sec * TARGET_SR)
wav = np.pad(wav, pad_width=pad)
# クリッピング対策
if np.max(np.abs(wav)) > 1.0:
wav = wav / (np.max(np.abs(wav)) + 1e-8)
return wav.astype(np.float32)
def setup_model_and_processor(
model_name: str, device: torch.device
) -> tuple[Wav2Vec2ForCTC, AutoProcessor]:
"""モデルとプロセッサーをセットアップ"""
config = MODEL_CONFIGS[model_name]
model_id = config["model_id"]
print(f"[INFO] Loading model: {model_id}")
processor = AutoProcessor.from_pretrained(model_id)
model = Wav2Vec2ForCTC.from_pretrained(model_id)
# デバイス設定(float32固定)
model = model.to(device)
return model, processor
def transcribe_audio(
audio_path: str,
model_name: str = "andrewmcdowell",
device: torch.device | None = None,
) -> tuple[str, str]:
"""音声ファイルを音声認識してpyopenjtalkでカタカナに変換"""
if device is None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 決め打ち設定
pad_sec = 0.5 # 0.5秒パディング固定
# モデルとプロセッサーの準備
model, processor = setup_model_and_processor(model_name, device)
print("[INFO] Using ASR without vocabulary restriction")
# 音声読み込み
wav = load_audio_mono_16k(audio_path, pad_sec=pad_sec)
# 入力準備
inputs = processor(wav, sampling_rate=TARGET_SR, return_tensors="pt")
# デバイスに移動
for key in inputs:
if isinstance(inputs[key], torch.Tensor):
inputs[key] = inputs[key].to(device)
# 推論(語彙制限なし)
with torch.inference_mode():
logits = model(**inputs).logits # [B, T, V]
pred_ids = torch.argmax(logits.detach().float().cpu(), dim=-1)[0]
raw_text = processor.decode(pred_ids, skip_special_tokens=True)
# pyopenjtalkでカタカナに変換(カタカナのみ抽出済み)
katakana_only = text_to_katakana(raw_text)
return raw_text, katakana_only
def main():
ap = argparse.ArgumentParser(description="Unified Kana-first ASR")
ap.add_argument("audio", help="入力音声ファイル(wav/その他librosa対応)")
ap.add_argument(
"--model",
choices=list(MODEL_CONFIGS.keys()),
default="andrewmcdowell",
help="使用するモデル (default: andrewmcdowell)",
)
args = ap.parse_args()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"[INFO] device={device}, model={args.model}")
# 転写実行
try:
raw_text, katakana_only = transcribe_audio(
args.audio, model_name=args.model, device=device
)
print("\n=== Results ===")
print(f"Raw ASR Output: {raw_text}")
print(f"Katakana (only): {katakana_only}")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
通常ASR
実行してみます。
% uv run scripts/recognize_kana_by_conversion.py datasets/sample/wav/gibberish_10a97e2e-4ea7-492c-8a27-0667a5c52138_ノコーカンオブッシュツ カイルダーヤキモロニセキホーレテオールラカア.wav --model reazon
Warning: ONNX Runtime is not installed. Nani prediction will be disabled.
Please install ONNX Runtime by `pip install pyopenjtalk-plus[onnxruntime]`
[INFO] device=cpu, model=reazon
[INFO] Loading model: reazon-research/japanese-wav2vec2-base-rs35kh
[INFO] Using ASR without vocabulary restriction
=== Results ===
Raw ASR Output: マこうかを物シカルダー焼き物にせきほれておるラか。
Katakana (only): マコーカヲモノシカルダーヤキモノニセキホレテオルラカ
正解文字列はファイル名にもある「ノコーカンオブッシュツ カイルダーヤキモロニセキホーレテオールラカア」です。
Raw ASR Outputをみると、全体的に音韻の特徴は捉えていますが、少し、「ヤキモロ -> 焼き物」など文としての自然さに引きずられている箇所もあります。
また、「物シ」は正解との対応を踏まえると「ブツシ」と読まれるべきなのだと思われますが、後処理のカナ変換で「モノシ」になってしまっていました。
ひらがなASR
次に、ひらがなに特化したASRモデルを使用してみます。
% uv run scripts/recognize_kana_by_conversion.py datasets/sample/wav/gibberish_10a97e2e-4ea7-492c-8a27-0667a5c52138_ノコーカンオブッシュツ カイルダーヤキモロニセキホーレテオールラカア.wav
Warning: ONNX Runtime is not installed. Nani prediction will be disabled.
Please install ONNX Runtime by `pip install pyopenjtalk-plus[onnxruntime]`
[INFO] device=cpu, model=andrewmcdowell
[INFO] Loading model: AndrewMcDowell/wav2vec2-xls-r-1b-japanese-hiragana-katakana
[INFO] Using ASR without vocabulary restriction
=== Results ===
Raw ASR Output: まこうかんをぶっしっかいるだんやきものにせきほおれておれらか
Katakana (only): マコーカンヲブッシッカイルダンヤキモノニセキホオレテオレラカ
通常ASR同様に、全体的には音韻の特徴をよく捉えていますが、「ヤキモロ」は通常ASRと同じく「ヤキモノ(焼き物?)」になっていました。単語の自然さが優先される傾向が少しありそうです。
一方、通常ASRで「モノシ」となっていた箇所は、最初からカナで出力されるため「ブッシッ」とより正解に近い音韻になっていました。
トークン制限
後処理で読みを推定するのではなく、ASRモデルのデコード時に、カタカナトークンのみを許可する語彙制限を適用する方法です。
"""
Unified Kana-first ASR supporting multiple models.
Supported models:
- AndrewMcDowell/wav2vec2-xls-r-1b-japanese-hiragana-katakana
- reazon-research/japanese-wav2vec2-base-rs35kh
Settings (fixed):
- dtype: float32 (for stability)
- padding: 0.5sec before/after audio
Usage:
python recognize_kana.py path/to/audio.wav
python recognize_kana.py path/to/audio.wav --model reazon
"""
import argparse
import sys
import jaconv
import librosa
import numpy as np
import torch
from transformers import AutoProcessor, Wav2Vec2ForCTC
# モデル設定
MODEL_CONFIGS = {
"andrewmcdowell": {
"model_id": "AndrewMcDowell/wav2vec2-xls-r-1b-japanese-hiragana-katakana",
},
"reazon": {
"model_id": "reazon-research/japanese-wav2vec2-base-rs35kh",
},
}
TARGET_SR = 16_000
def is_kana_character(char: str) -> bool:
"""ひらがな・カタカナ・長音符かどうか判定"""
code = ord(char)
# ひらがな (0x3041-0x3096) + カタカナ (0x30A0-0x30FF)
return (0x3041 <= code <= 0x3096) or (0x30A0 <= code <= 0x30FF)
def create_kana_vocabulary_mask(
processor: AutoProcessor, model_name: str
) -> torch.Tensor:
"""モデルに応じてかな文字のみの語彙マスクを作成"""
vocab = processor.tokenizer.get_vocab()
vocab_size = len(vocab)
allowed_mask = torch.ones(vocab_size, dtype=torch.bool)
for token, token_id in vocab.items():
# 特殊トークンは保持
if token.startswith("<") and token.endswith(">"):
continue
if token in ["[UNK]", "[PAD]", "[CLS]", "[SEP]", "|"]:
continue
# 読点・句読点を除外
if token in ["、", "。", ",", ".", ",", ".", "!", "?", "!", "?", "・"]:
allowed_mask[token_id] = False
continue
# モデルごとのトークン処理
check_token = token.lstrip("▁") if model_name == "reazon" else token
# トークンが全てかな文字でない場合は除外
if (
check_token
and not all(
is_kana_character(c) or c in [" ", " "]
for c in check_token
if c.strip()
)
and check_token.strip()
):
allowed_mask[token_id] = False
return allowed_mask
def load_audio_mono_16k(path: str, pad_sec: float) -> np.ndarray:
"""音声ファイルを16kHzモノラルで読み込み、オプションでパディング追加"""
wav, _ = librosa.load(path, sr=TARGET_SR, mono=True)
if pad_sec and pad_sec > 0:
pad = int(pad_sec * TARGET_SR)
wav = np.pad(wav, pad_width=pad)
# クリッピング対策
if np.max(np.abs(wav)) > 1.0:
wav = wav / (np.max(np.abs(wav)) + 1e-8)
return wav.astype(np.float32)
def setup_model_and_processor(
model_name: str, device: torch.device
) -> tuple[Wav2Vec2ForCTC, AutoProcessor]:
"""モデルとプロセッサーをセットアップ"""
config = MODEL_CONFIGS[model_name]
model_id = config["model_id"]
print(f"[INFO] Loading model: {model_id}")
processor = AutoProcessor.from_pretrained(model_id)
model = Wav2Vec2ForCTC.from_pretrained(model_id)
# デバイス設定(float32固定)
model = model.to(device)
return model, processor
def transcribe_audio(
audio_path: str,
model_name: str = "andrewmcdowell",
device: torch.device | None = None,
) -> str:
"""音声ファイルをかな文字で転写"""
if device is None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 決め打ち設定
pad_sec = 0.5 # 0.5秒パディング固定
# モデルとプロセッサーの準備
model, processor = setup_model_and_processor(model_name, device)
# 語彙マスクの作成
vocab_mask = create_kana_vocabulary_mask(processor, model_name)
vocab_mask = vocab_mask.to(device)
allowed_count = vocab_mask.sum().item()
total_count = len(vocab_mask)
print(
f"[INFO] Vocabulary restricted to kana characters. "
f"Allowed tokens: {allowed_count}/{total_count}"
)
# 音声読み込み
wav = load_audio_mono_16k(audio_path, pad_sec=pad_sec)
# 入力準備
inputs = processor(wav, sampling_rate=TARGET_SR, return_tensors="pt")
# デバイスに移動
for key in inputs:
if isinstance(inputs[key], torch.Tensor):
inputs[key] = inputs[key].to(device)
# 推論
with torch.inference_mode():
logits = model(**inputs).logits # [B, T, V]
# 語彙制限を適用
logits = logits.masked_fill(
~vocab_mask.unsqueeze(0).unsqueeze(0), float("-inf")
)
pred_ids = torch.argmax(logits.detach().float().cpu(), dim=-1)[0]
text = processor.decode(pred_ids, skip_special_tokens=True)
# カタカナ統一
text = jaconv.hira2kata(text)
return text
def main():
ap = argparse.ArgumentParser(description="Unified Kana-first ASR")
ap.add_argument("audio", help="入力音声ファイル(wav/その他librosa対応)")
ap.add_argument(
"--model",
choices=list(MODEL_CONFIGS.keys()),
default="andrewmcdowell",
help="使用するモデル (default: andrewmcdowell)",
)
args = ap.parse_args()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"[INFO] device={device}, model={args.model}")
# 転写実行
try:
text = transcribe_audio(args.audio, model_name=args.model, device=device)
print("\n=== Transcription ===")
print(text)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
通常ASR
全体的に音韻の特徴は捉えられています。
% uv run scripts/recognize_kana_by_filter.py datasets/sample/wav/gibberish_10a97e2e-4ea7-492c-8a27-0667a5c52138_ノコーカンオブッシュツカイルダーヤキモロニセキホーレテオールラカア.wav --model reazon
[INFO] device=cpu, model=reazon
[INFO] Loading model: reazon-research/japanese-wav2vec2-base-rs35kh
[INFO] Vocabulary restricted to kana characters. Allowed tokens: 512/3003
=== Transcription ===
マコウカヲブシカルダーヤキモノニセキホレテオルラカ
ひらがなASR
全体的に音韻の特徴を捉えられています。
促音(ッ)がある分、通常ASRよりも、正解テキストにより近い印象があります。
% uv run scripts/recognize_kana_by_filter.py datasets/sample/wav/gibberish_10a97e2e-4ea7-492c-8a27-0667a5c52138_ノコーカンオブッシュツカイ ルダーヤキモロニセキホーレテオールラカア.wav
[INFO] device=cpu, model=andrewmcdowell
[INFO] Loading model: AndrewMcDowell/wav2vec2-xls-r-1b-japanese-hiragana-katakana
[INFO] Vocabulary restricted to kana characters. Allowed tokens: 163/181
=== Transcription ===
マコウカンヲブッシッカイルダンヤキモノニセキホオレテオレラカ
精度比較
条件
上で見てきた4条件でCharacter Error Rate(CER)を比較してみます。
- 通常ASR-後処理カナ変換
- カナASR-後処理カナ変換
- 通常ASR-トークン制限
- カナASR-トークン制限
サンプル
サンプルは以下の5文(5単語?)とします。
- ノコーカンオブッシュツカイルダーヤキモロニセキホーレテオールラカア
- イウルシンガワブンシタイオーアイヨネンカイメイレマシキタライテオ
- ションニネンオアリソダササファーノタノヒッテイオツカイトデツエナワイ
- タチネッタイウスタトワキランショーアルカラクテーエントージュツワマク
- アプロクスクトサイオードーオーセンゼオトジョーオトレビューイチノイイ",
wavファイルの生成にはvoicevoxのspeaker_id=3(ずんだもんノーマル)を用いました。
結果
5サンプルの平均CER(低いほど良い)は以下の通りでした。
- 通常ASR-後処理カナ変換: 0.3231
- カナASR-後処理カナ変換: 0.2150
- 通常ASR-トークン制限: 0.3646
- カナASR-トークン制限: 0.2630
考察
通常ASRよりもカナASRのほうが、トークン制限よりも後処理カナ変換のほうがCERが低く良い精度という結果でした。
カナASRが高精度であることは主観と一致します。
一方、トークン制限のほうが後処理カナ変換より主観的には優れている印象でしたが、逆の結果でした。ただ、pyopenjtalkのカナ変換のミスが生じない限りは同程度になる気もするので、今回の5サンプル程度だと、カナ変換ミスが起こらなかったのかもしれません
指標について、今回は文字編集距離を用いましたが、以下のような改良は考えられます。
- モウラ単位で編集距離を求める
- 母音や子音の一致を考慮する(ローマ字で比べるなど)
- 母音や子音の類似度を考慮する
おわりに
モデルのチューニングなしでできるいくつかのカナASRを試してみました。
実験結果からはカナ出力ようのASRモデルを使うほうが有用なことが示唆されました。
ただ、出力をカナに制限するタイミングの検討と合わせて、もう少し、サンプル増やして検証したほうがよさそうです。