技術 約5分で読めます

昔作ったカラオケ採点、今ならもっとマシに作れるんじゃね?

昔、仕事でブラウザ上にカラオケ採点っぽいものを作ったことがある。入力音声と元の歌声を比較して、どれだけ似ているかをスコア化するやつ。自分の中では「三大ウェブでやるんですか!?案件」の一つ。

結果は… 微妙だった。出だしのタイミングがちょっとズレるだけでスコアがガタ落ちする。実用には程遠かった。

今ならもうちょっとマシなものが作れるんじゃないか?という話。

当時の実装を振り返る

当時書いた仕様書が残っていたので、振り返ってみる。

使った技術

  • Media Capture And Streams API でマイク入力を取得
  • FFT(高速フーリエ変換) で周波数分解
  • 相関係数 で類似度をスコアリング

当時はiOSが非対応で、PC版・Android版のChromeでしか動かなかった。今はもう少しマシになっているはず。

処理の流れ

  1. マイクから音声を入力
  2. 0.1〜0.3秒単位でFFT → 周波数ごとの強度を配列に保持
  3. 元の音声データも同様にFFT済みのデータを用意
  4. 入力音声と元音声の、周波数ごとの時間変化を比較
  5. 相関係数で類似度を計算
  6. 可聴域(20Hz〜22kHz)の各周波数帯で相関係数を出して合計

FFTって何?

FFT(Fast Fourier Transform / 高速フーリエ変換)は、音声の波形データを「どの周波数成分がどれだけ含まれているか」に変換する処理。

音声波形は時間軸で見ると複雑だけど、実は色んな周波数の正弦波が重なったもの。FFTを使うと、その内訳がわかる。

X(k)=n=0N1x(n)ei2πknNX(k) = \sum_{n=0}^{N-1} x(n) \cdot e^{-i \frac{2\pi kn}{N}}
  • x(n)x(n): 入力信号(時間領域)
  • X(k)X(k): 出力(周波数領域)
  • NN: サンプル数

数式は難しそうだけど、要は波形 → 周波数スペクトルへの変換。

なぜ音声比較にFFTを使うのか?

波形のまま比較すると、位相(波の開始位置)のズレに敏感すぎる。FFTで周波数成分に分解すれば、「どの高さの音がどれだけ鳴っているか」で比較できる。

波形: ~~~∿∿∿~~~  →  FFT  →  周波数スペクトル: [440Hz: 0.8, 880Hz: 0.3, ...]

カラオケ採点で言えば、「440Hz(ラの音)がちゃんと出ているか」みたいな比較ができる。

ブラウザでFFTできるの?

できる。当時はMedia Capture And Streams APIでやったけど、今はWeb Audio APIのAnalyserNodeを使えばもっと簡単。後述する。

相関係数でのスコアリング

相関係数は-1〜1の値を取る。1に近いほど「同じような変化をしている」ことを意味する。

ρAB=Cov(A,B)s(A)s(B)\rho_{AB} = \frac{\text{Cov}(A, B)}{s(A) \cdot s(B)}
  • Cov(A,B)\text{Cov}(A, B): AとBの共分散
  • s(A)s(A), s(B)s(B): それぞれの標準偏差

周波数帯ごとにこの相関係数を計算して、全部足し合わせる。完全に同じ音声なら、すべての周波数帯で相関係数が1になるので、最大スコアになる。

計算量を減らすために、偏差ベクトルの内積だけで計算できるように式変形もした。当時自分で導出したやつだけど、10年経って見返したらちゃんと合ってた。よかった。

何が問題だったか

時間軸のズレに弱すぎた

相関係数は「同じタイミングでの値」を比較する。だから:

  • 歌い出しが0.2秒ズレる → 全フレームで比較対象がズレる → スコア激減
  • 途中でテンポが微妙に速い/遅い → 後半になるほどズレが蓄積

リアルのカラオケ採点は、人間がある程度自由なタイミングで歌っても対応できる。当時の実装はそれができなかった。

今ならどうする?

1. 時間軸のズレを吸収する: DTW

DTW(Dynamic Time Warping / 動的時間伸縮法) を使えば、時間軸のズレを吸収できる。

DTWは「2つの時系列データを、最も似ているように時間軸を伸縮させながらマッチングする」手法。音声認識や手書き文字認識でよく使われる。

// DTWの概念的なコード
function dtw(seq1, seq2) {
  const n = seq1.length;
  const m = seq2.length;
  const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(Infinity));
  dp[0][0] = 0;

  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= m; j++) {
      const cost = Math.abs(seq1[i - 1] - seq2[j - 1]);
      dp[i][j] = cost + Math.min(
        dp[i - 1][j],     // 挿入
        dp[i][j - 1],     // 削除
        dp[i - 1][j - 1]  // マッチ
      );
    }
  }

  return dp[n][m];
}

DTWを使えば「0.2秒遅れて歌い始めた」くらいは吸収できる

ただし、両方の音声の全フレームを総当たりで比較するので、音声が長くなると計算量が爆発する。1分の音声同士を比較するだけでも数百万回の計算が必要になる(計算量でいえばO(n×m))。FastDTWという近似アルゴリズムを使えば軽くなる。

2. 音の始まりを自動検出: オンセット検出

もう一つのアプローチは、オンセット検出で音の始まりを自動的に見つけて同期させること。

オンセット検出は、音のエネルギーが急激に上がるポイント(=音の始まり)を検出する手法。

function detectOnsets(audioData, threshold = 0.5) {
  const onsets = [];
  const windowSize = 1024;

  for (let i = windowSize; i < audioData.length; i += windowSize) {
    const prevEnergy = calculateEnergy(audioData.slice(i - windowSize, i));
    const currEnergy = calculateEnergy(audioData.slice(i, i + windowSize));

    // エネルギーの急激な上昇を検出
    if (currEnergy > prevEnergy * threshold && currEnergy > someMinThreshold) {
      onsets.push(i);
    }
  }

  return onsets;
}

元音声と入力音声の両方でオンセットを検出して、最初のオンセット同士を合わせれば、歌い出しのズレは解消できる。

3. ピッチ検出の改善

当時はFFTで周波数スペクトル全体を比較していたけど、カラオケ採点ならピッチ(基本周波数) だけ比較するほうが妥当かもしれない。

ピッチ検出には:

  • 自己相関法: 波形の自己相関からピッチを推定
  • YIN: 自己相関法の改良版、精度が高い
  • FFTピーク検出: FFT結果から最大ピークを取る(簡易的)
// YINアルゴリズムの概念
function yin(audioData, sampleRate) {
  // 差分関数を計算
  const diff = calculateDifference(audioData);

  // 累積平均正規化差分関数
  const cmndf = cumulativeMeanNormalizedDifference(diff);

  // 閾値以下の最初の谷を探す
  const tau = findFirstValley(cmndf, threshold);

  // ピッチを計算
  return sampleRate / tau;
}

4. Web Audio APIで実装が楽になった

当時の仕様書には「Web Audio APIだとFFT処理に手間がかかりすぎる」と書いてあったけど、今はAnalyserNodeを使えば簡単にFFTできる。

const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);

// FFT結果を取得
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(frequencyData);

// 波形データを取得
const waveformData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(waveformData);

これだけでFFTされた周波数データが取れる。当時よりずっと楽。

改善版の設計案

今作り直すならこんな感じ:

処理フロー

入力音声 → オンセット検出 → ピッチ系列抽出

元音声   → オンセット検出 → ピッチ系列抽出

                      DTWでマッチング

                        スコア計算

スコアリング

  1. 両方の音声からピッチ系列を抽出
  2. DTWで最適なアライメントを求める
  3. アライメント後のピッチ差の平均をスコアに変換
function calculateScore(pitches1, pitches2) {
  // DTWでアライメント
  const { path, distance } = dtw(pitches1, pitches2);

  // 正規化した距離をスコアに変換(0-100)
  const maxDistance = estimateMaxDistance(pitches1.length);
  const score = Math.max(0, 100 - (distance / maxDistance) * 100);

  return score;
}

まとめ

当時の問題点:

  • 時間軸のズレに弱い(相関係数は同じタイミング同士を比較)
  • 歌い出しがズレるとスコアが崩壊

今ならこうする:

  • DTW で時間軸のズレを吸収
  • オンセット検出 で歌い出しを自動同期
  • ピッチ検出 に特化(周波数スペクトル全体じゃなく)
  • Web Audio API の AnalyserNode で実装が楽

実際に作り直すかは… 気が向いたら。Labツールとして公開したら面白いかもしれない。