逆変換特徴量
libsonare の大半は、音声を特徴量へ変換します。たとえばメルスペクトログラム、MFCC、クロマグラムなどです。
逆変換ヘルパーは、その逆向きです。特徴量から近似的なスペクトルやプレビュー音声を再構成します。
これらのヘルパーは、特徴量パイプラインのデバッグ、モデルが「聴いている」ものの可聴プレビュー作成、往復テスト、librosa 風ノートブックのネイティブ/ブラウザ移行に使えます。
解析が初めてなら、ここから始めない
これらのヘルパーは、すでにメルスペクトログラムや MFCC を生成していることを前提にします。始めたばかりなら、先に はじめに と JavaScript API または Python API の特徴抽出を読み、計算したものを逆変換したくなったら戻ってきてください。
このページで身につくこと
このページを読むと、次のことを判断・実装できるようになります。
- メル/MFCC の逆変換が近似であり、特徴量だけから位相を復元できない理由を説明できる。
- 正しく往復確認するために必要な
sampleRate、nFft、hopLength、nMels、nMfcc、fmin、fmax、htk、MFCC のlifterを保持するか、順変換の結果を渡してこれらを引き継がせることができる。 - 特徴量行列と、行列/プレビュー音声のどちらが必要かに応じて、
melToStft、melToAudio、mfccToMel、mfccToAudio、cqtToAudio、vqtToAudioを選べる。 - JavaScript と Python の戻り値の形を、行数・フレーム数・フラット配列を混同せずに比較できる。
ここでいう「逆変換」とは
最初に押さえるべき点は、逆変換が「元に戻す」処理ではなく「特徴量から、それらしく聞こえるものを作る」処理だということです。写真でたとえるなら、カラー写真から輪郭だけを抜き出したあと、輪郭を手がかりに元の写真を描き直すようなものです。形は分かっても、細部や色は完全には戻りません。
順変換は意図的に不可逆です。捨てられる情報が 2 種類あり、どんな逆変換も取り戻せません。
- メルフィルタバンクは正方行列ではない。 メルスペクトログラムは STFT(短時間フーリエ変換)の周波数ビン(
nFft = 2048なら約 1025 本、すなわちnFft/2 + 1)を、たとえば 128 のメルバンドに畳み込みます。逆変換は各メルバンドのエネルギーを元のビン群へ広げ直しますが、これは最小二乗の最良推定であって元の細部ではありません。 - 位相は完全に失われる。 振幅/パワースペクトログラムは各周波数にどれだけエネルギーがあるかを保ちますが、波形周期のどこにあるかは保ちません。音声再構成はもっともらしい位相を作り出す必要があり、それを担うのが Griffin-Lim です。
MFCC はさらにもう 1 段の損失を加えます。先頭 nMfcc 個(多くは 13〜20)のケプストラム係数だけを残すためです。この係数が保持するのはスペクトル包絡の大まかな形で、その内側の細かい構造(倍音のディテールやフォルマントの鋭いエッジ)は捨てられます。そのため MFCC の逆変換は、音声を取り戻す前に平滑化されたメルスペクトログラムを再構成します。
ケプストラム係数とは?
ケプストラムは「スペクトルのスペクトル」です。振幅スペクトルの対数を取り、それをもう一度変換します。古典的なケプストラムは逆フーリエ変換を使いますが、MFCC は対数メル帯域エネルギーに DCT(離散コサイン変換)をかけます。どちらの場合も、低次の係数がスペクトルの大まかな形(音色)を、高次の係数が細かいディテールを表します。MFCC はこの係数の先頭の数個だけを残すため、全体的な音色をコンパクトに表せる一方で、細かいスペクトルのディテールは再構成できません。
再構成は近似であって復元ではない
出力は確認とプレビュー用で、元録音を取り戻すためのものではありません。元の音だとは分かるものの、位相の乱れによる滲みが残ります(特に MFCC からの再構成や Griffin-Lim の反復が少ない場合)。本物の音声が必要なら、本物の音声を保持してください。
4 つのヘルパー
4 つの関数は、戻したい場所によって選びます。まだ行列のまま確認したいなら *ToStft / *ToMel、耳で聴けるプレビューが欲しいなら *ToAudio を使います。
2 つの順変換(破線)は、すでにお馴染みの特徴量を作ります。4 つの逆変換ヘルパー(実線)はそれを元に戻し、スペクトル領域にとどまるもの(melToStft、mfccToMel)と、Griffin-Lim でプレビュー波形まで再構成するもの(melToAudio、mfccToAudio)に分かれます。
| 目的 | JavaScript | Python |
|---|---|---|
| メルパワー → STFT 振幅 | melToStft(...) は { nBins, nFrames, magnitude } を返す | mel_to_stft(...) は InverseResult(rows, n_frames, data) を返す |
| メルパワー → 音声 | melToAudio(...) は Float32Array を返す | mel_to_audio(...) は list[float] を返す |
| MFCC → メルパワー | mfccToMel(...) は { nMels, nFrames, power } を返す | mfcc_to_mel(...) は InverseResult(rows, n_frames, data) を返す |
| MFCC → 音声 | mfccToAudio(...) は Float32Array を返す | mfcc_to_audio(...) は list[float] を返す |
| 順変換の結果 → 上記のいずれか | melToStft({ result })、melToAudio({ result })、mfccToMel({ result })、mfccToAudio({ result }) | mel_result_to_stft、mel_result_to_audio、mfcc_result_to_mel、mfcc_result_to_audio |
| CQT 振幅 → 音声 | cqtToAudio(...) は Float32Array を返す | cqt_to_audio(...) は list[float] を返す |
| VQT 振幅 → 音声 | vqtToAudio(...) は Float32Array を返す | vqt_to_audio(...) は list[float] を返す |
*ToStft / *ToMel の 2 つはスペクトル領域にとどまり、確認や次段への受け渡しに使える結果オブジェクトを返します。*ToAudio の 2 つは波形まで戻し、欠けた位相を補うために内部で Griffin-Lim を実行します。
スペクトルを再構成する
melToStft は、メルパワースペクトログラムを線形周波数の STFT 振幅スペクトログラムへ戻します。mfccToMel は、MFCC を平滑化されたメルパワースペクトログラムへ戻します。
melToStft が返すのはパワーではなく振幅です
このヘルパーは非負最小二乗をパワー領域で解いたあと平方根を取ります。既定が power=2.0 の librosa.feature.inverse.mel_to_stft に合わせた挙動です。したがって返ってくる値は振幅で、JavaScript のフィールド名が magnitude なのはそのためです(Python の InverseResult では data)。dB へ変換するときは 10·log₁₀(x) ではなく 20·log₁₀(x) を使い、パワースペクトルを前提とする処理へ渡す前には値を 2 乗してください。
順変換で 0 以外の lifter を使った場合は、mfccToMel または mfccToAudio に同じ値を渡してください。逆変換で取り除かれます。
逆変換は長い入力を受け付けない
melToStft、melToAudio、mfccToAudio は、4096 フレームを超える入力、または nFft が 8192 を超える設定で input exceeds the inverse resource limit を投げます。22050 Hz・ホップ 512 でおよそ 95 秒、44100 Hz ではおよそ 48 秒です。mfccToMel だけは上限がありません。長い録音は区間ごとに切り出して処理してください。
結果をそのまま逆変換する
メルまたは MFCC の結果は、それを作った変換のパラメータを持っています。nFft、hopLength、sampleRate、適用された fmin と fmax、htk、MFCC ではさらに nMels と lifter です。JavaScript では逆変換が結果そのものをリクエストとして受け取るので、値を繰り返す必要も、ずれる余地もありません。
const mel = melSpectrogram({ samples, sampleRate, nFft: 2048, hopLength: 512, nMels: 128 });
const spectrum = melToStft({ result: mel });
const preview = melToAudio({ result: mel, nIter: 32 });
const coeffs = mfcc({ samples, sampleRate, nFft: 2048, hopLength: 512, nMels: 128, nMfcc: 20, lifter: 22 });
const reMel = mfccToMel({ result: coeffs });
const fromMfcc = mfccToAudio({ result: coeffs, nIter: 32 });result と並べて指定したフィールドは結果の値と一致していなければならず、食い違うと両方の値を示す RangeError を投げます。つまり上書きできるのは順変換で使った値の再確認だけです(nIter は自由)。dB の状態のメル結果(isDb)は、dbToPower を案内するメッセージで拒否されるので、先に変換してください。行列で渡すリクエスト形式では、行列のフィールド名はメルが power、MFCC が coefficients です(melToStft({ power, nMels, nFrames, ... })、mfccToMel({ coefficients, nMfcc, nFrames, ... }))。それ以外のフィールド名では例外になります。result も行列フィールドもないリクエスト、またはオブジェクトでない引数は、欠けているフィールド名を示す TypeError になります(melToStft: power is required (or pass result))。
Python では mel_result_to_stft(mel)、mel_result_to_audio(mel, n_iter=32)、mfcc_result_to_mel(coeffs)、mfcc_result_to_audio(coeffs, n_iter=32) が同じ役割を担います。以降の位置引数形式の librosa 風関数は、同じ入力を 1 つずつ渡す形です。
ここではまだ音声波形には戻りません。結果は「周波数ごとのエネルギー表」です。機械学習モデルや可視化の途中結果を確認したい場合は、音声化するよりこの段階で見る方が原因を追いやすいことがあります。
import { init, melSpectrogram, melToStft, mfcc, mfccToMel } from '@libraz/libsonare';
await init();
// メルパワー -> STFT 振幅
const mel = melSpectrogram(samples, sampleRate, 2048, 512, 128);
const stft = melToStft(mel.power, mel.nMels, mel.nFrames, sampleRate, 2048);
// stft: { nBins, nFrames, magnitude } nBins = nFft/2 + 1 = 1025
// MFCC -> メルパワー
const coeffs = mfcc(samples, sampleRate, 2048, 512, 128, 20);
const reMel = mfccToMel(coeffs.coefficients, coeffs.nMfcc, coeffs.nFrames, 128);
// reMel: { nMels, nFrames, power }import libsonare as sonare
# メルパワー -> STFT 振幅
mel = sonare.mel_spectrogram(samples, sample_rate, n_fft=2048, hop_length=512, n_mels=128)
stft = sonare.mel_to_stft(mel.power, mel.n_mels, mel.n_frames, sample_rate=sample_rate, n_fft=2048)
# stft.rows = n_fft/2 + 1 = 1025。stft.data は行優先の [rows x n_frames]
# MFCC -> メルパワー
coeffs = sonare.mfcc(samples, sample_rate, n_fft=2048, hop_length=512, n_mels=128, n_mfcc=20)
re_mel = sonare.mfcc_to_mel(coeffs.coefficients, coeffs.n_mfcc, coeffs.n_frames, n_mels=128)
# re_mel.rows = n_mels。re_mel.data は行優先の [rows x n_frames]# 同じ順変換/逆変換設定で音声プレビューを作るソースビルド C++ CLI 経路:
sonare mel-to-audio song.wav --n-fft 2048 --hop-length 512 --n-mels 128 -o mel-preview.wav入力はいずれも行優先の行列です。メルの power は [nMels x nFrames]、MFCC 係数は [nMfcc x nFrames]。nMels/nMfcc/nFrames の引数は、その平坦配列の読み方をヘルパーに伝えるので、渡す行列と一致させてください。
音声を再構成する
melToAudio と mfccToAudio は、再生やファイル書き出しができるモノラルの Float32Array を返します。特徴量に位相がないため、どちらも Griffin-Lim を実行します。振幅にランダムな位相を与えて開始し、逆 STFT → STFT → 新しい位相を保持 → 既知の振幅を課す、を位相が自己整合するまで繰り返します。ランダムな初期位相は固定シードから作るので、同じ特徴量と nIter からは常に同じプレビューが得られます。
cqtToAudio と vqtToAudio も、cqt(...)/vqt(...)(定 Q 変換と可変 Q 変換。周波数ビンを線形ではなく対数間隔で並べる変換です)が返す行優先の振幅行列に同じ反復手法を使います。順変換時の sampleRate、hopLength、fmin、binsPerOctave(VQT は gamma も。順変換で省略したなら省略)を変えずに渡してください。メル/MFCC の復元と同じく、これは近似的なモノラルプレビューであり、原音の復元ではありません。
聴いたときに、元の曲の輪郭やリズムは分かるが、にじみ・金属的な質感・ざらつきが出ることがあります。これは多くの場合、実装の失敗ではなく、捨てた位相を推定しているために起きる自然な限界です。
順変換の結果が手元にあるなら、それをそのまま渡します。下の位置引数形式は、行列とその設定だけを持っている場合の librosa 風の書き方です。
import { init, melSpectrogram, melToAudio, mfcc, mfccToAudio } from '@libraz/libsonare';
await init();
const mel = melSpectrogram(samples, sampleRate, 2048, 512, 128);
const coeffs = mfcc(samples, sampleRate, 2048, 512, 128, 20);
// 順変換の結果から。設定を繰り返さなくてよい。
const previewFromResult = melToAudio({ result: mel, nIter: 32 });
const fromMfccResult = mfccToAudio({ result: coeffs, nIter: 32 });
// 位置引数形式(librosa 風)。
const preview = melToAudio(
mel.power, mel.nMels, mel.nFrames, // メル行列とその形
sampleRate,
2048, // nFft (順変換と一致させる)
512, // hopLength (順変換と一致させる)
0, // fmin (0 = 既定、全域)
0, // fmax (0 = 既定、sampleRate / 2)
32, // nIter (Griffin-Lim の反復回数)
);
const fromMfcc = mfccToAudio(
coeffs.coefficients, coeffs.nMfcc, coeffs.nFrames,
128, // nMels — 注意: この追加引数は sampleRate の前に来る
sampleRate,
2048, // nFft
512, // hopLength
0, // fmin(既定)
0, // fmax(既定)
32, // nIter
);import libsonare as sonare
mel = sonare.mel_spectrogram(samples, sample_rate, n_fft=2048, hop_length=512, n_mels=128)
coeffs = sonare.mfcc(samples, sample_rate, n_fft=2048, hop_length=512, n_mels=128, n_mfcc=20)
# 順変換の結果から。設定を繰り返さなくてよい。
preview_from_result = sonare.mel_result_to_audio(mel, n_iter=32)
from_mfcc_result = sonare.mfcc_result_to_audio(coeffs, n_iter=32)
# 位置引数形式(librosa 風)。
preview = sonare.mel_to_audio(mel.power, mel.n_mels, mel.n_frames, sample_rate=sample_rate, n_iter=32)
# n_iter=32(Griffin-Lim の反復回数)
from_mfcc = sonare.mfcc_to_audio(coeffs.coefficients, coeffs.n_mfcc, coeffs.n_frames, n_mels=128, sample_rate=sample_rate, n_iter=32)
# sample_rate の前に n_mels(128)引数が入る点に注意# 簡易プレビュー用のソースビルド C++ CLI 対応コマンド:
sonare mel-to-audio song.wav -o mel-preview.wav
sonare mfcc-to-audio song.wav -o mfcc-preview.wavGriffin-Lim はどう欠けた位相を補うか
Griffin-Lim は振幅のみからの反復再構成です。目標の振幅スペクトログラム |S| と初期位相 φ を与えると:
|S|·e^{iφ}を逆 STFT して時間領域信号にする。- その信号を順 STFT する。振幅は
|S|から少しずれるが、位相は実波形により整合した値になる。 - 新しい位相を保持し、振幅を
|S|に戻して繰り返す。
各パスが、実際の信号が生み出しうる位相へ近づけます。libsonare はモーメンタム 0.99 の高速版を使います。新しい位相の推定を、前のパスが動かした方向へさらに押し進めるので、素朴な反復より少ないパスで収束します。初期位相は固定シードの乱数なので、結果は毎回同じです。nIter はパス数を制御します。反復が多いほど近づき(滑らかでアーティファクトが少ない)線形にコストが増え、少ないほど速い代わりに位相の乱れによる滲みが目立ちます。32 が無難な既定値で、高速 UI プレビューでは 8〜16 に、プレビュー品質重視なら 60 以上に上げます。
nIter が主な品質/レイテンシのつまみです。それ以外の nFft、hopLength、fmin、fmax、htk は順変換と一致させる必要があります(下記参照)。ヘルパーのシグネチャでは、fmin/fmax の既定値は 0(全域)、htk の既定値は false(Slaney)です。順変換に渡したのと同じ値を、同じ位置引数の枠に渡してください。
このデモは、上の melSpectrogram → melToAudio の往復を短い母音でそのまま実行します。反復回数をドラッグして聴いてみてください。平均スペクトルはほとんど動かないのに音が澄んでいくのがわかります。振幅は最初から揃っており、反復が取り戻すのは使える位相だけです。
Slaney と HTK のメルスケール
メルバンドの並べ方には 代表的な流儀は、Slaney の式(librosa と libsonare の既定)と HTK の式の 2 つです。両者はバンド端の位置が異なるため、順変換と逆変換は同じ流儀を使う必要があります。順変換で使った場合にかぎり、逆変換にも htk: true(または htk=True)を渡してください。
成分を音として残したいとき
ここまでの再構成はすべて振幅から出発するため、いずれも位相を作り出す必要があります。スペクトル表現から音へ戻る経路のうち、ひとつだけそれを必要としないものがあります。
decompose は振幅スペクトログラムを成分と活性度に分解します。この因子は位相を持たないため、ひとつの成分を音に戻すには積を取って Griffin-Lim に渡すことになり、melToAudio と同じ近似、同じにじんだ質感になります。得られるのはその成分が何を含んでいるかのプレビューです。ステムではないので、成分を重ねて再生しても元の入力には戻りません。
decomposeStems はもう一方の経路を取ります。同じ分解を行ったうえで、成分ごとのソフトマスク(各時間周波数点における、その成分が占める振幅の割合)を作り、それを元の複素スペクトログラムに適用します。位相の推定は一切行いません。各成分はソース自身の位相をそのまま保ち、マスクは各点を近似するのではなく分配するため、成分は足し合わせると入力に戻ります。
マルチチャンネルの録音では、decomposeStemsLinked(Python では decompose_stems_linked)がすべてのチャンネルに 1 つのモデルと 1 組のマスクを当てはめるので、チャンネル間のレベルと位相が保たれます。入力、結果の並び、既定値は音源分離にあります。
decompose + Griffin-Lim | decomposeStems | |
|---|---|---|
| 位相 | 反復的に作り出す | ソース自身のもの |
| コスト | 成分ごとに nIter 回の STFT 往復 | マスク適用 1 回 |
| 成分の総和が入力に戻るか | 戻らない | 戻る |
| 向いている用途 | 成分が何を含むかを見る・聴く | そのまま使い続ける分離音声 |
モノラルの成分を聴かせる、ミックスに載せる、ファイルに書き出す場合は decomposeStems を使います。複数チャンネルで分離を共有し、相対的なレベルと位相を保つ場合は decomposeStemsLinked を使います。Griffin-Lim の経路は、特徴量表現に何が残っているかを確認する用途に向いています。シグネチャと環境ごとの名前(decomposeStems、decomposeStemsLinked、decompose_stems、decompose_stems_linked)は JavaScript API と Python API にあります。
往復の動作確認
よくある用途は、特徴量パイプラインが正しく組めているかの確認です。特徴量を抽出して逆変換し、その差を聴く(または測る)。結果は決して同一にはなりませんが、認識できるはずです。無音やノイズになるなら、どこかのパラメータか行列の形が誤っています。
const mel = melSpectrogram(samples, sampleRate, 2048, 512, 128);
const preview = melToAudio(mel.power, mel.nMels, mel.nFrames, sampleRate, 2048, 512, 0, 0, 32);
// 長さは同じ? 包絡は認識できる? 再生するかラウドネスを比較する。
console.log(samples.length, preview.length);mel = sonare.mel_spectrogram(samples, sample_rate, n_fft=2048, hop_length=512, n_mels=128)
preview = sonare.mel_to_audio(mel.power, mel.n_mels, mel.n_frames, sample_rate=sample_rate, n_iter=32)
# 長さは同じ? 包絡は認識できる? 再生するかラウドネスを比較する。
print(len(samples), len(preview))# 再構成プレビューを聴いてソースと比較する。
sonare mel-to-audio song.wav -o mel-preview.wav両側で同じパラメータを使う
逆変換ヘルパーは、順変換で使ったのと同じ sampleRate、nFft、hopLength、nMels、fmin、fmax、htk のときだけ意味を持ちます。不一致があると、エラーにならないまま「誤っているがもっともらしい」スペクトルが生まれ、最も気づきにくいバグになります。特に、順変換で htk: true やカスタムの fmin/fmax を使った場合は、逆変換にも必ず同じ htk/fmin/fmax を渡してください。さもないとメルフィルタバンクが別物として構築され、再構成は気づかないうちに誤った結果になります。これらの値を特徴量と一緒に保存し、逆変換呼び出しがずれないようにしてください。あるいは、これらの値を持っている順変換の結果そのものを逆変換してください。mel_to_stft(...) は周波数領域にとどまるため hop_length は不要ですが、音声を生成するヘルパーでは必要です。
関連
- librosa 互換性:
librosa.feature.inverse.*との対応 - JavaScript API · Python API:順変換側
- DSP 実装解説:メルフィルタバンクと STFT の構築方法