跳到主要內容

【2026 最新】音樂 Embedding 怎麼做?用 MERT 建立本機相似歌曲搜尋,六步上手與盲聽驗收

最後更新: ·
音樂 Embedding 教學:以 MERT 將音訊轉成向量,再找相似歌曲

你有一首很喜歡的歌,想從自己的硬碟找出「聽起來有點像」的下一首,卻不知道該輸入曲風、樂器還是歌手。音樂 Embedding 可以讓錄音本身成為查詢:把聲音轉成一串數字,再找出數字方向接近的曲目。

這篇寫給第一次接觸音樂向量、願意照著貼指令的新手。你會先懂原理,再用 20 首自有或取得使用授權的 WAV/FLAC,建立本機音訊查音訊搜尋,輸出能試聽的前五名。實作需要已安裝 Python 3.11 的 macOS 或 Linux;先從 30 秒片段開始。

截至 2026 年 10 月 11 日,以下以 MERT-v2-FullSong 官方模型卡 與固定程式版本設計流程。本次環境沒有模型推論套件與授權曲庫,因此這是供你執行的操作及驗收方法;不填入 AlphaLab 的歌曲排名、準確率或推論秒數。

先說結論:音樂 Embedding 搜尋要過三關

音樂搜尋=固定聲音切法+同一把向量尺+你的耳朵驗收。 第一關,把每首歌用相同規則處理;第二關,曲庫與查詢使用相同模型、層及池化;第三關,聽結果究竟像在哪裡。數字負責排序,你負責定義「像」。

  • 先做 20 首的小曲庫,逐首處理,不急著架向量資料庫。
  • 先比每首前 30 秒,再建立整首版本;兩套索引分開保存。
  • 先寫下你要找的是樂器、人聲、節奏還是旋律,再盲聽排名。

音樂 Embedding 是什麼?把聲音變成一張座標卡

Embedding(嵌入向量)可以想成每首錄音的座標卡。它不是把歌曲壓成能還原的音檔,而是讓模型把聲音中的模式放進數字表示。就像替餐廳做地圖:座標靠近,只表示這張地圖採用的尺度接近,不代表每個人都覺得兩家餐廳一樣好吃。

本篇用 cosine similarity(餘弦相似度) 比較向量方向。把每張卡先縮成長度 1,再做內積,就得到這把尺的分數;它不是「有幾成像」的機率。分數為 0.8,也不等於聽感相似度 80%。

例如你拿鋼琴與人聲的錄音當查詢,前五名可能都有人聲,卻有不同旋律。這時系統不是答對或答錯一個抽象問題,而是暴露了你還沒說清楚的需求:你想找同種配器,還是相似旋律?把這件事先寫下,才知道後面要改善哪個環節。

MERT 在這裡做什麼?先分清編碼器與搜尋器

官方提供的 FullSong 是音樂編碼器:輸入單聲道聲音,輸出隨時間排列的特徵。模型卡列出的輸入規格是 24 kHz,最後層每個時間位置有 1,024 個數字;本文把有效時間位置平均,變成一首歌的一張卡。搜尋排序與試聽頁由下面的小程式另外完成。

24 kHz 是每秒 24,000 個音訊取樣點,不是輸出的向量數量。重採樣像把不同紙張都換成同一種格線;torchaudio 的 resample 會真正轉換波形,單改檔案上的取樣率標籤會把聲音速度弄錯。

池化(pooling)是把一排特徵收成一張卡。本篇沿用官方範例的 feature_attention_mask,只平均有效位置,再做 L2 正規化。像算班級平均分數,要排除不存在的空位。這是可比較的起點;哪一層、哪種池化更合你的曲庫,必須用同一批查詢再比較。

音樂 Embedding 搜尋流程:固定音訊、向量索引、相似度排序與盲聽
編碼只產生表示;排名與聽感驗收是另外兩個步驟。

模型卡的 MARBLE 表格是在指定任務、探測器與設定下測音樂理解;它也說明外部基準的評估協議可能不同。因此本文不把分類、調性或節拍成績搬成你個人曲庫的檢索命中率。2026 年 4 月的 音訊表示與音樂推薦研究 也指出,傳統音樂資訊檢索任務與推薦任務的表現會有差距;該研究不是這個 FullSong checkpoint 的測試。

音樂 Embedding 本機搜尋:六步建立小曲庫

①「先挑歌」:固定權利、樣本與問題

怕結果被曲庫組成牽著走?先做曲目清單。放入 20 首自己錄製或明確允許此用途的音檔,刻意包含有人聲/純器樂、不同主奏樂器及節奏。若二十首都是同位歌手,這次練習就主要回答那位歌手作品內的排序,別推論到所有音樂。

把每首的使用權利、檔名與你認為的特徵寫在私人的 tracks.csv。買到或可播放音樂,與獲准提取、分享及商業使用,是不同的權利範圍;這個練習採用你已確認可處理的曲目,也不從串流服務抓歌。

權重另有一層條件:官方標示 CC BY-NC 4.0,包含姓名標示與非商業用途限制。本文的操作範圍是個人、非商業的小曲庫;若要把模型接進收費產品,先處理適用授權,不能把「權重可下載」當成商用許可。

②「先鎖尺」:隔離環境與模型版本

怕今天與明天結果不同?把版本寫進索引。在乾淨工作資料夾建立虛擬環境。下面採官方示範的 PyTorch/torchaudio 配對及 Transformers 版本;Python 3.11 必須已存在。若 pip 回報找不到相容 wheel,先檢查 Python、作業系統及 CPU 架構,不要只單獨升級其中一個套件。

mkdir music-search
cd music-search
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install torch==2.6.0 torchaudio==2.6.0 transformers==4.53.2 huggingface-hub safetensors soundfile numpy
mkdir audio
python -m pip freeze > requirements.lock.txt

將音檔放入 audio/。官方載入範例使用 trust_remote_code=True,會執行模型 repository 的 Python;先閱讀 本文固定 revision 的程式檔,在沒有密碼、SSH 金鑰與敏感文件的測試環境執行。Hugging Face 載入文件 建議審查自訂程式並以 commit 鎖定版本;權重採 Safetensors,也仍須審查自訂 Python。

③「切一樣」:先用前 30 秒,逐首編碼

怕混到不同處理方式?第一次只建立 clip 索引。下面程式將多聲道平均成單聲道,實際重採樣,再從最後層取有效位置的平均。每首至少 30 秒;整首模式另限制在官方描述的 30–360 秒範圍內。遇到不合規音檔直接停下,避免你不知道某首被偷偷截短。

把下面完整程式存成 music_index.py。它是依官方介面組合的教學實作;此處沒有聲稱已完成模型端到端推論。它含基本檔案與數值檢查,尚未加入大量曲庫的分批續跑、失敗隔離及新曲增量更新。初次載入會下載模型權重,後續使用本機快取;音訊由本機檔案讀取。

import argparse, hashlib, html, importlib.metadata, json, time
from pathlib import Path
from urllib.parse import quote
import numpy as np
import soundfile as sf
import torch
import torchaudio.functional as AF
from transformers import AutoFeatureExtractor, AutoModel

REPO = 'm-a-p/MERT-v2-FullSong'
REV = '7001f7657276238629e61c7746ce70735e0604c0'
p = argparse.ArgumentParser()
p.add_argument('mode', choices=['clip', 'full'])
p.add_argument('query', type=int, help='歌曲編號,從 0 開始')
a = p.parse_args()
files = sorted(x for x in Path('audio').iterdir()
               if x.is_file() and x.suffix.lower() in ['.wav', '.flac'])
if len(files) < 2 or not 0 <= a.query < len(files):
    raise ValueError('至少放兩首 WAV/FLAC,query 須在歌曲編號範圍內')
device = 'cuda' if torch.cuda.is_available() else 'cpu'
proc = AutoFeatureExtractor.from_pretrained(REPO, revision=REV)
model = AutoModel.from_pretrained(REPO, revision=REV,
    trust_remote_code=True, use_safetensors=True,
    attn_implementation='sdpa').eval().to(device)
rows, vectors = [], []
if device == 'cuda':
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize()
t0 = time.perf_counter()
for i, path in enumerate(files):
    info = sf.info(path)
    duration = info.frames / info.samplerate
    if duration < 30 or (a.mode == 'full' and duration > 360):
        raise ValueError(f'{path.name}: 本練習 clip 至少 30 秒;full 為 30–360 秒')
    frames = int(30 * info.samplerate) if a.mode == 'clip' else -1
    audio, sr = sf.read(path, frames=frames, dtype='float32', always_2d=True)
    mono = torch.from_numpy(audio.mean(axis=1))
    wave = AF.resample(mono, sr, proc.sampling_rate)
    if not torch.isfinite(wave).all() or wave.abs().max().item() == 0:
        raise ValueError(f'{path.name}: 空白或非有限音訊')
    inputs = proc(wave.numpy(), sampling_rate=proc.sampling_rate,
                  return_tensors='pt').to(device)
    with torch.inference_mode():
        out = model(**inputs, output_hidden_states=False)
        mask = out.feature_attention_mask[..., None]
        pooled = (out.last_hidden_state * mask).sum(1) / mask.sum(1).clamp_min(1)
        v = pooled[0].float().cpu().numpy()
    norm = np.linalg.norm(v)
    if not np.isfinite(v).all() or norm == 0:
        raise ValueError(f'{path.name}: 無效向量')
    vectors.append(v / norm)
    rows.append({'id': i, 'file': path.name, 'seconds': duration,
                 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()})
    print(i, path.name, flush=True)
if device == 'cuda':
    torch.cuda.synchronize()
X = np.stack(vectors).astype('float32')
metrics = {'index_seconds': time.perf_counter() - t0,
           'vector_bytes': X.nbytes, 'device': device,
           'cuda_peak_allocated_bytes': torch.cuda.max_memory_allocated()
               if device == 'cuda' else None}
versions = {x: importlib.metadata.version(x) for x in
            ['torch', 'torchaudio', 'transformers', 'numpy', 'soundfile']}
Path('results').mkdir(exist_ok=True)
np.save(f'results/{a.mode}.npy', X)
Path(f'results/{a.mode}.json').write_text(json.dumps(
    {'repo': REPO, 'revision': REV, 'mode': a.mode, 'pool': 'last-layer-masked-mean-L2',
     'versions': versions, 'tracks': rows, 'metrics': metrics}, ensure_ascii=False, indent=2))
scores = X @ X[a.query]
order = [int(i) for i in np.argsort(-scores, kind='stable') if i != a.query][:5]
items = []
for i in order:
    src = '../audio/' + quote(files[i].name, safe='')
    items.append(f'<li>{html.escape(files[i].name)} · cosine {scores[i]:.4f}'
                 f'<br><audio controls preload="none" src="{src}"></audio></li>')
page = '<!doctype html><meta charset="utf-8"><title>本機相似歌曲</title>'
page += f'<h1>查詢:{html.escape(files[a.query].name)}</h1><ol>{"".join(items)}</ol>'
Path(f'results/{a.mode}.html').write_text(page, encoding='utf-8')
print(json.dumps(metrics, indent=2))
print(f'開啟 http://127.0.0.1:8000/results/{a.mode}.html')

④「先看收據」:建立排名與本機試聽頁

怕它只印出一串數字?輸出能逐首點開的結果。執行 python music_index.py clip 0,把排序後編號 0 的歌曲當查詢;終端機會列出每個編號與檔名。曲庫至少兩首才可查詢,最多回傳其他五首,排除查詢自身。這裡示範的是用曲庫內一首找鄰居。

python music_index.py clip 0
python -m http.server 8000 --bind 127.0.0.1

在瀏覽器開 http://127.0.0.1:8000/results/clip.html。先核對查詢檔名,再播放結果;音訊控制項播放原檔,盲聽時請自行選相同長度及對應段落。服務綁在本機回環位址;只在這個練習資料夾啟動,結束按 Ctrl+C。

保存 clip.npy 與 clip.json:前者是向量矩陣,後者含音檔雜湊、模型 revision、套件版本及處理規則。計時從模型載入後開始,包含讀檔、編碼與雜湊;它不含首次下載和載入時間。vector_bytes 只量向量矩陣,CUDA 峰值只在使用 CUDA 時記錄,不能當成整個程式的 RAM。

⑤「換一個問題」:整首表示與片段平均分開比較

怕前奏決定一切?另建 full 索引。停止剛才的 HTTP 服務,執行 python music_index.py full 0,再啟動相同服務,開 /results/full.html。整首仍取最後層時間平均,但編碼器看到的聲音上下文更長;先確認 RAM/VRAM 可承受,逐首處理,記憶體不足就保留片段路線。本文不承諾某種筆電能處理六分鐘音訊。

「先把全曲送模型,再平均」與「切成多段、各段編碼,再平均段向量」是兩種程序,結果應分開驗。後者改變每段可見上下文,而且等權平均會讓短尾段與長段有同樣權重。下一輪可固定三個 30 秒位置,先以有效長度加權平均未正規化段向量,最後再 L2 正規化;將位置和權重寫进新 manifest,建立第三套索引。

⑥「讓耳朵評分」:五個查詢的盲聽驗收

怕看見歌名就先入為主?把結果順序藏起來。先選五首不同類型的查詢,為每首寫一個目標,例如「同樣明顯的鋼琴主奏」。請另一人把 clip 與 full 的候選混在一起,以 A、B、C 編號;獨自操作也可先隨機編號、評完再揭曉來源。

  • 固定每個候選播放相同秒數與段落,保持試聽音量接近;這是聽感控制,不是改寫已建立的模型索引。
  • 分別標記樂器、人聲、節奏、旋律的相似程度,最後回答「符合這次搜尋目標嗎?」;同一首可在不同面向得到不同答案。
  • 記錄各查詢前五名中符合目標的首數,例如填入 符合首數 / 實際回傳首數;這只是你的局部驗收比例,不是公開 benchmark。
  • 同一歌曲版本、同一張專輯與同一歌手的結果另加註;避免近乎重複的錄音讓搜尋看起來過度容易。

加一個便宜基準:檢查是否只找到了音色或音量

AI 排名看起來合理,是否真的比簡單規則多提供資訊?保留同一批查詢和試聽段落,另外用 librosa 官方聲學特徵 建立對照:每段取 RMS(聲音能量)、spectral centroid(頻譜重心,可粗略描述亮度)與 zero-crossing rate(過零率)。這是下一輪的基準設計,不是本文已跑出的結果。

將各特徵在同一曲庫做標準化,再用歐氏距離排序;保留均值、標準差與特徵版本,避免一個數值範圍特別大的欄位主宰距離。使用與 MERT 相同的五個查詢、候選數及盲聽判準,先比較每個查詢,再看平均。若兩邊總是找回同樣的錄音條件,模型的額外計算是否值得,就有了可討論的證據。

基準弱也不表示模型理解了旋律。若你的核心需求是拆開旋律、節奏與音色,MERIT 官方實作 提供三個分開的相似度頭;其公開範例基底是 MERT-v1-330M。它是另一路線,不能直接把其投影頭接到本篇 v2 的最後層向量,便宣稱得到了同樣的三種分數。

四個常見坑:每次只改一個旋鈕

  • 前奏偏差:clip 排到一堆安靜歌曲時,先檢查大家的前 30 秒。再用固定中段建立新索引,讓切法成為可比較的變因。
  • 向量混尺:改模型 revision、層、池化、片段位置或正規化時,重建整套索引;保存舊版才能做前後對照。
  • 記憶體誤讀:20×1,024×4=81,920 bytes 只是 float32 向量矩陣的計算,不包含音訊、模型及中間張量。建索引時同時觀察作業系統的程序記憶體峰值。
  • 排名誤讀:不要把前五名當成歌曲身分辨識或著作權判定。本文完成的是候選排序;若要回答旋律一致性,需另定義測試資料與決策規則。

常見問題:音樂 Embedding 新手八問

可以打「慵懶的爵士」找歌嗎?

本篇不提供這條查詢路線。查詢與曲庫都來自音訊;要加文字搜尋,另選有文件及測試支持文字/音訊對齊的模型,再驗對齊品質。

能拿一小段錄音找相似歌嗎?

可以設計這種系統,但本程式的起點是至少 30 秒、曲庫內查詢。短片段需要另外固定長度、處理方式與驗收資料,別套用這裡的品質假設。

整首一定比片段好嗎?

不一定。前奏、段落轉換與你想找的特徵會改變結果;把 clip 與 full 當成兩個候選設定,讓同一套盲聽判準選擇。

一定要有 NVIDIA GPU 嗎?

官方範例有 CPU 與 CUDA 分支,本程式沿用它。CPU 可作為起點,速度與整首記憶體需求留給你的機器量測;這裡不承諾完成時間。

可以用 MP3 嗎?

本練習限制 WAV/FLAC,減少解碼格式差異。若要加入 MP3,先用自己的解碼工具確認波形、長度與取樣率,再統一輸入規則。

cosine 0.9 就表示很好嗎?

不一定。分數是這把向量尺的方向接近程度;先看被找回的錄音是否符合預先寫下的目標,再決定要不要設門檻。

二十首需要向量資料庫嗎?

本篇用 NumPy 矩陣完整比較每一首,就能看清計算。曲庫變大、查詢變多或要增量更新時,再評估索引服務;先保留每個向量的來源收據。

可以把這套搜尋做成商業服務嗎?

先處理權重與音樂的授權。FullSong 的 CC BY-NC 4.0 含非商業條件,音檔也有自己的使用範圍;本篇採個人非商業練習。

給新手的三個重點

  • 先寫清楚「像在哪裡」,再選模型;一個總分會隱藏不同音樂面向。
  • 固定版本與聲音切法,留存向量、manifest、排名及聽感工作表,才能知道改動是否有用。
  • 先讓小曲庫能搜尋、能播放、能驗收,再增加曲數、分段策略或資料庫。

若你還在熟悉索引概念,可先讀 EmbeddingGemma 索引教學,理解為什麼資料與向量版本要一起管理;WEMM 多模態搜尋 則補足不同模態如何接到查詢流程。這兩篇提供工程觀念,並不是本篇音訊模型的替代品。

接著閱讀

左右滑動查看更多推薦

下一步:今天先留下第一張搜尋收據

音樂搜尋=固定聲音切法+同一把向量尺+你的耳朵驗收。今天先放入有權處理的曲目、建立 clip 索引,挑一首查詢,寫下前五名各像在哪裡;等你能解釋一次排名,再比較 full 與便宜基準。

想繼續把小實驗做成可維護的 AI 應用,可從 AI 教學文章 選下一個系統主題,或到 AlphaLab 課程 練習把模型、資料與驗收串成完整工作流。

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多三封:一封 Weekly 週報與最多兩封關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。已訂閱?管理主題偏好(會寄登入連結到你的信箱)