extractor.py
3.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
"""Chromagram 特征提取。
流程:
1. 音频标准化:ffmpeg 转 22050Hz / Mono / WAV
2. librosa 加载音频
3. librosa.feature.chroma_cens() 提取 12×T Chromagram(CENS,对速度/音色鲁棒)
4. 主音对齐:将能量最大的音级滚至第 0 行,实现转调不变性
5. scipy.signal.resample(chroma, 128, axis=1) 时间归一化到 12×128
6. .flatten() 展开为 1536 维向量
"""
import logging
import os
import subprocess
import tempfile
import librosa
import numpy as np
from scipy.signal import resample
logger = logging.getLogger(__name__)
# 目标采样率和时间帧数
TARGET_SR = 22050
TARGET_FRAMES = 128
VECTOR_DIM = 12 * TARGET_FRAMES # 1536
def _normalize_audio_ffmpeg(audio_path: str, output_path: str) -> None:
"""使用 ffmpeg 将音频标准化为 22050Hz / Mono / WAV。"""
cmd = [
"ffmpeg",
"-y",
"-i", audio_path,
"-ar", str(TARGET_SR),
"-ac", "1",
"-f", "wav",
output_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg 转换失败: {result.stderr}")
def extract_chroma_feature(audio_path: str) -> np.ndarray:
"""从音频文件提取 1536 维 Chromagram 特征向量。
Args:
audio_path: 音频文件路径。
Returns:
shape 为 (1536,) 的 numpy 数组。
Raises:
FileNotFoundError: 音频文件不存在。
RuntimeError: ffmpeg 转换失败。
"""
if not os.path.isfile(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
# 1. 音频标准化:ffmpeg 转 WAV
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_wav = tmp.name
try:
_normalize_audio_ffmpeg(audio_path, tmp_wav)
# 2. librosa 加载音频
y, _sr = librosa.load(tmp_wav, sr=TARGET_SR, mono=True)
# 3. 提取 CENS Chromagram (12×T),对速度变化和音色具有更强鲁棒性
chroma = librosa.feature.chroma_cens(y=y, sr=TARGET_SR)
# 4. 主音对齐:将全局能量最大的音级循环滚至第 0 行,实现转调不变性
tonic = int(np.argmax(chroma.sum(axis=1)))
if tonic != 0:
chroma = np.roll(chroma, -tonic, axis=0)
# 5. 时间归一化到 12×128
if chroma.shape[1] != TARGET_FRAMES:
chroma = resample(chroma, TARGET_FRAMES, axis=1)
# 6. 展开为 1536 维向量
feature = chroma.flatten().astype(np.float32)
assert feature.shape == (VECTOR_DIM,), (
f"特征维度错误: 期望 {VECTOR_DIM}, 实际 {feature.shape}"
)
return feature
finally:
# 清理临时文件
if os.path.exists(tmp_wav):
os.remove(tmp_wav)
def extract_chroma_matrix(audio_path: str) -> np.ndarray:
"""从音频文件提取 12×128 Chromagram 矩阵(未展平,供 DTW 精排使用)。
Returns:
shape 为 (12, 128) 的 numpy 数组,已做主音对齐。
"""
feature = extract_chroma_feature(audio_path)
return feature.reshape(12, TARGET_FRAMES)