2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

構音障害の自分が自作曲を自分で歌うために、ピッチ補正ツールを作った ─ WORLD + DTW で「直すズレ」と「残す揺れ」を分ける

2
Posted at

はじめに

十六年前、脳梗塞で構音障害になりました。

曲は作れます。頭の中でメロディも鳴っています。でも、いざ自分で歌おうとすると音程が保てない。狙った音に乗ったつもりでも、伸ばしているうちにずるずる下がっていく。同じフレーズを何度歌っても、毎回ちがう場所に着地する。

自作曲を自分の声で歌いたい。でも歌えない。そこで、ガイドボーカルに合わせて自分の歌の音程だけを直すツールを作りました。

市販のオートチューンやDAWのピッチ補正も試しました。でも、これが自分の用途ではうまく機能しない理由がはっきりあって、そこが今回のツールの設計そのものになっています。この記事ではその話を中心に書きます。

作ったもの

  • ブラウザでガイドボーカルを聴きながら録音(ヘッドホン必須)
  • ローカルのPythonが解析して、音程だけをガイドに合わせて補正
  • 補正前と聴き比べて、WAVでダウンロード
  • 音声は一切外部に送信されない(全部このPCの中で完結)

技術スタックは Python(pyworld + librosa + FastAPI)+ ブラウザ(Web Audio API / AudioWorklet)。

なぜ普通のオートチューンではダメだったのか

一般的なオートチューンは「今出している音に一番近いスケール上の音」へ吸着させます。Cメジャーなら、出した音をC・D・E・F・G・A・Bのうち最寄りへ寄せる。

これは「だいたい合っているけど少し甘い」歌を直すには完璧な仕組みです。ずれが±50セント以内なら、最寄りの音=狙った音だからです。

でも自分の場合、ずれが半音を超えることが普通にあります。すると何が起きるか。

狙った音: E4
出した音: E4 より 70セント低い
最寄りの音: D#4  ← Eではなく、こっちに吸着してしまう

間違った音に、より正確に吸着される。 ずれが大きいほど、補正すると余計に音痴になる。しかもD#4は堂々とジャストなので、聴くと「わざと半音下を歌っている」ようにしか聞こえません。

スケールを手動で指定しても、ノートを1つずつ手で置いても解決はします。でも1曲ぶんそれをやるのは現実的ではない。

ガイドがあれば、推測がいらない

考え直しました。自分にはガイドボーカルがある。仮歌でも、市販曲のボーカル抽出でも、機械音声でもいい。「この瞬間はE4であるべき」という情報が、そこに全部入っています。

つまり、最寄りの音を推測する必要がない。目標のピッチ曲線を直接読める

一般的なオートチューン:  出した音 → 最寄りのスケール音を推測 → そこへ寄せる
                                      ↑ ずれが大きいと推測が外れる

このツール:            ガイドのF0 → 目標として確定 → そこへ寄せる
                                      ↑ ずれが何半音でも正しい音が分かる

これが設計の出発点です。

全体アーキテクチャ

ガイド音源 ──┐
             ├─→ ① F0推定 ──→ ② 時間合わせ ──→ ③ 目標曲線の生成 ──→ ④ 再合成 ──→ WAV
自分の録音 ──┘   (WORLD)      (相関 + DTW)      (ドリフト/揺れ分離)   (WORLD)

ブラウザ側                          Python側 (127.0.0.1)
─────────────                       ────────────────────
ガイド再生 + マイク録音  ──WAV──→   FastAPI ─→ ThreadPool ─→ pitchfix
進捗ポーリング           ←─JSON──   ジョブ状態
結果の再生・グラフ表示   ←─WAV───   補正済み音声

処理は全部ローカルです。歌ったものが外に出ないというのは、自分にとっては地味に重要でした。

① F0推定 ─ オクターブ誤検出をどう潰すか

WORLD(pyworld)の dio + stonemask で5msごとにF0を取ります。

f0, t = pw.dio(x, fs, f0_floor=65.0, f0_ceil=1000.0, frame_period=5.0)
f0 = pw.stonemask(x, f0, t, fs)   # dio の結果を精密化

ここでHzのまま扱うと後の計算が全部やりにくいので、すぐ半音単位(MIDIノート番号)に変換します。音楽的な「ずれ」は比ではなく差で考えたいからです。

midi = 69.0 + 12.0 * np.log2(f0 / 440.0)   # 無声は NaN にしておく

問題はオクターブ誤検出です。F0推定は倍音や低域に引っ張られて、ときどき1オクターブ跳んだ値を返します。これをそのまま目標にすると、その瞬間だけ12半音動かそうとして音が壊れます。

対策として、局所メディアンを基準に「12の倍数ぶんずれている値」を引き戻します。

for start, end in voiced_segments(out):
    seg = out[start:end]
    for _ in range(2):   # 1回目で基準が整い、2回目で残りを拾う
        ref = median_filter(seg, size=size, mode="nearest")
        shift = np.clip(np.round((ref - seg) / 12.0), -2, 2)
        if not shift.any():
            break
        seg = seg + 12.0 * shift

round((ref - seg) / 12) は、局所メディアンから6半音以上離れているときだけ非ゼロになります。本物のオクターブ跳躍は局所メディアン自体が追従するので壊しません。地味ですが、これがないと結果に「プツッ」と異音が混ざります。

② 時間合わせ ─ ここが一番ハマった

ガイドと録音は、まず時間が揃っていません。録音レイテンシ、カウントインのずれ、歌い出しの突っ込み・もたり。ここを外すと別の音を目標にしてしまうので、補正そのものより重要です。

発音の立ち上がりだけで合わせると、盛大に外す

最初は素直に、オンセット(発音の立ち上がり)の強さを相互相関させました。

env_g = librosa.onset.onset_strength(y=guide, sr=SR, hop_length=hop)
env_t = librosa.onset.onset_strength(y=take,  sr=SR, hop_length=hop)
corr = correlate(env_t, env_g, mode="full", method="fft")
offset = lags[np.argmax(corr)] / fps

合成データでテストしたら、正解 +0.42秒に対して -2.335秒という答えが返ってきました。相関のトップを覗いてみると理由は一目瞭然でした。

top lags: [(-2.33, 352.8), (0.47, 329.2), (-2.34, 309.2), (-2.59, 302.1), ...]
                   ↑ 偽のピーク      ↑ 正解。わずか7%差で負けている

歌のリズムは規則的です。だから1小節ずれた位置にも、ほぼ同じ高さのピークが立ちます。テンポが少し揺れているだけで順位がひっくり返る。オンセットだけでは原理的に区別できません。

旋律の「形」を足して曖昧さを潰す

リズムが区別できないなら、メロディの上下の形を使えばいい。こちらは遥かに識別力があります。

ただし自分の歌は音程がずれているので、生のピッチをそのまま相関させても合いません。そこで局所メディアンを引いて「形」だけを取り出します。これでキー・音域・ゆっくりしたドリフトが落ち、上がり下がりのパターンだけが残ります。

def melodic_contour(midi, frame_period, n_frames):
    filled = np.interp(positions, positions[voiced], m[voiced])
    base = median_filter(filled, size=int(4.0 * fps) | 1, mode="nearest")
    contour = np.clip(filled - base, -12.0, 12.0)   # 「形」だけが残る
    contour[~voiced] = 0.0
    return contour / contour.std()

そしてオンセットと旋律、2つの相関を探索範囲内でzスコア化してから足します。単位も分散も違うものを素朴に足すと片方に支配されるので、標準化が要ります。

score = _zscore_in_window(onset_corr, window)
if guide_midi is not None:
    contour_corr = correlate(con_t, con_g, mode="full", method="fft")
    # 旋律の形のほうが識別力が高いので重く見る
    score = score + 2.0 * _zscore_in_window(contour_corr, window)
best = int(np.argmax(np.where(window, score, -np.inf)))

結果、-2.335秒 → +0.469秒(正解 +0.42秒 + 最初の音のテンポゆらぎ)。一発で通るようになりました。

DTWは「時間を伸縮する」ためではなく「目標を引く」ために使う

全体オフセットが決まったら、MFCCのDTWで歌い出しや伸ばしのズレを細かく合わせます。ここで大事な設計判断がひとつ。

録音の時間軸は絶対にいじりません。

DTWでタイムストレッチしてガイドに合わせることもできますが、それをやると自分のタイム感まで矯正されてしまう。直したいのは音程だけです。

そこでDTWから得るのは、波形の変形ではなく対応表だけにしました。

「録音の時刻 t のとき、ガイドのどの時刻を目標ピッチとして読むか」

音は1サンプルも動かさず、参照先だけを動かす。リズムも語尾も、自分のものがそのまま残ります。

実装上の注意が2つ。

メモリ: 4分の曲を100fpsで丸ごとDTWすると 24000×24000 のコスト行列になり、float32でも2.3GBです。20秒チャンク(5秒オーバーラップ)に切れば 2000×2600 で41MBに収まります。

テンポドリフト: チャンクごとに固定オフセットで探索窓を切ると、曲後半でじわじわ外れます。前のチャンクで実測したずれを次のチャンクの基準にして持ち回るようにしました。

running_off = offset * fps
for i0 in range(0, n_take, hop_f):
    g0 = int(np.clip(round(i0 - running_off) - margin_f, 0, n_guide))
    g1 = int(np.clip(round(i1 - running_off) + margin_f, 0, n_guide))
    local = _dtw_chunk(feat_g[:, g0:g1], feat_t[:, i0:i1]) + g0
    # ...
    measured = float(np.median(np.arange(i0, i1)[sample] - local[sample]))
    if abs(measured - running_off) <= margin_f:   # 暴走したチャンクは採用しない
        running_off = measured

最後に単調性を強制します。時間は巻き戻らないので。

mapped = np.maximum.accumulate(mapped)

③ 補正の心臓部 ─ 「直すズレ」と「残す揺れ」を分ける

ここが、このツールで一番考えたところです。

強度100%でガイドに完全一致させれば、音程は確実に合います。でもそれは自分が歌った意味がない。ガイドをそのまま使うのと変わらない。

自分が直したいのは「音を保てないこと」であって、「自分の歌い方」ではありません。この2つを分ける必要がありました。

歌の音程は2つの成分でできている

歌の音程 = ゆっくりした成分 + 速い揺れ
           ↑ どこで音を取っているか   ↑ ビブラート・しゃくり・語尾の表情
           = 直したいズレ            = 残したい個性

ビブラートは通常4〜7Hz、直したいドリフトは1〜2Hz以下。周波数で綺麗に分かれます。だからガウシアン平滑でローパスをかけるだけで分離できます。

u_slow = gaussian_filter1d(u, sigma=sigma_slow, mode="nearest")  # ゆっくりした成分
u_fine = u - u_slow                                              # 速い揺れ
g_slow = gaussian_filter1d(g_filled, sigma=sigma_slow, mode="nearest")

delta = g_slow - u_slow                                    # 直すべき量
delta = np.clip(delta, -params.max_shift, params.max_shift)  # 安全装置
delta = gaussian_filter1d(delta, sigma=sigma_retune, mode="nearest")
delta *= params.strength

corrected[start:end] = u_slow + params.vibrato_keep * u_fine + delta

最後の1行がすべてです。

  • u_slow + delta … 音を取る位置をガイドへ寄せる
  • vibrato_keep * u_fine … 自分の揺れをそのまま(あるいは好きな量だけ)戻す

strength=0, vibrato_keep=1 なら u_slow + u_fine = u、つまり完全な無加工。これはテストで実測して確認しました(補正前後で誤差が0.54半音のまま動かない)。恒等変換がちゃんと恒等になるのは、この手の処理では大事な性質だと思います。

vibrato_keep を下げれば、意図しない震えやふらつきも一緒に抑えられます。自分の体調によって使い分けています。

ガイドが無い区間をどう扱うか

ガイドが無声(息継ぎ・子音)なのに自分が発声している区間では、目標がありません。ここで補正量を急に0にすると段差が出て「プツッ」と鳴ります。

参照できるかどうかのマスクを平滑化して、補正量をなめらかに0へ戻します。

coverage_w = gaussian_filter1d(available.astype(np.float64),
                               sigma=sigma_retune, mode="nearest")
delta = delta * coverage_w

もうひとつ、目標ピッチを引くときは線形補間ではなく最近傍にしています。有声と無声の境目をまたいで補間すると、実在しない中間の音程が生まれて音の変わり目が濁るからです。

idx = np.round(guide_times_for_take / step).astype(np.int64)
out[inside] = guide_midi[idx[inside]]   # 最近傍で引く

④ WORLDで再合成 ─ なぜケロケロにならないか

単純にリサンプリングで音程を上げると、声まで高くなって「早送り声」になります。フォルマント(声道の共鳴=その人の声の音色)まで一緒に動いてしまうからです。

WORLDボコーダを使うと、これが構造的に回避できます。ポイントは解析と合成でF0を変えること

# スペクトル包絡と非周期性は「元のF0」で解析する
sp = pw.cheaptrick(x, f0_chunk, t_chunk, fs, f0_floor=f0_floor, fft_size=fft_size)
ap = pw.d4c(x, f0_chunk, t_chunk, fs, fft_size=fft_size)

# 合成のときだけF0を差し替える。包絡はそのまま使われる
y = pw.synthesize(f0_new[a:b], sp, ap, fs, frame_period)

sp は「声道の形」、f0 は「声帯の振動数」。この2つが独立に持てるので、声色を保ったまま音程だけ動かせます。人間が同じ母音を高い声でも低い声でも発音できるのと同じ構造です。

メモリの壁とチャンク分割

素直に全部を一度に処理すると死にます。4分の曲を5msフレームで解析すると48000フレーム。44.1kHzでFFTサイズ2048なら包絡の次元は1025なので、

48000 × 1025 × 8 byte ≒ 393MB   ← sp だけで

ap も同じサイズなので約800MB。しかも f0_floor を65Hzより下げるとFFTサイズが4096になり、これが倍になります(=1.5GB)。

そこで時間方向にチャンク分割します。ここで効いたのが、解析には毎回フル波形を渡し、フレーム時刻だけを切り出すというやり方です。

sp = pw.cheaptrick(x, f0_analysis[a:b], t[a:b], fs, ...)
#                  ↑ フル波形          ↑ 時刻だけスライス

波形を切ってから渡すとチャンク境界で解析窓が欠けますが、この形なら文脈が常に完全なまま、メモリだけ30秒ぶん(約49MB)に抑えられます。

チャンクの継ぎ目をどこに置くか

WORLDは呼び出しごとに位相を作り直します。有声の途中で切って繋ぐと、重ね合わせでコムフィルタ的な濁りが出ます。

対策は単純で、境界を無声(息継ぎ・子音・無音)の位置に寄せること。位相が問題にならない場所で切ります。

def _find_cut_frames(f0, chunk_frames, search_frames):
    while cuts[-1] + chunk_frames < total:
        target = cuts[-1] + chunk_frames
        unvoiced = np.flatnonzero(f0[lo:hi] <= 0)
        if unvoiced.size:
            # 目標位置に一番近い無声フレームで切る
            cut = int(lo + unvoiced[np.argmin(np.abs(unvoiced + lo - target))])

90秒(3チャンク)で検証して、隣接サンプル差の最大が0.117・99.99パーセンタイルが0.107。突出した段差がないので、継ぎ目は聞こえていません。

ブラウザ側でハマったポイント

1. echoCancellation はピッチ解析の敵

これが最重要です。getUserMedia の既定値はビデオ会議向けにチューニングされていて、声を加工します。エコーキャンセルもノイズ抑制も自動ゲインも、全部オフにしないとF0推定が狂います。

stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: false, noiseSuppression: false, autoGainControl: false,
    channelCount: 1,
  },
});

あわせてヘッドホン必須です。スピーカーだとガイドがマイクに回り込み、ガイドのピッチを「自分の声」として解析してしまいます。

2. currentFrame で録音とガイドをサンプル単位で揃える

「ガイドが鳴り始めた瞬間」と「録音の0秒」を揃えたい。AudioWorkletGlobalScopecurrentFrame を使うと、これがAudioContextのサンプル時計で正確に取れます。

// ワークレット側: 最初のブロックのフレーム番号を送る
if (!this.started){
  this.port.postMessage({ firstFrame: currentFrame });
  this.started = true;
}
// メイン側: ガイドの開始予定時刻との差だけ、録音の頭を切り落とす
const startFrame = Math.round(st.startTime * st.ac.sampleRate);
const trim = Math.max(0, startFrame - st.firstFrame);
takeMono = all.subarray(trim);

これで「録音の0秒 = ガイドの開始位置」が保証されます。残るハードウェアの入力レイテンシは、②の相関が勝手に吸収してくれます。

3. AudioWorkletNodeは繋がないと止まる

出力を使わないつもりでも、destination まで繋がっていないと process() が呼ばれなくなります。ゲイン0のノードを噛ませて繋いでおきます。

const sink = ac.createGain();
sink.gain.value = 0;   // 出力はしないが、繋いでおかないと処理が止まる
source.connect(rec).connect(sink).connect(ac.destination);

4. MP3のデコードはブラウザに任せる

サーバー側でMP3やM4Aを読もうとするとffmpeg依存が生えます。でもブラウザには decodeAudioData があります。

ブラウザでデコード → モノラルWAVにしてPOST。これでサーバーはWAVだけ読めればよくなり、対応形式はブラウザのデコーダそのものになります。マイク録音とファイル読み込みが同じ経路に乗るのも綺麗でした。

効果の実測

「音程が取れていない歌」を合成して検証しました。ゆっくりしたドリフト±0.9半音、音ごとのランダムなずれ±0.8半音、テンポのゆらぎ5〜6%、出だしの遅れ。正解の音程を別途保持しておいて、そこと照合しています(ツール自身の出す統計ではなく、独立した答え合わせです)。

ずれの中央値 半音以内に収まる割合
補正前 0.58 半音 44%
補正後 0.21 半音 92%

残る0.21半音は、ほぼ意図的に残したビブラート成分です。vibrato_keep=0 にすると0.03半音まで下がります。狙い通りの分離ができている証拠として、この数字はけっこう気持ちよかったです。

強度を振ったときの挙動も素直でした。

設定 ずれの中央値 半音以内
strength=0.0(無補正) 0.54 47%
strength=0.5 0.34 67%
strength=1.0 0.20 95%
strength=1.0 + 揺れ除去 0.03 98%

処理時間は88秒の音声で約74秒。実時間の0.85倍程度です。

限界と、正直なところ

  • ガイドはボーカルのみが必要。伴奏入りのミックスだとF0推定が伴奏に引っ張られて機能しません。フルミックスしかない場合はDemucs等でボーカル分離が要ります
  • ずれ幅が常時3〜4半音を超えると音質が落ちます。この場合は補正を強くするより、ガイドを自分の音域に移調するほうが良い結果になります
  • 構音障害そのものが治るわけではありません。直せるのは音程だけで、発音の明瞭さは自分の声のままです

最後の点について。作る前は「音程さえ合えば」と思っていましたが、実際に自分の歌で試すと、残った自分の癖のほうが愛おしく感じる瞬間がありました。だから vibrato_keep の既定値は100%にしています。全部消せる設計にはしましたが、既定では消しません。

おわりに

  • ずれが大きい歌には、スケールへの吸着ではなくガイドからの直接参照が効く
  • 時間合わせはリズムだけでは1小節ずれを見抜けない。旋律の形を足すと一発で決まる
  • DTWは波形を変形させるためではなく、目標を引くための対応表として使うとリズムを壊さない
  • 歌の音程はゆっくりした成分と速い揺れに周波数で分離できる。前者だけ直せば個性が残る
  • WORLDは解析と合成でF0を変えられるから、声色を保ったまま音程だけ動かせる

十六年、自分の曲は誰かに歌ってもらうものでした。それが悪いことだったわけではありません。でも「自分の声で歌ったもの」が手元にあるというのは、思っていたよりずっと違うことでした。

同じような理由で歌うことを諦めている人がいたら、この方向は案外いけます。ぜひ試してみてください。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?