mp3などの楽曲ファイルを入力すると、ボーカルと伴奏を分離し、ボーカルの文字起こしとキー推定、さらに伴奏側のコード進行と小節ごとのキー進行までまとめて出力する CLI を実装しました。
実行結果
まず、どんな出力が得られるかを先に載せておきます。
この CLI はテキスト出力と JSON 出力の両方に対応しています。
uv run chord-mode-analyzer path/to/song.mp3
uv run chord-mode-analyzer path/to/song.mp3 --json
テキスト出力のイメージ:
bpm: 124.16
vocal_key: Am conf=0.812
instrument_key: C conf=0.847
measure_keys (8 measures):
| C | G | Am | F |
| C | G | Am | F |
lyrics_with_chords:
[C / G] いつもの帰り道で
[Am / F] 君のことを思い出した
[C] まだ少しだけ
[G / Am / F] あの声が残っている
JSON 出力のイメージ:
{
"audio_path": "path/to/song.mp3",
"stems": {
"vocals": "/abs/path/.separated/htdemucs/song/vocals.wav",
"accompaniment": "/abs/path/.separated/htdemucs/song/no_vocals.wav"
},
"lyrics_with_chords": [
{
"start": 0.52,
"end": 2.81,
"text": "いつもの帰り道で",
"chord": "C / G"
}
],
"rendered_lyrics": "[C / G] いつもの帰り道で",
"transcript_segments": [
{
"start": 0.52,
"end": 2.81,
"text": "いつもの帰り道で"
}
],
"bpm": 124.16,
"measure_times": [0.0, 1.94, 3.87, 5.81],
"measure_chords": [
{ "measure": 1, "start": 0.0, "end": 1.94, "chord": "C" },
{ "measure": 2, "start": 1.94, "end": 3.87, "chord": "G" }
],
"vocal_key": { "key": "Am", "confidence": 0.812 },
"instrument_key": { "key": "C", "confidence": 0.847 },
"instrument_key_progression": [
{ "start": 0.0, "end": 3.87, "key": "C", "confidence": 0.861 },
{ "start": 3.87, "end": 7.74, "key": "Am", "confidence": 0.804 }
]
}
実装方針
このプロジェクトでは、楽曲解析を以下の4ステップに分けています。
-
Demucsで音源をボーカルと伴奏に分離する - ボーカルを
Whisperで文字起こしする - ボーカルと伴奏それぞれからクロマ特徴量を作り、キーを推定する
- 伴奏側からコードセグメントを作り、歌詞の時間帯に重ねて「コード付き歌詞」を作る
分離・文字起こし・音楽理論ベースの推定をつなぐことで、ひとつのモデルに全部やらせるよりも役割が明確になり、結果の構造も扱いやすくなります。
メリット
- ボーカルと伴奏を分けることで、文字起こしと和声解析をそれぞれ得意な素材に対して実行できる
- 出力が JSON 化されているため、別アプリやフロントエンドに組み込みやすい
- キー推定だけでなく、歌詞とコードの対応表まで一気に作れる
- CLI なので手元の楽曲ファイルに対してそのまま試せる
デメリット
-
DemucsとWhisperの初回モデルダウンロードのコスト(時間・容量)が大きい - 分離品質が悪い音源では、文字起こし精度やコード推定精度がそのまま下がる
- 小節推定は 4/4 拍子前提なので、変拍子やテンポ揺れの大きい曲では崩れやすい
- コード推定はテンプレート照合ベースなので、テンションやオンコードの表現力には限界がある
アーキテクチャ
パイプラインは大きく次のような流れです。
モジュールの役割分担
| モジュール | 役割 |
|---|---|
separation.py |
Demucs 実行とステムファイルの解決 |
transcription.py |
Whisper によるボーカル文字起こし |
audio.py |
librosa によるクロマ・ビート・小節情報の抽出 |
chord_extraction.py |
キー推定とコードセグメント推定 |
music_theory.py |
コード名・キー名と Krumhansl-Schmuckler プロファイル |
pipeline.py |
各工程をつないで最終 JSON を構築 |
cli.py |
引数解析と表示形式の切り替え |
時間情報で歌詞とコードを結びつける
文字起こし結果とコード推定結果をどちらも時間区間として保持して、あとから重ね合わせています。
Whisper 側は各セグメントに start / end を持っています。
@dataclass(frozen=True)
class TranscriptSegment:
start: float
end: float
text: str
コード推定側も同じように時間区間を持っています。
@dataclass
class ChordSegment:
start: float
end: float
label: ChordLabel
confidence: float
この 2 つの区間が重なっているかどうかを見て、歌詞 1 行に対してコード列を割り当てています。
def _collect_overlapping_chords(
transcript_segment: TranscriptSegment, chord_segments: list[ChordSegment]
) -> list[str]:
labels: list[str] = []
for chord_segment in chord_segments:
overlap_start = max(transcript_segment.start, chord_segment.start)
overlap_end = min(transcript_segment.end, chord_segment.end)
if overlap_end <= overlap_start:
continue
symbol = chord_segment.label.symbol
if not labels or labels[-1] != symbol:
labels.append(symbol)
return labels
この実装にしておくと、歌詞を中心に見たときのコード感がかなり把握しやすくなります。
単に時系列でコードだけを並べるより、「どのフレーズの裏で何が鳴っていたか」が読み取りやすいです。
キー推定の実装
キー推定は機械学習モデルではなく、クロマベクトルと Krumhansl-Schmuckler のキープロファイルの相関で求めています。
def estimate_key_from_chroma(chroma_mean: np.ndarray) -> tuple[KeyLabel, float]:
norm = np.linalg.norm(chroma_mean)
vec = chroma_mean / norm if norm > 0 else chroma_mean
best_score = -float("inf")
best_key = KeyLabel(0, "major")
for root in range(12):
for mode, profile in (("major", _KS_MAJOR), ("minor", _KS_MINOR)):
rotated = np.roll(profile, root)
rotated = rotated / np.linalg.norm(rotated)
score = float(np.dot(vec, rotated))
if score > best_score:
best_score = score
best_key = KeyLabel(root=root, mode=mode)
return best_key, best_score
やっていることはシンプルで、12 個のルート音 × 長調/短調の 24 パターンを全部試し、もっとも相関が高いものを採用しています。
伴奏全体の代表キーだけでなく、ビート単位で同じ処理を回して連続区間をまとめることで、instrument_key_progression も作っています。
そのため、転調や一時的なモーダルな揺れもある程度は追える設計です。
コード推定の実装
コード推定も同じくクロマベクトルに対するテンプレートマッチングです。
C, Cm, C7, Cmaj7, Cm7, Cdim のような基本テンプレートを持っておき、ルートを 12 音分だけ回転させて総当たりしています。
CHORD_QUALITIES = {
"": np.array([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
"m": np.array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]),
"dim": np.array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
"7": np.array([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]),
"maj7": np.array([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
"m7": np.array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]),
}
もちろん本格的なコード認識器と比べると表現力は限定的です。ただし、CLIの出力として「ざっくり伴奏の流れをつかむ」用途には十分実用的です。
小節ごとのキー進行表示
このプロジェクトでは、単にビートごとにキーを出すのではなく、小節単位の表示も用意しています。
小節境界は librosa.beat.plp を使って推定し、4/4 拍子を前提に「テンポの 1/4 を小節テンポ」とみなしてピークを拾っています。
def _detect_measures(
y: np.ndarray, sr: int, hop_length: int, tempo: float
) -> np.ndarray:
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
measure_bpm = tempo / 4
pulse = librosa.beat.plp(
onset_envelope=onset_env,
sr=sr,
hop_length=hop_length,
tempo_min=max(20.0, measure_bpm * 0.75),
tempo_max=measure_bpm * 1.25,
)
その小節区間に重なったキーを C, G, Am のように割り当てることで、CLI 上でもかなり読みやすいグリッドになります。
| C | G | Am | F |
| C | G | Am | F |
コード進行そのものとは別に、「この 4 小節は実質 C メジャー圏、ここから A マイナー寄り」といった大きな流れが見えて便利です。
実装上のポイント
精度を上げるためのポイントを解説します。
1. 伴奏とボーカルで処理対象を分けている
文字起こしはボーカルだけに掛けたいですし、和声解析はなるべく歌声の影響を減らしたいです。
そのため最初に Demucs を挟む設計は理にかなっていると思っています。
2. HPSS で打楽器成分を弱めてからクロマを取っている
audio.py ではそのままクロマを計算するのではなく、librosa.effects.hpss で倍音成分を抽出してから chroma_cqt を作っています。
これによりドラムのアタックに引っ張られにくくなっています。
3. Demucs の保存形式エラーにフォールバックがある
一部環境では wav 保存時に TorchCodec 関連エラーが起きることがあります。
この実装ではそれを検知して、自動で --mp3 出力に切り替えて再試行します。
try:
_run_demucs(_build_command(source=source, stems_root=stems_root, model=model, use_mp3=False))
except RuntimeError as exc:
message = str(exc)
if "TorchCodec is required for save_with_torchcodec" not in message:
raise RuntimeError(f"Demucs separation failed: {message}") from exc
_run_demucs(_build_command(source=source, stems_root=stems_root, model=model, use_mp3=True))
精度について
このプログラムによる推定とMoisesというツールによる分析を、Claudeに検証してもらいます。
水瀬いのりさんのStarlight Museumという曲のイントロ部分になります。
| Am7, G# | G#, G#maj7, Cm, C#maj7 | C#maj7, A#m, C#, C | C, Fm, Fm7, Fm |
| Fm, A#m7, F#, G#7, A#m7 | A#m7, G#maj7, C#maj7 | C#maj7, G#maj7 | G#maj7, Bmaj7 |
|Ab | Gm7-5, C | Fm | Ebm7 Ab7 |
|DbM7 | Ab/C | B | Bbm7 Eb7 |
Claudeによる比較分析
まず、推定側が「シャープ系表記」、ツール側が「フラット系表記」なので、異名同音変換をしながら比較します。
| ツール | 推定(変換後) |
|---|---|
| Ab = G# | G# ✓ |
| Db = C# | C# ✓ |
| Bb = A# | A# ✓ |
| Eb = D# | D# ✓ |
小節ごとの対照
| 小節 | ツール | 推定(変換) | 一致度 |
|---|---|---|---|
| 1 | Ab | Am7, G# | △ G#は一致。Am7はAbと半音違いで誤検出の疑い |
| 2 | Gm7-5, C | G#, G#maj7, Cm, C | △ Cは一致。Gm7-5(半減)は推定に対応なし |
| 3 | Fm | C#maj7, A#m, C#, C | △ C#maj7はDbmaj7でF・Abを含み、Fmと構成音が重複 |
| 4 | Ebm7, Ab7 | C, Fm7, Fm | ○ Fm7(F,Ab,C,Eb) と Ab7(Ab,C,Eb,Gb) は3音共有 |
| 5 | DbM7 | Fm, A#m7, G#7, A#m7 | ○ A#m7=Bbm7(Bb,Db,F,Ab)はDbM7と3音共有。機能的に代理関係 |
| 6 | Ab/C | A#m7, G#maj7, C#maj7 | ◎ G#maj7はAbmaj7と同義。Ab/CとG#maj7は3音一致 |
| 7 | B | C#maj7, G#maj7 | △ BとG#maj7は並行関係にはなく、調性的文脈が異なる |
| 8 | Bbm7, Eb7 | G#maj7, Bmaj7 | △ G#maj7はEb7の解決先(I)として整合。Bmaj7はBbm7と半音誤差 |
総合評価
「同じ調性空間にいるが、局所的な一致率は中程度」
似ている点 ✓
- 調性中心が一致:Ab長調(G#長調)
- IV度領域:DbM7 ↔ C#maj7 は完全一致(異名同音)
- 小節6:Ab/C ↔ G#maj7 はほぼ同じ和音
- 小節5:A#m7がDbM7の代理コードとして機能的に正しい
異なる点 ✗
-
小節2:
Gm7-5(Gハーフディミニッシュ)が推定側に完全欠落。これはAbの文脈では重要な経過和音 -
小節1:
Am7はAbと半音ずれており、おそらく検出ミス - 小節7・8:BとBmaj7周辺の解釈がかなり異なる
Claudeによる結論
調性中心・大きなカデンツ骨格(I → IV → ii-V → I)は両者で共有されています。ただし、クロマティックな経過和音(Gm7-5、B)の扱いや、異名同音の表記揺れによる誤検出が推定側に見られます。「同じ曲を分析している」とは言えますが、細部の和声機能まで含めた一致率は50〜60%程度と評価します。
私の見解
音楽理論は全く分かりませんが、コード推定で出力されるコード数がかなり多いので、判定のためのウィンドウが狭いように思います。キーを推定して、いくつかのキーから妥当なコードを導き出すのが良さそうです。既存ライブラリを使うのであればautochordで解決するかもしれません。
ソースコード
ソースコードはこのリポジトリにあります。
README にはインストール手順と実行例をまとめています。
まとめ
Demucs、Whisper、librosa を素直につなぐだけでも、曲に対してかなりリッチな構造化情報を作れることがわかりました。
特に「歌詞」と「コード」と「キー進行」を時間情報でまとめて扱う設計にしておくと、あとから可視化したり、別の音楽アプリの入力として活用したりしやすくなります。
そして、課題・改善余地として以下の項目を挙げたいと思います。
- オンコードやテンション込みのコード辞書を増やす
- 4/4 固定ではない小節推定に対応する
- 歌詞行ではなく単語レベルでコード対応を細かく出す
- 転調検出のしきい値やセグメント統合条件をもう少し音楽的に調整する
最後に
株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせフォームからご連絡ください。
また、一緒に働く仲間も募集しています。
詳細は採用情報ページをご覧ください。