読み上げ音声で解説動画を作っていたら、音声合成 API(Fish Audio)が「一行」を「いっこう」と読みました。コードは生成AIに書かせているので、作業の途中で音声を聴く人がいません。そこで、読みを聴かずに確かめる方法を作りました。
複合語になると読みが変わる
| 書いた語 | 読まれ方 |
|---|---|
| 一行 | いっこう(正しくは、いちぎょう) |
| 3D(単体) | スリーディー(正しい) |
| 3Dプリンタ | サンディープリンタ |
| AI / QR | エーアイ / キューアール(正しい) |
厄介なのは 3D です。単体で試すと正しいのに、「3Dプリンタ」になると読みが変わります。単語だけ試して安心すると見逃します。
直し方は簡単で、かなで書きます(字幕に出ない行なら画面に影響しません)。問題は、直すべき語をどう見つけるかです。
方法:候補の読みと音を比べる
- 確かめたい語と、候補の読み(かな)を、それぞれ3回ずつ合成する
- 対数メルスペクトルにする
- 総当たりで DTW 距離を測り、候補ごとに平均する
- 対象どうしの距離(基準線)に近い候補が、実際の読み
DTW は、話す速さが違っても時間軸を伸び縮みさせて比べる方法です。
音声の長さの比較は使えません。Fish Audio では同じ語でも 0.74〜0.94 秒と揺れました。
# 使い方: python3 compare_reading.py target_*.wav -- yomiA_*.wav -- yomiB_*.wav
import sys, wave
from itertools import product
import numpy as np
def load(path):
with wave.open(path) as w:
x = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(float)
return x / 32768, w.getframerate()
def log_mel(x, sr, n_fft=1024, hop=256, n_mels=40):
frames = np.lib.stride_tricks.sliding_window_view(x, n_fft)[::hop] * np.hanning(n_fft)
power = np.abs(np.fft.rfft(frames)) ** 2
mel = lambda f: 2595 * np.log10(1 + f / 700)
hz = 700 * (10 ** (np.linspace(0, mel(sr / 2), n_mels + 2) / 2595) - 1)
bins = np.floor((n_fft + 1) * hz / sr).astype(int)
fb = np.zeros((n_mels, n_fft // 2 + 1))
for i in range(n_mels):
l, c, r = bins[i:i + 3]
fb[i, l:c] = (np.arange(l, c) - l) / max(c - l, 1)
fb[i, c:r] = (r - np.arange(c, r)) / max(r - c, 1)
s = np.log(power @ fb.T + 1e-10)
return s - s.mean(axis=0) # 音量差を消す
def dtw(a, b):
cost = np.linalg.norm(a[:, None] - b[None, :], axis=2)
d = np.full((len(a) + 1, len(b) + 1), np.inf)
d[0, 0] = 0
for i in range(len(a)):
for j in range(len(b)):
d[i + 1, j + 1] = cost[i, j] + min(d[i, j], d[i, j + 1], d[i + 1, j])
return d[-1, -1] / (len(a) + len(b)) # 長さの差で有利不利が出ないように割る
def avg(xs, ys):
return np.mean([dtw(a, b) for a, b in product(xs, ys) if a is not b])
args = sys.argv[1:]
groups, cur = [], []
for p in args:
if p == "--":
groups.append(cur); cur = []
else:
cur.append(p)
groups.append(cur)
feats = [[log_mel(*load(p)) for p in g] for g in groups]
print(f"基準線(対象どうしのばらつき): {avg(feats[0], feats[0]):.2f}")
for name, f in zip("ABCDEF", feats[1:]):
print(f"候補{name}との距離: {avg(feats[0], f):.2f}")
手元で試す(Mac の say)
このコードの動作確認は、API の料金がかからない macOS の say で行いました。say は同じ条件なら同じ音を返すので、話す速さを変えて3本作ります。音声合成 API を使うときは、同じ文を3回合成します。
gen(){ i=0; for r in 165 180 195; do i=$((i+1)); say -v Kyoko -r $r -o "$1_$i.wav" --data-format=LEI16@22050 "$2"; done; }
gen target "一行"; gen a "いちぎょう"; gen b "いっこう"
python3 compare_reading.py target_*.wav -- a_*.wav -- b_*.wav
結果です。
| 書いた語 | 基準線 | 候補A | 候補B |
|---|---|---|---|
| 一行 | 1.16 | いちぎょう 11.72 | いっこう 0.77 |
| 三百円 | 1.13 | さんびゃくえん 3.06 | さんぜんえん 7.65 |
say でも「一行」は「いっこう」でした。実際、この3本は、かなで「いっこう」と書いて作った音声とファイルが完全に一致しました。
「三百円」は候補Aのほうが近いものの、基準線からは離れています。どの候補も基準線に届かないときは、正しい読みが候補に無いか、抑揚が違う可能性があります。その語は人が聴きます。
注意点
- 実際に喋らせる文の形で確かめる。 上の 3D のように、単語だけの結果は文の中の読みを保証しません
- 候補は人かAIが考えます。読みそのものを当てる仕組みではなく、候補の中から選ぶ仕組みです
おまけ:最後の章が消える
台本を章ごとにシェルで API に投げていたら、5章のうち1章が丸ごと消えました。while read は、改行で終わっていない最終行を読み飛ばします。
while IFS= read -r line || [ -n "$line" ]; do
echo "$line"
done < chapters.txt
|| [ -n "$line" ] を付けると最終行も処理されます。
まとめ
- 音声合成の読み間違いは、複合語になると急に出る
- 候補の読みを合成して DTW 距離で比べれば、聴かずに絞り込める
- 基準線に届かない語だけ、人が聴く
解説動画を音声合成で作る流れをまとめたテンプレート(有料・980円):https://note.com/kaito_cometa/n/n0e017d760a22
生成AI(Claude Code)に書かせて一人で会社を回している記録を note に書いています。
https://note.com/kaito_cometa