音声変換が『ゆっくり喋る』バグの犯人は、Whisperの30秒制限だった
声質変換(Voice Conversion)のアプリを作っていて、こんな報告を受けました。
「録音は普通なのに、変換後がゆっくり喋るんです」
入力した音声は自然な速さ。なのに変換結果だけが、酔っ払いのように間延びして再生される。しかも短い音声では起きず、長い録音でだけ発生する——。この記事は、その原因が変換モデルでも音声フォーマットでもなく、意味抽出に使っている Whisper の「一度に30秒まで」という制限だった、という調査の記録です。
同じ罠は Whisper を特徴抽出器として使うあらゆるパイプライン(音声変換、TTS、リップシンク、字幕生成など)で踏み得ます。
前提:このパイプラインの構造
作っていたのは Seed-VC ベースの声質変換です。ざっくり言うと、音声を「誰の声か(話者性)」と「何を喋っているか(発話内容)」に分解し、話者性だけ差し替えて再合成します。このうち発話内容の抽出に Whisper のエンコーダを使っていました。
処理の流れはこうです。
- 入力音声(キャリア)を Whisper に通して、発話内容を表す意味特徴の系列を得る
- その系列を、目標の長さ(メルスペクトログラムのフレーム数)に合わせて整える
- 話者埋め込みと合わせて拡散モデルで再合成する
問題はステップ1と2の境目にありました。
症状の観察:長い音声でだけ、比例して遅くなる
まず切り分けです。「ゆっくりになる」には典型的な原因がいくつかあります。
- サンプリングレートの取り違え:44.1kHz の音声を 22.05kHz として再生すると、ちょうど半速で低く再生される
- リサンプリングのミス:どこかで想定外のレート変換が挟まっている
- タイムストレッチ処理のバグ:話速調整の DSP が暴走している
ところが出力ファイルを調べると、サンプリングレートは正しく、話速調整もオフ。それでいて遅い。決定的だったのは、入力が長いほど遅くなるという点でした。
実際に手元の再現ケースはこうでした。
- 入力(録音):約 131秒
- 出力:入力よりはるかに間延びし、体感で 4倍以上スロー
短い音声(数秒)ではまったく問題が出ません。「長さに比例して悪化する」——これが真因を指し示していました。
真因:Whisper は一度に30秒しか“聞け”ない
Whisper のエンコーダは、入力を 30秒のログメルスペクトログラムに固定してから処理します。30秒より長い音声を素直に渡すと、先頭30秒ぶんの内容しか特徴として得られません(残りは切り捨てられる)。
ここで、先ほどのパイプラインのステップ2「目標の長さに合わせて整える」を思い出してください。長さを揃える処理(length regulator)は、Whisper から得た意味系列をメルスペクトログラムの全長へ引き伸ばします。
つまり 131秒の録音を渡すと、こうなっていました。
- Whisper は先頭30秒ぶんの内容しか返さない
- length regulator はそれを 131秒ぶんの長さへ引き伸ばす
- 結果、30秒の中身が131秒かけて再生される = 約4.4倍スロー
「4倍以上ゆっくり」という体感と、131 ÷ 30 ≒ 4.4 がぴたりと一致しました。犯人はモデルの品質でも音声フォーマットでもなく、30秒を超えた瞬間に意味情報が引き伸ばされていたことだったのです。
短い音声で起きなかったのは当然で、30秒以内なら切り捨ても引き伸ばしも発生しないからです。
解決:30秒の重複チャンクに分けて抽出する
正攻法は、長い音声を 30秒のチャンクに分割してそれぞれ Whisper に通し、結果を連結することです。ただし単純に30秒ごとにブツ切りにすると、境界で文脈が途切れて不自然になります。そこでチャンク間を数秒オーバーラップさせ、重複ぶんを後段で捨てて連結します。
Seed-VC 本家の推論コードも同じ考え方を採っているので、それに倣いました。実装した意味抽出のヘルパーがこれです。
def _semantic(w16):
"""意味特徴を抽出。>30s の長尺は 30s 重複チャンクで分割して連結する。
whisper エンコーダは一度に最大30秒しか扱えず、長尺をそのまま渡すと
先頭30秒ぶんの内容しか得られない。これを本来の長さに引き伸ばすと
「ゆっくり喋る」出力になるため、30s(5s重複) チャンクで処理して結合する。
"""
sfn = _S["semantic_fn"]
if w16.size(-1) <= 16000 * 30: # 30秒以内はそのまま
return sfn(w16)
ov = 5 # 秒(チャンク間のオーバーラップ)
parts, buf, t = [], None, 0
while t < w16.size(-1):
chunk = w16[:, t:t + 16000 * 30] if buf is None else \
torch.cat([buf, w16[:, t:t + 16000 * (30 - ov)]], dim=-1)
s = sfn(chunk)
parts.append(s if t == 0 else s[:, 50 * ov:]) # whisper 50token/s、重複ぶんを除去
buf = chunk[:, -16000 * ov:]
t += 30 * 16000 if t == 0 else chunk.size(-1) - 16000 * ov
return torch.cat(parts, dim=1)
ポイントを補足します。
- 音声は 16kHz(Whisper の入力レート)で扱うので、
16000 * 30が30秒ぶんのサンプル数です - Whisper の意味系列は概ね 1秒あたり50トークン。オーバーラップ5秒ぶん=
50 * 5トークンを、2チャンク目以降の先頭から捨てて重複を解消します - 30秒以内の入力では従来どおり素通しなので、短い音声の挙動は一切変えません
あとは、意味抽出を直接呼んでいた箇所(キャリアからの条件生成、F0条件付き経路など)をすべてこの _semantic() 経由に差し替えるだけです。
検証:131秒の録音で 1.00 倍に
修正後、問題の131秒録音(の先頭40秒を切り出したもの)で変換し、入力と出力の長さの比を測りました。
- 入力:40.00秒
- 出力:40.00秒(比 1.00)
修正前ならスローになっていたケースが、正しい長さで生成されるようになりました。もちろん30秒以下の音声はこれまで通りです。
学び:Whisper を“特徴抽出器”として使うときの落とし穴
今回の教訓は、Whisper を文字起こし以外の用途——特徴抽出器として組み込むときに特に効いてきます。
- 30秒は「エラーになる境界」ではなく「静かに切り捨てられる境界」。例外も警告も出ないまま、先頭30秒だけが使われます。だから気づきにくい。
- 症状は下流に現れる。今回は「音が遅い」という DSP っぽい症状でしたが、真因は3ステップ上流の特徴抽出でした。サンプリングレートやタイムストレッチという“それっぽい容疑者”を先に潰せたのは、「長さに比例して悪化する」という観察のおかげです。
- 長尺対応は自前で用意する。
transformersの pipeline には長尺向けのチャンク機能がありますが、エンコーダ出力を直接使うような組み込み方では、自分でチャンク化と連結(+オーバーラップ処理)を実装する必要があります。
「モデルの品質が悪いのでは」と疑いたくなる場面ほど、その手前の前処理・特徴抽出の暗黙の制限を先に確認する。応急処置で症状を隠すのではなく、比例関係という手がかりから真因にたどり着く——地味ですが、これが一番の近道でした。
まとめ
- 声質変換の「ゆっくり喋る」バグの真因は、Whisper エンコーダの 30秒制限だった
- 長尺音声で先頭30秒ぶんの意味しか得られず、それを全長へ引き伸ばすため間延びしていた(131秒 ≒ 4.4倍スロー)
- 30秒(5秒重複)のチャンク分割+連結で解決。オーバーラップぶんはトークン単位で除去する
- 検証では入力40秒→出力40秒(比1.00)に是正。短い音声の挙動は不変
- Whisper を特徴抽出器として使うなら、「30秒で静かに切り捨てられる」ことを前提に長尺対応を自分で用意する