TadaoYamaokaの開発日記

個人開発しているスマホアプリや将棋AIの開発ネタを中心に書いていきます。

CQTで和音を解析する その3(コレスキー分解)

前回実装した、NNLSのLawson–Hansonアルゴリズムで、能動集合(passive set)に制限した最小二乗問題を解くときにコレスキー分解を行っている。

辞書行列Eが、各半音ごとに独立した倍音スペクトル分布を列として持つため、ほぼフルランク(すべて線形独立)であることを前提にしている。

コレスキー分解とは?

コレスキー分解とは、対称正定値行列 A を次のように分解する方法である。

\displaystyle
A = L L^{T}

ここで:

  • L は 下三角行列(対角要素は正)
  • L^{T}L の転置

つまり、複雑な行列を「三角形の形」に分解できる、というのがコレスキー分解のポイントである。

直感的なイメージ

行列の連立方程式を解くとき、そのままでは計算が重かったり不安定だったりする。
しかし三角行列は「順番に代入していくだけ」で解ける。

コレスキー分解が使える条件

行列 A が次の条件を満たす必要がある。

1. 対称行列 (A = A^{T})
2. 正定値行列(全ての固有値が正)

これらを満たさないと、途中で平方根の計算が破綻する。

具体例で理解する

例として、次の行列を考える:

\displaystyle
A =
\begin{bmatrix}
4 & 2 \\
2 & 3
\end{bmatrix}

この行列は対称かつ正定値である。
コレスキー分解すると、

\displaystyle
L =
\begin{bmatrix}
2 & 0 \\
1 & \sqrt{2}
\end{bmatrix}

となり、

\displaystyle
A = L L^T =
\begin{bmatrix}
2 & 0 \\
1 & \sqrt{2}
\end{bmatrix}
\begin{bmatrix}
2 & 1 \\
0 & \sqrt{2}
\end{bmatrix}
=
\begin{bmatrix}
4 & 2 \\
2 & 3
\end{bmatrix}

が確認できる。

コレスキー分解のメリット

連立一次方程式の高速解法

最小二乗法やガウス過程回帰などでは、

\displaystyle
A x = b

のような連立方程式を解く必要がある。

コレスキー分解を使うと、

1. L y = b を前進代入で解く
2. L^T x = y を後退代入で解く

という2段階の簡単な計算で解が得らる。

数値的安定性

LU分解や逆行列計算に比べて、コレスキー分解は計算が安定しており、誤差が蓄積しにくい。
そのため、機械学習や統計分野では「SPD行列を扱うときはまずコレスキー分解」と言われるほど定番である。

まとめ

  • コレスキー分解は SPD行列を下三角行列に分解する方法。
  • 連立一次方程式や最小二乗法を効率的・安定的に解くのに有効。
  • 機械学習や数値解析で頻出するテクニック。

CQTで和音を解析する その2

前回Pythonで実装したNNLSをWindowsプログラムに組み込んで、リアルタイムで処理できるか検証する。

NNLSの実装

Pythonでは、scipy.optimize.nnlsを使用したが、C++に組み込むため、Lawson–Hansonアルゴリズムを独自実装した(GPT-5で実装)。

static void nnls_lawson_hanson_colmajor(
    const double* A, int m, int n,       // A: m×n(列優先)A[j*m + i] = A_ij
    const double* b,                      // b: m
    std::vector<double>& x,               // x: n(出力)
    std::vector<double>& workAx,          // Ax: m
    std::vector<double>& workRes,         // r = b - A x: m
    std::vector<double>& workW,           // w = A^T r: n
    std::vector<int>& passive,            // 1=Passive(P), 0=Active(Z)
    std::vector<int>& active              // 1=Active(Z), 0=Passive(P)
) {
    const int MAX_IT = 3 * n + 5;
    const double TOL = 1e-10;

    x.assign(n, 0.0);
    passive.assign(n, 0);
    active.assign(n, 1);

    // w = A^T b
    workAx.assign(m, 0.0);
    workRes.assign(m, 0.0);
    workW.assign(n, 0.0);
    for (int j = 0; j < n; ++j) {
        double dot = 0.0;
        const double* col = A + j * m;
        for (int i = 0; i < m; ++i) dot += col[i] * b[i];
        workW[j] = dot;
    }

    int it = 0;
    while (it++ < MAX_IT) {
        // Z(active)から最大正の w を持つ列を選択
        double wmax = 0.0; int t = -1;
        for (int j = 0; j < n; ++j) if (active[j] && workW[j] > wmax) { wmax = workW[j]; t = j; }
        if (t < 0 || wmax <= TOL) break; // 収束

        passive[t] = 1; active[t] = 0;

        // 反復内ループ:負成分が出たら P→Z に戻す
        std::vector<int> Pidx; Pidx.reserve(n);
        for (int j = 0; j < n; ++j) if (passive[j]) Pidx.push_back(j);

        // 最小二乗(正規方程式)を P のみで解く
        std::vector<double> G, g;
        auto solve_on_P = [&](std::vector<double>& xP) -> bool {
            const int p = (int)Pidx.size();
            if (p == 0) return false;
            G.assign(p * p, 0.0); g.assign(p, 0.0);
            // G = A_P^T A_P, g = A_P^T b
            for (int a = 0; a < p; ++a) {
                int ja = Pidx[a];
                const double* colA = A + ja * m;
                for (int b2 = a; b2 < p; ++b2) {
                    int jb = Pidx[b2];
                    const double* colB = A + jb * m;
                    double s = 0.0;
                    for (int i = 0; i < m; ++i) s += colA[i] * colB[i];
                    G[a * p + b2] = G[b2 * p + a] = s;
                }
                double sb = 0.0;
                for (int i = 0; i < m; ++i) sb += colA[i] * b[i];
                g[a] = sb;
            }
            // xP ← 解
            xP = g;
            if (!cholesky_solve_spd(G, p, xP)) return false;
            return true;
        };

        // 旧解(P成分)
        std::vector<double> x_old = x;
        for (;;) {
            // 解候補
            std::vector<double> xP;
            if (!solve_on_P(xP)) break;

            // 候補を全成分ベクトルへ詰める
            std::vector<double> xCand(n, 0.0);
            for (int k = 0; k < (int)Pidx.size(); ++k) xCand[Pidx[k]] = xP[k];

            // すべて非負なら採用
            bool allPos = true;
            for (int k = 0; k < (int)Pidx.size(); ++k) if (xCand[Pidx[k]] <= 0.0) { allPos = false; break; }
            if (allPos) { x.swap(xCand); break; }

            // α を求めて0に落ちる変数を Z へ戻す
            double alpha = 1.0;
            for (int k = 0; k < (int)Pidx.size(); ++k) {
                int j = Pidx[k];
                if (xCand[j] <= 0.0) {
                    double denom = xCand[j] - x_old[j];
                    if (denom != 0.0) {
                        double a = x_old[j] / (x_old[j] - xCand[j]);
                        if (a > 0.0 && a < alpha) alpha = a;
                    }
                }
            }
            for (int j = 0; j < n; ++j) x[j] = x_old[j] + alpha * (xCand[j] - x_old[j]);

            // 0 になったものを Z へ
            for (int k = 0; k < (int)Pidx.size(); ++k) {
                int j = Pidx[k];
                if (x[j] <= 1e-16) { x[j] = 0.0; passive[j] = 0; active[j] = 1; }
            }
            // P の再構成
            x_old = x;
            Pidx.clear(); for (int j = 0; j < n; ++j) if (passive[j]) Pidx.push_back(j);
            if (Pidx.empty()) break;
        }

        // w = A^T (b - A x)
        std::fill(workAx.begin(), workAx.end(), 0.0);
        for (int j = 0; j < n; ++j) if (x[j] != 0.0) {
            const double* col = A + j * m;
            double cj = x[j];
            for (int i = 0; i < m; ++i) workAx[i] += col[i] * cj;
        }
        for (int i = 0; i < m; ++i) workRes[i] = b[i] - workAx[i];
        for (int j = 0; j < n; ++j) {
            if (!active[j]) { workW[j] = 0.0; continue; }
            const double* col = A + j * m;
            double dot = 0.0;
            for (int i = 0; i < m; ++i) dot += col[i] * workRes[i];
            workW[j] = dot;
        }
    }
}

結果

リアルタイムでも問題なく処理できることがわかった。
Debugビルドしたバイナリでも遅延なく処理できる。

単音の声の解析結果

・CQT

・NNLS

CQTと比べて、NNLSの方が倍音のピークの数が減っていることがわかる。
特に6倍音の位置は、ピークがほぼ消えている。

ミックスされた曲

ミックスされた曲でも比較した。
女性のボーカルの入っている曲であり、ボーカルの基本周波数はC4~C5のあたりで多くなる。

・CQT

・NNLS

CQTでは、C4~C5のボーカルの基本周波数のピークが薄いのに対して、NNLSではピークがはっきりしている。

ミックスされた曲は、ボーカルの基本周波数の帯域は、低音の楽器の倍音と重なるため、削られ、2kHzくらいの倍音のある帯域を持ち上げることがある。
NNLSを使うと、基本周波数が強調されることになるので、音階がわかりやすくなる。

まとめ

WindowsプログラムにC++で独自実装したNNLSを組み込んだ。
結果、リアルタイム処理でも問題ない計算量であることが確認できた。

また、実際のミックスされた曲で基本周波数が強調される効果があることが確認できた。

次は、VST3プラグインに組み込みたい。

CQTで和音を解析する

スペクトログラム解析VST3プラグインに和音の解析機能を加えたい。

ミックスされた音源から和音の構成音を取り出すのは容易ではなく、古典的にはCQT や HPCPをピッチクラス化して推定する方法がある。
この方法では、倍音が構成音と誤検知される場合があり、精度を上げるのが難しい。

最新の精度の高い手法は、Deep Chromaのようなディープラーニングベースの手法が用いられる。

ディープラーニングベースの手法は計算量が大きくスマホでのリアルタイム解析には向かないため、軽量である程度の精度がでる手法がないか調査したところ、「Approximate Note Transcription for the Improved Identification of Difficult Chords」という方法を見つけた。

NNLS Chroma

上記の論文の手法では、NNLS Chromaという特徴量からコードを推定する。

NNLSは、Non-Negative Least Squares(非負最小二乗法)の略で、解が正の値のみという制約付き最適化問題である。

CQTのスペクトルから、A0からG#6までのどの音が鳴っているかを最適化問題として求める。

スペクトルは複数の音が混ざったものとして、理想の倍音構成の各音が、それぞれどの強さで鳴っているかを求める。

NNLSの解は、解析的には求めることはできないため、反復法で解く必要がある。

NNLSを解くアルゴリズムには、Solving Least Squares Problems(Lawson, C.L. & Hanson, R.J.)や、その改良アルゴリズムA Fast Non‐Negativity‐Constrained Least Squares Algorithm(Bro, R. & de Jong, S.)が用いられる。
後者は、scipy.optimize.nnlsに実装されている。

実験

CQTからピッチクラス化した場合と、NNLS Chromaを使用した場合の比較を行う。

CQTのみ

まずは、CQTからピッチクラス化した結果を示す。

音源は、librosa付属のnutcracker(くるみ割り人形金平糖の精の踊り」)を使用する。
この曲のキーはEマイナーである。

import librosa
file_path = librosa.ex("nutcracker")
target_sr = 11025
y, sr = librosa.load(file_path, sr=target_sr, mono=True)

C = librosa.cqt(
    y=y,
    sr=sr,
    hop_length=2048,
    bins_per_octave=36,
    n_bins=36 * 7,
)
librosa_chroma = librosa.feature.chroma_cqt(
    C=C, sr=sr, hop_length=2048
)

librosa.display.specshow(
    librosa_chroma,
    sr=sr,
    hop_length=2048,
    x_axis="time",
    y_axis="chroma",
    cmap="viridis",
)

結果

NNLS Chroma

次に、NNLS Chromaからピッチクラス化した結果を示す。

NNLSの実装は、論文にならいChromaとBass chromaをそれぞれ求める。

@dataclass
class NNLSChroma:
    sr: int = 11025
    hop_length: int = 2048
    bpo: int = 36
    n_octaves: int = 7
    s_param: float = 0.6
    preprocessing: str = "std"
    chroma_norm: int = 3

    def compute(self, y):
        n_bins = self.bpo * self.n_octaves
        fmin = librosa.midi_to_hz(21)
        tuning = librosa.estimate_tuning(y=y, sr=self.sr, bins_per_octave=self.bpo)
        C = librosa.cqt(
            y=y,
            sr=self.sr,
            hop_length=self.hop_length,
            fmin=fmin,
            n_bins=n_bins,
            bins_per_octave=self.bpo,
            tuning=tuning,
        )
        Y = np.abs(C).T
        half_span = 18
        proc_Y = np.zeros_like(Y)
        for t in range(Y.shape[0]):
            mu, std = _running_mean_std_1d(Y[t], half_span)
            sub = Y[t] - mu
            sub[sub < 0] = 0.0
            if self.preprocessing == "std":
                std_safe = np.where(std > 1e-6, std, 1.0)
                proc_Y[t] = sub / std_safe
            else:  # "o" or "sub"
                proc_Y[t] = Y[t] if self.preprocessing == "o" else sub
        E = _build_note_dictionary(
            n_bins, bpo=self.bpo, n_octaves=self.n_octaves, s=self.s_param, fmin=fmin
        )
        n_semitones = self.n_octaves * 12
        S = np.zeros((proc_Y.shape[0], n_semitones), dtype=float)
        for t in range(proc_Y.shape[0]):
            x, _ = nnls(E, proc_Y[t])
            S[t] = x
        chroma = np.zeros((S.shape[0], 12), dtype=float)
        bass_chroma = np.zeros((S.shape[0], 12), dtype=float)
        pc_offset = 21 % 12  # fmin が A0 のため 9
        for t in range(S.shape[0]):
            for i in range(n_semitones):
                chroma[t, (i + pc_offset) % 12] += S[t, i] * TREBLE_WINDOW[i]
                bass_chroma[t, (i + pc_offset) % 12] += S[t, i] * BASS_WINDOW[i]
            for v in (chroma[t], bass_chroma[t]):
                if self.chroma_norm == 1:
                    m = v.max()
                    if m > 1e-6:
                        v /= m
                elif self.chroma_norm == 2:
                    s_norm = v.sum()
                    if s_norm > 1e-6:
                        v /= s_norm
                elif self.chroma_norm == 3:
                    n = np.linalg.norm(v)
                    if n > 1e-6:
                        v /= n
        times = librosa.frames_to_time(
            np.arange(S.shape[0]), sr=self.sr, hop_length=self.hop_length
        )
        return times, chroma, bass_chroma, S, tuning
extractor = NNLSChroma(
    sr=target_sr, preprocessing="std", s_param=0.6, chroma_norm=3
)
times, nnls_chroma, nnls_bass_chroma, semitone, tuning = extractor.compute(y)

librosa.display.specshow(
    nnls_chroma.T,
    sr=sr,
    hop_length=extractor.hop_length,
    x_axis="time",
    y_axis="chroma",
    ax=axes[1],
    cmap="viridis",
)

librosa.display.specshow(
    nnls_bass_chroma.T,
    sr=sr,
    hop_length=extractor.hop_length,
    x_axis="time",
    y_axis="chroma",
    ax=axes[2],
    cmap="viridis",
)

結果

※比較のため、CQTの結果も並べている。

NNLS Chromaの結果は、CQTと比べてピッチクラスがすっきりしており、構成音が分かりやすくなっている。

また、ピッチクラス化前のスペクトル( (d)と(e) )を比べると、(e)のCQTの結果では高域が倍音により密になっているのに比べて、(d)のNNLS Chromaでは低域から高域まで疎な音で構成されているのが分かる。

まとめ

ミックスされた曲の構成音を推定する手法について調査した。
比較的軽量で精度が高そうな手法であるNNLS Chromaを実装してみた。
CQTからそのままピッチクラス化するよりも構成音が分かりやすくなることが確認できた。

NNLSの解を反復法で求めるにはO(m * n^2)※(m=ビン数252, n=音符数84)の計算量がかかるため、リアルタイムで処理できるか検証したい。

スペクトログラム解析VST3プラグイン

先日、実装したリアルタイムスペクトログラム解析VST3プラグインをPayhipで公開した。

payhip.com


Audio Spectrogram Monitor VST3 — Windows対応 リアルタイムCQTスペクトログラム(ピアノロール軸表示)

Audio Spectrogram Monitor は、トラックに挿入するだけで使えるシンプルかつゼロレイテンシースペクトログラムモニターです。 72分割/オクターブの定Q変換(CQT)ディスプレイに、鍵盤の白黒を模したピアノロール軸DAWと同期する小節/拍ガイドライン、オプションのピッチライン、さらにMIDIノートオーバーレイを備えています。耳コピ(採譜)や演奏練習、サウンドデザイン、ミックス分析に最適です。


特長

  • 音楽的なCQTグリッド(72分割/オクターブ) 半音ごとのグリッドとオクターブラベルを表示。範囲は C1 〜 B7 と、声や楽器の大部分をカバーします。
  • ピアノロール軸 & ホバー強調 左側に鍵盤を模した白黒キー領域を表示。マウスホバーで対象の半音帯域をハイライト。
  • ホスト同期ガイド DAWのテンポと拍子に同期した 拍(細線)/小節(太線)マーカーを描画。
  • MIDIノートオーバーレイ アクティブなMIDIノートをスペクトログラム上に重ねて表示。採譜やスケール練習のターゲット比較に便利。
  • オプションのピッチライン 「Pitch Line」オプションを有効にすると、検出したピッチを滑らかな線で表示。
  • 直感的なUI操作

    • Shift + ホイール: 時間方向のズーム
    • Ctrl + ホイール: ピッチ方向のズーム
    • ホイール: 周波数範囲のスクロール
    • Ctrl + 0: ズーム/スクロールをリセット
  • オーディオを変化させない ステレオ信号をそのまま通過させる、純粋なビジュアルモニター。
  • 約30fpsのスムーズな描画で、快適な動作と軽量なCPU負荷を両立。

同梱物

  • vst3_spectro.vst3.zip(Payhipの「ダウンロード」ファイル)
  • 解凍すると vst3_spectro.vst3 フォルダが含まれています。

動作環境

  • Windows 64bit専用Windows 10/11 推奨)
  • VST3対応DAW
  • 32bit浮動小数点を含む標準的なオーディオフォーマットを処理可能。

インストール(Windows

  1. vst3_spectro.vst3.zip をダウンロードして保存。
  2. 右クリック → 「すべて展開」を選択。
  3. vst3_spectro.vst3 フォルダが生成されます(フォルダのまま保持)。
  4. フォルダごと以下にコピーします: C:\Program Files\Common Files\VST3
  5. DAWを再起動し、必要ならプラグインを再スキャン。VST3一覧に vst3_spectro が表示されます(一部のDAWでは「Other」に分類されます)。

クイックスタート

  1. 対象のオーディオトラックにプラグインを挿入。
  2. DAWで再生を開始すると、ビート/小節ライン付きでスペクトログラムがリアルタイム表示されます。
  3. (オプション)Pitch Enable をONにして、検出されたピッチをラインで確認。
  4. (オプション)MIDIトラックをルーティングすると、ターゲットノートを重ねて表示できます(耳コピやスケール練習に便利)。

ヒント & メモ

  • スペクトログラムは音楽向けに調整された 定Q変換を採用。ピッチラインは、ボーカルや多くの楽器に適した範囲をカバーしています。
  • Windows専用プラグインです(macOSは非対応)。
  • オーディオは常に変更されずに通過します。EQやコンプなどの処理は一切行いません。

Payhip使ってみる

先日開発したVocal Pitch MonitorのVST3版をPayhipで公開した。

payhip.com

はじめGumroadで公開したがPayoutの設定がうまくいかなかったので、Payhipに変更した。

Payhipは、PayPalもしくはStripeと連携することができる。
PayPalのアカウントがあったので連携してみたら、簡単に連携できた。

PayPalから銀行に出金するには手続きが必要になるが、PayPalオライリーの書籍とか購入できるので出金できなくてもよい。
VST3プラグインは需要がほとんどないのでそんな心配は不要だと思うが。

まとめ

GPT-5を使って、以前に作っていたVSTプラグインをさくっとVST3プラグイン化したので、Payhipで公開してみた。

Payhipは、簡単にPayPalと連携できたので、個人で海外向けにソフトウェアを公開するには便利である。

国内向けには、昔からあるVectorや、BOOTHなども併用すると良いかもしれない。

スマホ版のVocal Pitch Monitorは、海外のユーザが多いので今回はPyhipを選択した。

CQTでスペクトログラムを描く その10(拡大縮小)

自作スペクトログラム解析VST3プラグインにマウスホイールによる拡大縮小とスクロール処理を実装した。

はじめ解析範囲をVSTのパラメータに使用としたが、マウスホイールの方が操作しやすいのでそちらで実装することにした。

操作

  • Ctrl + ホイール → 垂直方向のピッチ拡大・縮小
  • Shift + ホイール → 水平方向の時間拡大・縮小
  • 修飾キーなし → 垂直スクロール(表示位置の移動)

実装

解析範囲を動的に変更するのは処理が複雑になるため、表示範囲のみ変えることで対応した。

既存の描画処理にオフセットを加算して、縮尺の係数を掛けるだけで対応できた。

結果

拡大して、特定の音域にスクロールして、マウスでハイライトすることで、採譜が行いやすくなった。

まとめ

拡大縮小とスクロールを実装した。

これで、実装したい機能はほぼ実装できた。

Gumroadで配布しようと思っていたが、payoutの設定がうまくいかず別の手段を検討している。

CQTでスペクトログラムを描く その9(補間方法変更)

その6で、低域をバンク分割することで、時間分解能を上げつつ、最小音階をC1まで下げることができた。

その際に、低域のバンクでは、オクターブあたりのビン数が高域の半分になるため、本来のCQTのビンの間隔の半分の間隔でスペクトルカーネルを作成することで、高域とビン間隔が同じになるように補間を行った。

放物線補間の廃止

一方、高域ではその2で、放物線補間により、周波数軸での補間を行っている。

2通りの補間方法を採用する理由もないので、スペクトルカーネルの間隔で補間する方式に統一することにした。

バンク分割

これにより、

  • 高域では、オクターブ当たりのビン間隔=24、ビンを3グリッドに細分化
  • 低域では、オクターブ当たりのビン間隔=12、ビンを6グリッドに細分化

となり、オクターブ当たりの72グリッドに統一されることになる。

ついでに高域のバンクをさらに分割して、

  • オクターブ当たりのビン間隔=36、ビンを2グリッドに細分化

のバンクを追加する。

3つのバンクで、高域のバンクほど周波数分解能が高くなる。

結果

F2以下が低域バンクになっているため、ぼやけている。
これは、時間分解能とのトレードオフのため、補間を行っても本質的には改善しない。

時間分解能と周波数分解能のトレードオフをパラメータで設定できるようにすると良いかもしれない。

まとめ

放物線補間を廃止して、スペクトルカーネルを本来のビン間隔より狭くすることで補間を行うようにした。
また、高域のバンクを追加して、高域の周波数分解能を上げた。