話速変更でロボット声になる問題 — フェーズボコーダからWSOLAへ
「声のデザイン」アプリを作っていて、話速(話すスピード)を調整するスライダーを付けました。音高(ピッチ)は変えずに、テンポだけ速く/遅くしたい。よくある要件です。
librosa には librosa.effects.time_stretch という、まさにこのための関数があります。1行で済む。最初はそれを使いました。ところが、少しでもスライダーを動かすと出力にうっすらと金属的な反響が乗る。声がわずかに「ロボットっぽく」「エコーがかかったように」濁るのです。元の声は自然なのに、テンポを変えた瞬間に質感が落ちる。
この記事は、その原因が フェーズボコーダの位相のにじみ だったこと、そして WSOLA(Waveform Similarity Overlap-Add) を numpy で自前実装して解消した記録です。
前提:ピッチを保ってテンポだけ変えるとは
音声を単純に間引いたり水増ししたりすると、再生速度と一緒にピッチも動きます(早送りするとキンキン声になるあれです)。これを避けて テンポだけ 変えるのがタイムストレッチです。
代表的な方式は大きく2つあります。
- フェーズボコーダ(phase vocoder):STFT で周波数領域に変換し、各周波数ビンの位相を進める量を調整して伸縮する。周波数領域で処理する。
- WSOLA:波形(時間領域)を短いフレームに切り、フレーム同士がなめらかにつながる位置を探して重ね合わせる。時間領域で処理する。
librosa の time_stretch は前者、フェーズボコーダです。
症状:金属的な反響(ロボット声)
フェーズボコーダは各周波数ビンを独立に扱い、位相を「あるべき進み量」に更新していきます。理屈のうえではきれいですが、実際の音声では調波成分(倍音)どうしの位相の関係が少しずつ崩れます。これが 位相のコヒーレンス(位相コヒーレンシー)の喪失 です。
耳には、次のような形で現れます。
- 金属的・電子的な響き(俗に phasiness と呼ばれる)
- うっすらとした反響/エコー感
- 母音の芯がぼやけ、声が「合成っぽく」なる
音楽やパーカッシブな素材では気になりにくいのですが、人の声はフォルマントと倍音構造が命なので、この位相のにじみが「ロボット声」として敏感に聞こえてしまいます。しかも伸縮率を上げるほど悪化する。話速スライダーとは相性が悪い、というのが結論でした。
解決:WSOLA を時間領域で
WSOLA は周波数領域に持ち込まず、波形をそのまま切って貼る 方式です。位相を「計算し直す」のではなく、隣り合うフレームが最も自然につながる貼り付け位置を 相互相関で探す。だから位相のにじみが原理的に起きません。
考え方はこうです。
- 出力側は一定間隔(
syn_hop)でフレームを並べていく - 入力側からは、伸縮率に応じた間隔(
ana_hop = syn_hop × rate)で「理想位置」のフレームを取る - ただし理想位置ちょうどではなく、その周辺 ±
tolをずらして、直前に貼ったフレームの続き(自然な連続波形)と最も相似なフレームを探す - 見つけたフレームを Hann 窓で重ね合わせ加算(overlap-add)する
実装(_apply_speed)の心臓部が、この「相似位置の粗探索」です。
ana_hop = int(round(syn_hop * float(rate)))
win = np.hanning(frame).astype(np.float32)
...
# 直前フレームの「自然な続き」= nat
nat = xp[off + prev_ana + syn_hop: off + prev_ana + syn_hop + frame]
best_d, best = 0, -1e18
for d in range(-tol, tol + 1, 8): # ±tol を粗探索して位相整合
cand = xp[off + ideal + d: off + ideal + frame + d]
sc = float(np.dot(cand, nat)) / (float(np.linalg.norm(cand)) + 1e-6)
if sc > best:
best, best_d = sc, d
a = ideal + best_d
seg = xp[off + a: off + a + frame]
y[syn:syn + frame] += seg * win
norm[syn:syn + frame] += win
nat が「直前に貼ったフレームの、波形として自然な続き」です。候補フレーム cand を ±tol の範囲でずらしながら、nat との正規化内積(相互相関)が最大になる位置 best_d を探します。要は「波形の周期がそろう場所」で貼り継ぐわけで、これが位相連続性の担保になります。最後に窓関数の重み norm で割って正規化しています(overlap-add の定石)。
パラメータは frame=1024, syn_hop=512, tol=512 です。探索は range(-tol, tol+1, 8) と8サンプル刻みの粗探索にしてあり、サンプル単位の総当たりはしていません。話速調整はリアルタイム性が要るので、音質と速度のバランスを取った刻みです。
トレードオフ:万能ではない
WSOLA にすれば何でも解決、ではありません。手法選択には素材依存のトレードオフがあります。
- 音声(単一話者の声)には WSOLA が強い。周期性がはっきりしているので相似位置が見つけやすく、位相のにじみが出ない。今回の用途にはこれがベストでした。
- 一方、複雑な多声・音楽・トランジェントが多い素材ではフェーズボコーダのほうが破綻しにくい こともある。WSOLA は「貼り継ぎ位置」を1つに決める以上、複数の周期が混ざると相似探索が迷い、リズムの揺れやダブリングが出ることがあります。
- 伸縮率が極端だと、どちらの方式でもアーティファクトは増える。WSOLA も伸ばしすぎれば同じフレームの反復が目立ちます。
「フェーズボコーダはダメで WSOLA が正義」ではなく、声というピッチのはっきりした素材にはWSOLAが向いていた、という素材適合の話です。
落とし穴と学び
- ライブラリの1行を疑う。
librosa.time_stretchは正しく動いていました。バグではなく、フェーズボコーダという方式の特性(位相のにじみ)が声に合わなかっただけ。「関数が壊れている」ではなく「方式が用途に合わない」を見分けるのが肝でした。 - 時間領域か周波数領域か、を最初に意識する。金属的な響き=位相由来、と当たりをつけられれば、周波数領域処理(フェーズボコーダ)を疑い、時間領域処理(WSOLA)へ乗り換える、という筋道が立ちます。
- 自前実装は数十行で済む。WSOLA の中核は「相互相関で貼り継ぎ位置を探して overlap-add する」だけで、numpy だけで書けます。外部依存を1つ減らせたのも副次的な利点でした。
- 窓とオーバーラップの正規化を忘れない。overlap-add は窓の重み合計
normで割って初めて振幅が正しくなります。ここを省くと音量がうねります。
まとめ
- 話速変更で乗る「ロボット声/金属的な反響」の正体は、フェーズボコーダの 位相のにじみ(phasiness) だった
- 声のようにピッチのはっきりした素材では、位相を計算し直すフェーズボコーダより、波形の相似位置で貼り継ぐ WSOLA が自然
- WSOLA の核心は「直前フレームの自然な続きと相互相関が最大になる位置を ±tol で探して Hann 窓で overlap-add する」こと。numpy だけで数十行
- ただし万能ではない。多声・音楽・トランジェント素材ではフェーズボコーダが有利なこともあり、素材に合わせて選ぶ のが正解
- ライブラリ関数が「壊れている」と疑う前に、その関数が採る 方式(アルゴリズム)が用途に合っているか を確認する