Commit 6b9f5ef7 6b9f5ef705542ce4f52bed71c9b3b9b380dc0792 by 沈秋雨

update

1 parent 974df4ae
Showing 45 changed files with 2683 additions and 34 deletions
...@@ -29,3 +29,4 @@ test_api ...@@ -29,3 +29,4 @@ test_api
29 composition_dedup/composition_eval 29 composition_dedup/composition_eval
30 30
31 composition_testset 31 composition_testset
32 acrcloud_testset_cloud
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -21,11 +21,7 @@ from pathlib import Path ...@@ -21,11 +21,7 @@ from pathlib import Path
21 21
22 import librosa 22 import librosa
23 import numpy as np 23 import numpy as np
24 from scipy.ndimage import ( 24 from scipy.ndimage import generate_binary_structure, iterate_structure, maximum_filter
25 generate_binary_structure,
26 iterate_structure,
27 maximum_filter,
28 )
29 from scipy.signal import spectrogram 25 from scipy.signal import spectrogram
30 26
31 logger = logging.getLogger(__name__) 27 logger = logging.getLogger(__name__)
...@@ -54,7 +50,7 @@ DEFAULT_WINDOW_SIZE = 4096 ...@@ -54,7 +50,7 @@ DEFAULT_WINDOW_SIZE = 4096
54 DEFAULT_OVERLAP_RATIO = float(os.environ.get("COMPOSITION_DEJAVU_OVERLAP_RATIO", "0.3")) 50 DEFAULT_OVERLAP_RATIO = float(os.environ.get("COMPOSITION_DEJAVU_OVERLAP_RATIO", "0.3"))
55 DEFAULT_FAN_VALUE = int(os.environ.get("COMPOSITION_DEJAVU_FAN_VALUE", "10")) 51 DEFAULT_FAN_VALUE = int(os.environ.get("COMPOSITION_DEJAVU_FAN_VALUE", "10"))
56 DEFAULT_AMP_MIN = float(os.environ.get("COMPOSITION_DEJAVU_AMP_MIN", "20")) 52 DEFAULT_AMP_MIN = float(os.environ.get("COMPOSITION_DEJAVU_AMP_MIN", "20"))
57 PEAK_NEIGHBORHOOD_SIZE = 20 53 PEAK_NEIGHBORHOOD_SIZE = int(os.environ.get("COMPOSITION_DEJAVU_PEAK_NEIGHBORHOOD_SIZE", "20"))
58 MIN_HASH_TIME_DELTA = 0 54 MIN_HASH_TIME_DELTA = 0
59 MAX_HASH_TIME_DELTA = 200 55 MAX_HASH_TIME_DELTA = 200
60 PEAK_SORT = True 56 PEAK_SORT = True
...@@ -128,10 +124,9 @@ def _get_2d_peaks(arr2D: np.ndarray, amp_min: float = DEFAULT_AMP_MIN): ...@@ -128,10 +124,9 @@ def _get_2d_peaks(arr2D: np.ndarray, amp_min: float = DEFAULT_AMP_MIN):
128 Returns: 124 Returns:
129 (frequency_idx, time_idx): 峰值的频率和时间索引列表 125 (frequency_idx, time_idx): 峰值的频率和时间索引列表
130 """ 126 """
127 # 找局部极大值(菱形邻域,L1 球,4连通膨胀 PEAK_NEIGHBORHOOD_SIZE 次)
131 struct = generate_binary_structure(2, 1) 128 struct = generate_binary_structure(2, 1)
132 neighborhood = iterate_structure(struct, PEAK_NEIGHBORHOOD_SIZE) 129 neighborhood = iterate_structure(struct, PEAK_NEIGHBORHOOD_SIZE)
133
134 # 找局部极大值
135 detected_peaks = maximum_filter(arr2D, footprint=neighborhood) == arr2D 130 detected_peaks = maximum_filter(arr2D, footprint=neighborhood) == arr2D
136 131
137 # 提取峰值 132 # 提取峰值
......
...@@ -6,6 +6,9 @@ ...@@ -6,6 +6,9 @@
6 3. 主音对齐:将能量最大的音级滚至第 0 行,实现转调不变性 6 3. 主音对齐:将能量最大的音级滚至第 0 行,实现转调不变性
7 4. scipy.signal.resample(chroma, 128, axis=1) 时间归一化到 12×128 7 4. scipy.signal.resample(chroma, 128, axis=1) 时间归一化到 12×128
8 5. .flatten() 展开为 1536 维向量 8 5. .flatten() 展开为 1536 维向量
9
10 子序列 DTW 路径(extract_chroma_fixed_fps_from_samples)跳过第 4、5 步,
11 改用线性插值到固定帧率,保留音频时长信息供子序列匹配使用。
9 """ 12 """
10 13
11 import logging 14 import logging
...@@ -120,3 +123,72 @@ def extract_chroma_matrix(audio_path: str, hop_length: int = 512, win_len_smooth ...@@ -120,3 +123,72 @@ def extract_chroma_matrix(audio_path: str, hop_length: int = 512, win_len_smooth
120 shape 为 (12, 128) 的 numpy 数组,已做主音对齐。 123 shape 为 (12, 128) 的 numpy 数组,已做主音对齐。
121 """ 124 """
122 return extract_chroma_feature(audio_path, hop_length=hop_length, win_len_smooth=win_len_smooth).reshape(12, TARGET_FRAMES) 125 return extract_chroma_feature(audio_path, hop_length=hop_length, win_len_smooth=win_len_smooth).reshape(12, TARGET_FRAMES)
126
127
128 # 子序列 DTW 路径的默认目标帧率(帧/秒)。
129 # fps=2 → 每 0.5s 一帧;3 分钟歌曲 = 360 帧;副歌片段(40%)= 144 帧。
130 FIXED_FPS = 2
131
132
133 def load_audio_mono_22050hz(audio_path: str) -> np.ndarray:
134 """从音频文件加载 22050Hz 单声道 float32 样本(对外公开接口)。
135
136 直接调用内部的 ffmpeg pipe 解码,无临时文件。
137
138 Raises:
139 FileNotFoundError: 音频文件不存在。
140 RuntimeError: ffmpeg 解码失败。
141 """
142 if not os.path.isfile(audio_path):
143 raise FileNotFoundError(f"音频文件不存在: {audio_path}")
144 return _load_audio_via_pipe(audio_path)
145
146
147 def extract_chroma_fixed_fps_from_samples(
148 samples: np.ndarray,
149 sr: int,
150 target_fps: int = FIXED_FPS,
151 hop_length: int = 512,
152 win_len_smooth: int = 41,
153 ) -> np.ndarray:
154 """提取固定帧率 Chromagram 矩阵,帧数由音频时长决定(供子序列 DTW 使用)。
155
156 与 extract_chroma_feature_from_samples 的区别:
157 - 不做固定 128 帧归一化,保留音频绝对时长信息
158 - 时间轴插值改用线性插值,避免 FFT 重采样(scipy.signal.resample)的 Gibbs 振铃
159 - 返回 12×T 矩阵,T = round(duration_sec * target_fps)
160 - 查询片段(如 chorus_only)与全曲均保持同等时间密度(帧/秒),
161 子序列 DTW 可在全曲序列中找到最佳对齐窗口
162
163 Args:
164 samples: 单声道音频样本(任意采样率)。
165 sr: samples 对应的采样率。
166 target_fps: 目标帧率(帧/秒),决定最终时间分辨率。
167 hop_length: CQT hop 大小。
168 win_len_smooth: CENS 平滑窗口帧数。
169
170 Returns:
171 shape 为 (12, T) 的 numpy float32 数组,未做主音对齐(由调用方的 12 路位移处理)。
172 """
173 y = samples if sr == TARGET_SR else librosa.resample(samples, orig_sr=sr, target_sr=TARGET_SR)
174 chroma = librosa.feature.chroma_cens(
175 y=y, sr=TARGET_SR, hop_length=hop_length, win_len_smooth=win_len_smooth
176 )
177
178 # 不做主音对齐:固定帧率路径依赖 _best_shifted_subsequence_dtw 的 12 路遍历
179 # 实现转调不变性,argmax 能量估计不稳定,去掉后向量空间更干净
180
181 # 目标帧数由音频时长和目标帧率共同决定
182 duration_sec = len(y) / TARGET_SR
183 target_t = max(1, round(duration_sec * target_fps))
184
185 # 线性插值到目标帧数
186 if chroma.shape[1] != target_t:
187 x_old = np.linspace(0, 1, chroma.shape[1])
188 x_new = np.linspace(0, 1, target_t)
189 chroma = np.array(
190 [np.interp(x_new, x_old, chroma[p]) for p in range(12)],
191 dtype=np.float32,
192 )
193
194 return chroma.astype(np.float32)
......
...@@ -19,8 +19,11 @@ from scipy.spatial.distance import cdist ...@@ -19,8 +19,11 @@ from scipy.spatial.distance import cdist
19 19
20 from .extractor import ( 20 from .extractor import (
21 TARGET_FRAMES, 21 TARGET_FRAMES,
22 TARGET_SR,
22 extract_chroma_feature_from_samples, 23 extract_chroma_feature_from_samples,
23 extract_chroma_matrix, 24 extract_chroma_matrix,
25 extract_chroma_fixed_fps_from_samples,
26 load_audio_mono_22050hz,
24 ) 27 )
25 from .dejavu_fingerprinter import fingerprint_audio, fingerprint_from_samples, load_audio, QUERY_MAX_DURATION_SEC 28 from .dejavu_fingerprinter import fingerprint_audio, fingerprint_from_samples, load_audio, QUERY_MAX_DURATION_SEC
26 29
...@@ -86,6 +89,14 @@ class CompositionConfig: ...@@ -86,6 +89,14 @@ class CompositionConfig:
86 # Dejavu 指纹匹配配置 89 # Dejavu 指纹匹配配置
87 dejavu_enabled: bool = _env_bool("COMPOSITION_DEJAVU_ENABLED", True) 90 dejavu_enabled: bool = _env_bool("COMPOSITION_DEJAVU_ENABLED", True)
88 dejavu_match_threshold: int = _env_int("COMPOSITION_DEJAVU_MATCH_THRESHOLD", 20) 91 dejavu_match_threshold: int = _env_int("COMPOSITION_DEJAVU_MATCH_THRESHOLD", 20)
92 # 子序列 DTW 配置(对片段场景更鲁棒,可替代固定 128 帧 DTW)
93 subsequence_dtw_enabled: bool = _env_bool("COMPOSITION_SUBSEQUENCE_DTW_ENABLED", True)
94 chroma_full_fps: int = _env_int("COMPOSITION_CHROMA_FULL_FPS", 2)
95 # 子序列 DTW 仅在 query 时长明显短于 DB 时启用(片段查询场景)。
96 # query_frames < db_frames × 此比例 时走子序列 DTW,否则回退到 128 帧 DTW。
97 # 0.85 可覆盖 chorus_only(40%) / trim_intro(80%) 等片段,
98 # 同时排除 tempo_slow(111%) / pitch_up1(94%) 等接近全曲长度的变体。
99 subsequence_dtw_length_ratio: float = _env_float("COMPOSITION_SUBSEQUENCE_DTW_LENGTH_RATIO", 0.85)
89 100
90 def __post_init__(self) -> None: 101 def __post_init__(self) -> None:
91 # 0 表示自动:按 hop_length 等比缩小,保持平滑窗覆盖时长约 1 秒 102 # 0 表示自动:按 hop_length 等比缩小,保持平滑窗覆盖时长约 1 秒
...@@ -116,8 +127,32 @@ class CompositionDedupService: ...@@ -116,8 +127,32 @@ class CompositionDedupService:
116 feature = extract_chroma_feature_from_samples(samples, sr, hop_length=self.config.chroma_hop_length, win_len_smooth=self.config.chroma_win_len_smooth) 127 feature = extract_chroma_feature_from_samples(samples, sr, hop_length=self.config.chroma_hop_length, win_len_smooth=self.config.chroma_win_len_smooth)
117 self._logger.info("提取 Chromagram 特征完成: song_id=%s", song_id) 128 self._logger.info("提取 Chromagram 特征完成: song_id=%s", song_id)
118 129
130 chroma_full: np.ndarray | None = None
131 if self.config.subsequence_dtw_enabled:
132 chroma_full = extract_chroma_fixed_fps_from_samples(
133 samples, sr,
134 target_fps=self.config.chroma_full_fps,
135 hop_length=self.config.chroma_hop_length,
136 win_len_smooth=self.config.chroma_win_len_smooth,
137 )
138 self._logger.info(
139 "提取固定帧率 Chromagram 完成: song_id=%s, n_frames=%d", song_id, chroma_full.shape[1]
140 )
141
119 with psycopg.connect(self.config.dsn) as conn: 142 with psycopg.connect(self.config.dsn) as conn:
120 with conn.cursor() as cursor: 143 with conn.cursor() as cursor:
144 if chroma_full is not None:
145 cursor.execute(
146 """
147 INSERT INTO composition_feature (song_id, feature_vector, chroma_full, chroma_n_frames)
148 VALUES (%s, %s, %s, %s)
149 ON CONFLICT (song_id) DO UPDATE
150 SET chroma_full = EXCLUDED.chroma_full,
151 chroma_n_frames = EXCLUDED.chroma_n_frames
152 """,
153 (song_id, feature.tolist(), chroma_full.flatten().tolist(), int(chroma_full.shape[1])),
154 )
155 else:
121 cursor.execute( 156 cursor.execute(
122 """ 157 """
123 INSERT INTO composition_feature (song_id, feature_vector) 158 INSERT INTO composition_feature (song_id, feature_vector)
...@@ -248,7 +283,20 @@ class CompositionDedupService: ...@@ -248,7 +283,20 @@ class CompositionDedupService:
248 ) -> list[CompositionCandidate]: 283 ) -> list[CompositionCandidate]:
249 """Chromagram 12 路循环对齐 + DTW 精排查询。""" 284 """Chromagram 12 路循环对齐 + DTW 精排查询。"""
250 _t = time.perf_counter() 285 _t = time.perf_counter()
251 chroma = extract_chroma_matrix(audio_path, hop_length=self.config.chroma_hop_length, win_len_smooth=self.config.chroma_win_len_smooth) 286 # 统一加载一次 22050Hz 样本,供 ANN 向量和子序列 DTW 矩阵共用,避免重复解码
287 samples = load_audio_mono_22050hz(audio_path)
288 chroma = extract_chroma_feature_from_samples(
289 samples, TARGET_SR,
290 hop_length=self.config.chroma_hop_length,
291 win_len_smooth=self.config.chroma_win_len_smooth,
292 ).reshape(12, TARGET_FRAMES)
293 if self.config.subsequence_dtw_enabled:
294 query_chroma_full = extract_chroma_fixed_fps_from_samples(
295 samples, TARGET_SR,
296 target_fps=self.config.chroma_full_fps,
297 hop_length=self.config.chroma_hop_length,
298 win_len_smooth=self.config.chroma_win_len_smooth,
299 )
252 if timings is not None: 300 if timings is not None:
253 timings["chroma_extract_ms"] = round((time.perf_counter() - _t) * 1000, 1) 301 timings["chroma_extract_ms"] = round((time.perf_counter() - _t) * 1000, 1)
254 self._logger.info("提取 Chromagram 查询特征完成: audio=%s", audio_path) 302 self._logger.info("提取 Chromagram 查询特征完成: audio=%s", audio_path)
...@@ -301,9 +349,26 @@ class CompositionDedupService: ...@@ -301,9 +349,26 @@ class CompositionDedupService:
301 349
302 _t = time.perf_counter() 350 _t = time.perf_counter()
303 with conn.cursor() as cursor: 351 with conn.cursor() as cursor:
352 if self.config.subsequence_dtw_enabled:
304 cursor.execute( 353 cursor.execute(
305 "SELECT song_id, feature_vector::float4[] FROM composition_feature WHERE song_id = ANY(%s)", 354 """
306 (rerank_ids,), 355 SELECT song_id,
356 COALESCE(chroma_full, feature_vector::float4[]) AS chroma,
357 COALESCE(chroma_n_frames, %s) AS n_frames,
358 feature_vector::float4[] AS fv_128,
359 (chroma_full IS NOT NULL) AS has_full
360 FROM composition_feature WHERE song_id = ANY(%s)
361 """,
362 (TARGET_FRAMES, rerank_ids),
363 )
364 else:
365 cursor.execute(
366 """
367 SELECT song_id, feature_vector::float4[], %s,
368 feature_vector::float4[], false::boolean
369 FROM composition_feature WHERE song_id = ANY(%s)
370 """,
371 (TARGET_FRAMES, rerank_ids),
307 ) 372 )
308 db_rows = cursor.fetchall() 373 db_rows = cursor.fetchall()
309 if timings is not None: 374 if timings is not None:
...@@ -311,10 +376,28 @@ class CompositionDedupService: ...@@ -311,10 +376,28 @@ class CompositionDedupService:
311 376
312 _t = time.perf_counter() 377 _t = time.perf_counter()
313 reranked = [] 378 reranked = []
314 for song_id, fv in db_rows: 379 for song_id, fv, n_frames, fv_128, has_full in db_rows:
315 cand_chroma = np.array(fv, dtype=np.float32).reshape(12, TARGET_FRAMES) 380 # 子序列 DTW 仅在以下两个条件同时满足时启用:
381 # 1. chroma_full 已写入(has_full)
382 # 2. query 明显短于 DB(片段场景),超过此比例则退回 128 帧 DTW
383 # 避免 tempo 变速等全曲查询因绝对时长差异导致的对齐退化
384 q_n = query_chroma_full.shape[1] if self.config.subsequence_dtw_enabled else 0
385 use_sub = (
386 self.config.subsequence_dtw_enabled
387 and has_full
388 and q_n < n_frames * self.config.subsequence_dtw_length_ratio
389 )
390 if use_sub:
391 cand_chroma = np.array(fv, dtype=np.float32).reshape(12, n_frames)
392 dtw_sim = _best_shifted_subsequence_dtw(
393 query_chroma_full, cand_chroma,
394 early_exit_threshold=self.config.duplicate_threshold,
395 )
396 else:
397 cand_chroma_128 = np.array(fv_128, dtype=np.float32).reshape(12, TARGET_FRAMES)
316 dtw_sim = _best_shifted_dtw_similarity( 398 dtw_sim = _best_shifted_dtw_similarity(
317 chroma, cand_chroma, early_exit_threshold=self.config.duplicate_threshold 399 chroma, cand_chroma_128,
400 early_exit_threshold=self.config.duplicate_threshold,
318 ) 401 )
319 reranked.append(CompositionCandidate(song_id=int(song_id), similarity=dtw_sim)) 402 reranked.append(CompositionCandidate(song_id=int(song_id), similarity=dtw_sim))
320 reranked.sort(key=lambda c: c.similarity, reverse=True) 403 reranked.sort(key=lambda c: c.similarity, reverse=True)
...@@ -457,3 +540,64 @@ def _best_shifted_dtw_similarity( ...@@ -457,3 +540,64 @@ def _best_shifted_dtw_similarity(
457 if best >= early_exit_threshold: 540 if best >= early_exit_threshold:
458 break 541 break
459 return best 542 return best
543
544
545 @numba.njit(cache=True)
546 def _subsequence_dtw_dp(cost: np.ndarray) -> float:
547 """子序列 DTW DP 填表(numba JIT 编译)。
548
549 与全局 DTW(_dtw_dp)的两处关键差异:
550 1. 第一行初始化为 cost[0, :](允许从 db 任意位置开始匹配)
551 2. 返回最后一行的最小值而非 dp[n-1, m-1](允许在 db 任意位置结束)
552
553 代价除以 query 帧数 n 归一化,代表每帧平均对齐代价,
554 不随 db 序列长度变化,使相似度在全曲和片段查询间可比。
555 """
556 n, m = cost.shape # n = query 帧数,m = db 帧数
557 dp = np.full((n, m), np.inf)
558 # 自由起点:query 第 0 帧可对齐 db 任意帧
559 for j in range(m):
560 dp[0, j] = cost[0, j]
561 for i in range(1, n):
562 for j in range(m):
563 best_prev = dp[i - 1, j]
564 if j > 0:
565 v1 = dp[i - 1, j - 1]
566 v2 = dp[i, j - 1]
567 if v1 < best_prev:
568 best_prev = v1
569 if v2 < best_prev:
570 best_prev = v2
571 dp[i, j] = cost[i, j] + best_prev
572 # 自由终点:取最后一行最小值,除以 query 帧数归一化
573 min_val = dp[n - 1, 0]
574 for j in range(1, m):
575 if dp[n - 1, j] < min_val:
576 min_val = dp[n - 1, j]
577 return min_val / n
578
579
580 def _subsequence_dtw_similarity(query: np.ndarray, db_full: np.ndarray) -> float:
581 """子序列 DTW 相似度,query 可短于 db_full(片段查询场景)。
582
583 当 query 和 db_full 等长时退化为标准 DTW(与 _dtw_similarity 等价)。
584 """
585 cost = cdist(query.T, db_full.T, metric="euclidean")
586 dist = _subsequence_dtw_dp(cost)
587 return float(1.0 / (1.0 + dist))
588
589
590 def _best_shifted_subsequence_dtw(
591 query: np.ndarray,
592 db_full: np.ndarray,
593 early_exit_threshold: float = 1.1,
594 ) -> float:
595 """12 路音高循环位移下的最佳子序列 DTW 相似度。"""
596 best = 0.0
597 for shift in range(12):
598 sim = _subsequence_dtw_similarity(np.roll(query, -shift, axis=0), db_full)
599 if sim > best:
600 best = sim
601 if best >= early_exit_threshold:
602 break
603 return best
......
...@@ -22,6 +22,11 @@ pgvector>=0.2.0 ...@@ -22,6 +22,11 @@ pgvector>=0.2.0
22 # HTTP API server 22 # HTTP API server
23 fastapi>=0.110.0 23 fastapi>=0.110.0
24 uvicorn[standard]>=0.29.0 24 uvicorn[standard]>=0.29.0
25 python-multipart>=0.0.9
25 26
26 # Environment variable loading 27 # Environment variable loading
27 python-dotenv>=1.0 28 python-dotenv>=1.0
29
30 # Aliyun OSS and ICE SDK
31 oss2>=2.18.0
32 alibabacloud_ice20201109>=4.0.0
......
1 {
2 "total": 240,
3 "filters": {
4 "variants": null,
5 "sample_classes": null,
6 "expected": null,
7 "original_total": 240
8 },
9 "duplicate_threshold": 0.8,
10 "accuracy": 0.975,
11 "precision": 1.0,
12 "recall": 0.9625,
13 "f1": 0.9809,
14 "tp": 154,
15 "fp": 0,
16 "tn": 80,
17 "fn": 6,
18 "by_variant": {
19 "acr_original": {
20 "accuracy": 1.0,
21 "total": 20
22 },
23 "codec_320k": {
24 "accuracy": 1.0,
25 "total": 20
26 },
27 "eq_bass_boost": {
28 "accuracy": 1.0,
29 "total": 20
30 },
31 "eq_mid": {
32 "accuracy": 0.95,
33 "total": 20
34 },
35 "negative_original": {
36 "accuracy": 1.0,
37 "total": 80
38 },
39 "noise_floor": {
40 "accuracy": 1.0,
41 "total": 20
42 },
43 "reverb_small": {
44 "accuracy": 1.0,
45 "total": 20
46 },
47 "slight_trim": {
48 "accuracy": 0.75,
49 "total": 20
50 },
51 "sr_16k": {
52 "accuracy": 1.0,
53 "total": 20
54 }
55 },
56 "out": "results/aliyun_dna_eval.csv"
57 }
...\ No newline at end of file ...\ No newline at end of file
1 import json, os
2 from pathlib import Path
3 sys_path = str(Path(__file__).resolve().parent.parent.parent)
4 import sys; sys.path.insert(0, sys_path)
5
6 # 加载 .env
7 env_path = Path(__file__).resolve().parent.parent.parent / ".env"
8 with env_path.open(encoding="utf-8") as f:
9 for line in f:
10 line = line.strip()
11 if not line or line.startswith("#") or "=" not in line:
12 continue
13 key, value = line.split("=", 1)
14 key = key.strip()
15 if key:
16 os.environ.setdefault(key, value.strip().strip('"').strip("'"))
17
18 from acrcloud.recognizer import ACRCloudRecognizer
19
20 config = {
21 "host": "identify-cn-north-1.acrcloud.cn",
22 "access_key": os.environ["ACRCLOUD_ACCESS_KEY"],
23 "access_secret": os.environ["ACRCLOUD_ACCESS_SECRET"],
24 "timeout": 10,
25 }
26 re = ACRCloudRecognizer(config)
27 result = re.recognize_by_file("/Volumes/移动硬盘/composition_test/202930109_GA000376.wav", 0, 10)
28 print(json.dumps(json.loads(result), ensure_ascii=False, indent=2))
1 Usage: ./acrcloud_extr [options] -i <infile>
2
3 General Options:
4 -h, --help Print this help message
5 -v, --version Print version information
6
7 Advanced Options:
8 -i Path to the input audio file.
9 -s Start time (in seconds) to skip from the beginning. Default: 0.
10 Example: ./acrcloud_extr -s 30 -i input.mp3
11 -l Duration (in seconds) of audio to process.
12 Default: 12s (for recognition fingerprint).
13 Note: This is ignored if generating a DB fingerprint.
14 Example: ./acrcloud_extr -l 12 -i input.mp3
15 -o Path to the output file.
16 Default: Saves to the same directory as the input file.
17 -cli Generate a recognition (query) fingerprint instead of a DB fingerprint.
18 Example: ./acrcloud_extr -cli -i input.mp3
19 -type Fingerprint type identifier. Default: 0.
20 0: standard audio fingerprint;
21 200: large filter audio fingerprint;
22 Example: ./acrcloud_extr -type 0 -i input.mp3
23 -au Export decoded audio data (Format: RIFF WAV, PCM, 16-bit, Mono, 8000Hz).
24 Example: ./acrcloud_extr -au -i input.mp3
25 --debug Enable debug mode to print detailed runtime information.
26
27 #*******************************************************#
28 MD5 (acrcloud_extr_linux_arm64) = cafaa6105cbfff4387e50beb229c8b56
29 MD5 (acrcloud_extr_linux_arm64_alpine) = 1ced0ca9e0b7c1b0a3848fcbe0c7b6ab
30 MD5 (acrcloud_extr_linux_x86-x64) = a869c8b3e2a0ac29bd1e902e5dafba50
31 MD5 (acrcloud_extr_linux_x86-x64_alpine) = 5d9dd7c2665facca498d12b028767d18
32 MD5 (acrcloud_extr_mac) = c59e6a64ad9c6aa62079d34c74691bfa
33 MD5 (acrcloud_extr_win.exe) = 501460707e10f23cbe396debdcd0a41a
1 调用CancelDNAJob取消DNA作业。
2
3 ## 接口说明
4
5 - 本接口只能取消状态处于“排队中”状态的作业;
6
7 - 建议先调用更新管道接口(**UpdatePipeline**)将管道状态置为 Paused,暂停作业调度,再调用取消作业接口取消作业;取消完后需要恢复管道状态为 Active,管道中的作业才会被调度执行。
8
9
10 ## 调试
11
12 [您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/CancelDNAJob)
13
14 [![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/CancelDNAJob)
15
16 ## 授权信息
17
18 下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
19
20 - 操作:是指具体的权限点。
21 - 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
22 - 资源类型:是指操作中支持授权的资源类型。具体说明如下:
23 - 对于必选的资源类型,用前面加 \* 表示。
24 - 对于不支持资源级授权的操作,用`全部资源`表示。
25 - 条件关键字:是指云产品自身定义的条件关键字。
26 - 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
27
28 | 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
29 | --- | --- | --- | --- | --- |
30 | ice:CancelDNAJob | | \\*全部资源 `*` | 无 | 无 |
31
32 ## 请求参数
33
34 | 名称 | 类型 | 必填 | 描述 | 示例值 |
35 | --- | --- | --- | --- | --- |
36 | JobId | string | 是 | 取消 DNA 作业 ID。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
37
38 ## 返回参数
39
40 | 名称 | 类型 | 描述 | 示例值 |
41 | --- | --- | --- | --- |
42 | | object | Schema of Response | |
43 | RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
44 | JobId | string | 作业 ID。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
45
46 ## 示例
47
48 正常返回示例
49
50 `JSON`格式
51
52 ```
53 {
54 "RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58",
55 "JobId": "2288c6ca184c0e47098a5b665e2a12****"
56 }
57 ```
58
59 ## 错误码
60
61 访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
62
63 ## 变更历史
64
65 | 变更时间 | 变更内容概要 | 操作 |
66 | --- | --- | --- |
67 | 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/CancelDNAJob?updateTime=2023-05-25#workbench-doc-change-demo) |
...\ No newline at end of file ...\ No newline at end of file
1 调用DeleteDNAFiles删除DNA文件。
2
3 ## 调试
4
5 [您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/DeleteDNAFiles)
6
7 [![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/DeleteDNAFiles)
8
9 ## 授权信息
10
11 下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
12
13 - 操作:是指具体的权限点。
14 - 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
15 - 资源类型:是指操作中支持授权的资源类型。具体说明如下:
16 - 对于必选的资源类型,用前面加 \* 表示。
17 - 对于不支持资源级授权的操作,用`全部资源`表示。
18 - 条件关键字:是指云产品自身定义的条件关键字。
19 - 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
20
21 | 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
22 | --- | --- | --- | --- | --- |
23 | ice:DeleteDNAFiles | | \\*全部资源 `*` | 无 | 无 |
24
25 ## 请求参数
26
27 | 名称 | 类型 | 必填 | 描述 | 示例值 |
28 | --- | --- | --- | --- | --- |
29 | DBId | string | 是 | 需要删除文件的 DNA 库 ID。 | fb712a6890464059b1b2ea7c8647\\*\\*\\*\\* |
30 | PrimaryKeys | string | 是 | 需要删除的文件主键,用半角逗号(,)分隔,一次最多删除 50 个。 | 41e6536e4f2250e2e9bf26cdea19\\*\\*\\*\\* |
31
32 ## 返回参数
33
34 | 名称 | 类型 | 描述 | 示例值 |
35 | --- | --- | --- | --- |
36 | | object | | |
37 | RequestId | string | 请求 ID。 | 31E30781-9495-5E2D-A84D-759B0A01E262 |
38
39 ## 示例
40
41 正常返回示例
42
43 `JSON`格式
44
45 ```
46 {
47 "RequestId": "31E30781-9495-5E2D-A84D-759B0A01E262"
48 }
49 ```
50
51 ## 错误码
52
53 访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
54
55 ## 变更历史
56
57 | 变更时间 | 变更内容概要 | 操作 |
58 | --- | --- | --- |
59 | 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/DeleteDNAFiles?updateTime=2023-05-25#workbench-doc-change-demo) |
...\ No newline at end of file ...\ No newline at end of file
1 调用ListDNAFiles查询DNA文件。
2
3 ## 接口说明
4
5 本接口通过 DNA 库 ID 查询文件列表,支持分页查询。
6
7 ## 调试
8
9 [您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/ListDNAFiles)
10
11 [![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/ListDNAFiles)
12
13 ## 授权信息
14
15 下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
16
17 - 操作:是指具体的权限点。
18 - 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
19 - 资源类型:是指操作中支持授权的资源类型。具体说明如下:
20 - 对于必选的资源类型,用前面加 \* 表示。
21 - 对于不支持资源级授权的操作,用`全部资源`表示。
22 - 条件关键字:是指云产品自身定义的条件关键字。
23 - 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
24
25 | 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
26 | --- | --- | --- | --- | --- |
27 | ice:ListDNAFiles | | \\*全部资源 `*` | 无 | 无 |
28
29 ## 请求参数
30
31 | 名称 | 类型 | 必填 | 描述 | 示例值 |
32 | --- | --- | --- | --- | --- |
33 | NextPageToken | string | 否 | 分页查询。请求第一页时,NextPageToken 为空;请求后续文件时需传入前一页查询结果中的 NextPageToken 值。 | ae0fd49c0840e14daf0d66a75b83\\*\\*\\*\\* |
34 | PageSize | integer | 否 | 单页数据个数,默认为 20,最大 100。 | 10 |
35 | DBId | string | 是 | DNA 库 Id。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
36
37 ## 返回参数
38
39 | 名称 | 类型 | 描述 | 示例值 |
40 | --- | --- | --- | --- |
41 | | object | | |
42 | RequestId | string | 请求 ID。 | 2AE89FA5-E620-56C7-9B80-75D09757385A |
43 | NextPageToken | string | 下一页 Token。 | ae0fd49c0840e14daf0d66a75b83\\*\\*\\*\\* |
44 | FileList | array<object> | 文件列表。 | |
45 | DNAFile | object | DNAFile | |
46 | PrimaryKey | string | DNA 文件用户主键。 | ae0fd49c0840e14daf0d66a75b83\\*\\*\\*\\* |
47 | InputFile | object | 输入文件 OSS 信息。 | |
48 | Object | string | 输入文件的 OSS Object。 | example-\\*\\*\\*\\*.mp4 |
49 | Location | string | 输入文件的 OSS Location。 | oss-cn-beijing |
50 | Bucket | string | 输入文件的 OSS Bucket。 | example-bucket |
51
52 ## 示例
53
54 正常返回示例
55
56 `JSON`格式
57
58 ```
59 {
60 "RequestId": "2AE89FA5-E620-56C7-9B80-75D09757385A",
61 "NextPageToken": "ae0fd49c0840e14daf0d66a75b83****",
62 "FileList": [
63 {
64 "PrimaryKey": "ae0fd49c0840e14daf0d66a75b83****",
65 "InputFile": {
66 "Object": "example-****.mp4",
67 "Location": "oss-cn-beijing",
68 "Bucket": "example-bucket"
69 }
70 }
71 ]
72 }
73 ```
74
75 ## 错误码
76
77 访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
78
79 ## 变更历史
80
81 | 变更时间 | 变更内容概要 | 操作 |
82 | --- | --- | --- |
83 | 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/ListDNAFiles?updateTime=2023-05-25#workbench-doc-change-demo) |
...\ No newline at end of file ...\ No newline at end of file
1 调用QueryDNAJobList查询DNA作业。
2
3 ## 调试
4
5 [您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/QueryDNAJobList)
6
7 [![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/QueryDNAJobList)
8
9 ## 授权信息
10
11 下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
12
13 - 操作:是指具体的权限点。
14 - 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
15 - 资源类型:是指操作中支持授权的资源类型。具体说明如下:
16 - 对于必选的资源类型,用前面加 \* 表示。
17 - 对于不支持资源级授权的操作,用`全部资源`表示。
18 - 条件关键字:是指云产品自身定义的条件关键字。
19 - 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
20
21 | 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
22 | --- | --- | --- | --- | --- |
23 | ice:QueryDNAJobList | | \\*全部资源 `*` | 无 | 无 |
24
25 ## 请求参数
26
27 | 名称 | 类型 | 必填 | 描述 | 示例值 |
28 | --- | --- | --- | --- | --- |
29 | JobIds | string | 否 | 需要查询的 DNA 作业 ID 列表。一次最多建议查询 10 个,用半角逗号(,)分隔。 | 88c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
30
31 ## 返回参数
32
33 | 名称 | 类型 | 描述 | 示例值 |
34 | --- | --- | --- | --- |
35 | | object | | |
36 | RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
37 | JobList | array<object> | DNA 作业信息。 | |
38 | DNAJob | object | DNAJob | |
39 | DNAResult | string | DNA 结果链接。 | http://test\\_bucket.oss-cn-shanghai.aliyuncs.com/fingerprint/video/search\\_result/5/5.txt |
40 | PrimaryKey | string | 唯一的视频主键,唯一性由用户保证。 | 3ca84a39a9024f19853b21be9cf9\\*\\*\\*\\* |
41 | DBId | string | DNA 库 Id。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
42 | CreationTime | string | 作业创建时间。 | 2022-12-28T03:21:37Z |
43 | FinishTime | string | 作业完成时间。 | 2022-12-28T03:21:44Z |
44 | Status | string | 作业状态,可取值: - **Queuing**:排队中。 - **Analysing**:分析中。 - **Success**:成功。 - **Fail**:失败。 | Queuing |
45 | Message | string | 作业执行错误信息。 | "The resource operated \\\\"a887d0b\\*\\*\\*d805ef6f7f6786302\\\\" cannot be found" |
46 | Config | string | DNA 配置。 | {"SaveType": "save","MediaType"":"video"} |
47 | UserData | string | 用户自定义数据。 | testdna |
48 | Code | string | 作业执行错误码。 | "InvalidParameter.ResourceNotFound" |
49 | Input | object | 输入文件。 | |
50 | Type | string | 输入文件类型,取值: 1. OSS:OSS 文件地址 2. Media:媒资 ID | Media |
51 | Media | string | 输入文件信息,支持 OSS 地址和媒资 ID 两种。 OSS 地址规则为 1、oss://bucket/object 2、http(s)://bucket.oss-\\[regionId\\].aliyuncs.com/object 其中 bucket 为和当前项目处于同一区域的 oss bucket 名称,object 为文件路径。 | 1b1b9cd148034739af413150fded\\*\\*\\*\\* |
52 | Id | string | 作业 ID。 | 88c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
53
54 DNAResult 的内容为 Array of VideoMatchInfo,其中:
55
56 **VideoMatchInfo 详情**
57
58 | 名称 | 类型 | 描述 |
59 | --- | --- | --- |
60 | PrimaryKey | String | 匹配文件唯一主键。 |
61 | GlobalSimilarity | Double | 整体相似度。 |
62 | VideoMatchSegments | Array of VideoMatchSegment | 视频/图搜视频匹配片段信息。 |
63 | AudioMatchSegments | Array of AudioMatchSegment | 音频搜音频匹配片段信息。 |
64 | TextMatchSegments | Array of TextMatchSegment | 文本搜文本匹配片段信息。 |
65
66 **VideoMatchSegment/AudioMatchSegment 详情**
67
68 | 名称 | 类型 | 描述 |
69 | --- | --- | --- |
70 | StartTime | Double | 输入视频/音频的开始时间。 |
71 | EndTime | Double | 输入视频/音频的结束时间。 |
72 | MasterStartTime | Double | 库中视频/音频的开始时间。 |
73 | MasterEndTime | Double | 库中视频/音频的结束时间。 |
74 | Similarity | Double | 匹配片段的置信度。 |
75
76 **TextMatchSegment 详情**
77
78 | 名称 | 类型 | 描述 |
79 | --- | --- | --- |
80 | Start | Double | 查询匹配片段起始时间。 |
81 | End | Double | 查询匹配片段结束时间。 |
82 | QueryText | String | 查询匹配的文本片段。 |
83 | MasterText | String | 底库匹配的文本片段。 |
84 | Similarity | Double | 匹配片段的置信度。 |
85
86 ## 示例
87
88 正常返回示例
89
90 `JSON`格式
91
92 ```
93 {
94 "RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58",
95 "JobList": [
96 {
97 "DNAResult": "http://test_bucket.oss-cn-shanghai.aliyuncs.com/fingerprint/video/search_result/5/5.txt",
98 "PrimaryKey": "3ca84a39a9024f19853b21be9cf9****",
99 "DBId": "2288c6ca184c0e47098a5b665e2a12****",
100 "CreationTime": "2022-12-28T03:21:37Z",
101 "FinishTime": "2022-12-28T03:21:44Z",
102 "Status": "Queuing",
103 "Message": "The resource operated \"a887d0b***d805ef6f7f6786302\" cannot be found",
104 "Config": "{\"SaveType\": \"save\",\"MediaType\"\":\"video\"}",
105 "UserData": "testdna",
106 "Code": "InvalidParameter.ResourceNotFound",
107 "Input": {
108 "Type": "Media",
109 "Media": "1b1b9cd148034739af413150fded****"
110 },
111 "Id": "88c6ca184c0e47098a5b665e2a12****"
112 }
113 ]
114 }
115 ```
116
117 ## 错误码
118
119 访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
120
121 ## 变更历史
122
123 | 变更时间 | 变更内容概要 | 操作 |
124 | --- | --- | --- |
125 | 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/QueryDNAJobList?updateTime=2023-05-25#workbench-doc-change-demo) |
...\ No newline at end of file ...\ No newline at end of file
1 调用SubmitDNAJob提交DNA作业。
2
3 ## 接口说明
4
5 - 该接口为[异步接口](https://help.aliyun.com/zh/ims/developer-reference/explanation-of-the-asynchronous-task-processing-flow),提交任务后返回任务 ID(此时任务尚未完成,任务将进入后台排队异步执行),最终结果将通过回调通知,也可通过[查询 DNA 作业列表](https://help.aliyun.com/zh/ims/developer-reference/api-ice-2020-11-09-querydnajoblist)主动查询任务状态。
6
7 - 本接口目前支持使用的地域为华北 2(北京)、华东 1(杭州)、华东 2(上海)。
8
9 - 提交文本 DNA 作业目前仅支持华东 2(上海)地域使用。
10
11
12 ## 调试
13
14 [您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/SubmitDNAJob)
15
16 [![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png) 调试](https://api.aliyun.com/api/ICE/2020-11-09/SubmitDNAJob)
17
18 ## **授权信息**
19
20 下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
21
22 - 操作:是指具体的权限点。
23
24 - 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
25
26 - 资源类型:是指操作中支持授权的资源类型。具体说明如下:
27
28 - 对于必选的资源类型,用前面加 \* 表示。
29
30 - 对于不支持资源级授权的操作,用`全部资源`表示。
31
32 - 条件关键字:是指云产品自身定义的条件关键字。
33
34 - 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
35
36
37 | **操作** | **访问级别** | **资源类型** | **条件关键字** | **关联操作** |
38 | --- | --- | --- | --- | --- |
39 | ice:SubmitDNAJob | | \\*全部资源 `*` | 无 | 无 |
40
41 ## 请求参数
42
43 | **名称** | **类型** | **必填** | **描述** | **示例值** |
44 | --- | --- | --- | --- | --- |
45 | Input | object | 是 | 输入 DNA 文件。 | |
46 | Type | string | 是 | 输入文件类型,取值: 1. OSS:OSS 文件地址 2. Media:媒资 ID | Media |
47 | Media | string | 是 | 输入文件信息,支持 OSS 地址和媒资 ID 两种。 OSS 地址规则为: 1、oss://bucket/object 2、http(s)://bucket.oss-\\[regionId\\].aliyuncs.com/object 其中 bucket 为和当前项目处于同一区域的 oss bucket 名称,object 为文件路径。 | 1b1b9cd148034739af413150fded\\*\\*\\*\\* |
48 | PipelineId | string | 否 | 管道 ID。 | 5246b8d12a62433ab77845074039\\*\\*\\*\\* |
49 | Config | string | 否 | DNA 配置,JSON 对象。 若填写,会覆盖模板参数。 | {"SaveType": "save","MediaType":"video"} |
50 | TemplateId | string | 否 | 模版 ID。 | S00000101-100060 |
51 | UserData | string | 否 | 用户自定义数据,最大长度 128 个字节。 | userData |
52 | PrimaryKey | string | 是 | 唯一的视频主键,唯一性由用户保证。 | 3ca84a39a9024f19853b21be9cf9\\*\\*\\*\\* |
53 | DBId | string | 是 | DNA 库 ID。如需创建 DNA 库,请参见[CreateDNADB - 创建 DNA 库](https://help.aliyun.com/zh/ims/developer-reference/api-ice-2020-11-09-creatednadb)。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
54
55 DNA 配置 Config 参数包括:SaveType 和 MediaType 字段。
56
57 其中,SaveType 表示存储类型,包括:
58
59 - **nosave**: 仅搜索不入库。
60
61 - **save**: 去重入库。
62
63 - **forcesave**: 强制入库。
64
65 - **onlysave**: 仅入库不搜索。
66
67
68 MediaType 表示输入文件媒体类型,包括:
69
70 - **video**: 视频。
71
72 - **audio**: 音频。
73
74 - **image**: 图片。
75
76 - **text**: 长文本。
77
78 - **asr**: asr 识别文本。
79
80
81 ## **返回参数**
82
83 | **名称** | **类型** | **描述** | **示例值** |
84 | --- | --- | --- | --- |
85 | | object | | |
86 | RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
87 | JobId | string | 视频 DNA 作业 ID。建议您保存此 ID 便于后续调用其他相关接口时使用。 | 88c6ca184c0e47098a5b665e2\\*\\*\\*\\* |
88
89 ## 示例
90
91 正常返回示例
92
93 `JSON`格式
94
95 ```
96 {
97 "RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58",
98 "JobId": "88c6ca184c0e47098a5b665e2****"
99 }
100 ```
101
102 ## 错误码
103
104 访问[错误中心](https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
105
106 ## **变更历史**
107
108 更多信息,参考[变更详情](https://api.aliyun.com/document/ICE/2020-11-09/SubmitDNAJob#workbench-doc-change-demo)
...\ No newline at end of file ...\ No newline at end of file
1 调用ListDNADB查询DNA库。
2
3 ## 调试
4
5 [您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。](https://api.aliyun.com/api/ICE/2020-11-09/ListDNADB)
6
7 [![](https://img.alicdn.com/tfs/TB16JcyXHr1gK0jSZR0XXbP8XXa-24-26.png)调试](https://api.aliyun.com/api/ICE/2020-11-09/ListDNADB)
8
9 ## 授权信息
10
11 下表是API对应的授权信息,可以在RAM权限策略语句的`Action`元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下:
12
13 - 操作:是指具体的权限点。
14 - 访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。
15 - 资源类型:是指操作中支持授权的资源类型。具体说明如下:
16 - 对于必选的资源类型,用前面加 \* 表示。
17 - 对于不支持资源级授权的操作,用`全部资源`表示。
18 - 条件关键字:是指云产品自身定义的条件关键字。
19 - 关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。
20
21 | 操作 | 访问级别 | 资源类型 | 条件关键字 | 关联操作 |
22 | --- | --- | --- | --- | --- |
23 | ice:ListDNADB | none | \\*全部资源 `*` | 无 | 无 |
24
25 ## 请求参数
26
27 | 名称 | 类型 | 必填 | 描述 | 示例值 |
28 | --- | --- | --- | --- | --- |
29 | DBIds | string | 否 | DNA 库 ID 列表。⼀次建议最多查 10 个,ID 之间⽤半角逗号(,)分隔。 | 2288c6ca184c0e47098a5b665e2a12\\*\\*\\*\\*,78dc866518b843259669df58ed30\\*\\*\\*\\* |
30
31 ## 返回参数
32
33 | 名称 | 类型 | 描述 | 示例值 |
34 | --- | --- | --- | --- |
35 | | object | | |
36 | DBList | array<object> | DNA 库列表。 | |
37 | DBInfo | object | DBInfo | |
38 | Status | string | DNA 库状态。默认值:**offline**(离线)。**active** 表示 DNA 库可用。可取值: - **offline**:离线。 - **active**:在线。 - **deleted**:删除。 | active |
39 | Description | string | DNA 库描述。 | 这是一个视频DNA库。 |
40 | Name | string | DNA 库名称。 | example-name |
41 | Model | string | DNA 库模型。包含: - **Video**:视频 - **Audio**:音频 - **Image**:图片 - **Text**:文本【仅上海区域支持】 | Video |
42 | DBId | string | DNA 库 Id。 | 88c6ca184c0e47098a5b665e2a12\\*\\*\\*\\* |
43 | RequestId | string | 请求 ID。 | 25818875-5F78-4A13-BEF6-D7393642CA58 |
44
45 ## 示例
46
47 正常返回示例
48
49 `JSON`格式
50
51 ```
52 {
53 "DBList": [
54 {
55 "Status": "active",
56 "Description": "这是一个视频DNA库。",
57 "Name": "example-name",
58 "Model": "Video",
59 "DBId": "88c6ca184c0e47098a5b665e2a12****"
60 }
61 ],
62 "RequestId": "25818875-5F78-4A13-BEF6-D7393642CA58"
63 }
64 ```
65
66 ## 错误码
67
68 访问[错误中心]( https://api.aliyun.com/document/ICE/2020-11-09/errorCode)查看更多错误码。
69
70 ## 变更历史
71
72 | 变更时间 | 变更内容概要 | 操作 |
73 | --- | --- | --- |
74 | 2024-07-23 | OpenAPI 返回结构发生变更 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/ListDNADB?updateTime=2024-07-23#workbench-doc-change-demo) |
75 | 2023-05-25 | API 内部配置变更,不影响调用 | [查看变更详情](https://api.aliyun.com/document/ICE/2020-11-09/ListDNADB?updateTime=2023-05-25#workbench-doc-change-demo) |
...\ No newline at end of file ...\ No newline at end of file
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 """查询阿里云 DNA 作业状态。
4
5 用法:
6 # 查询指定 JobId
7 python scripts/aliyun_dna/query_dna_jobs.py --job-ids job_id_1,job_id_2
8
9 # 查看最近入库状态(从 state-file 读取 JobId)
10 python scripts/aliyun_dna/query_dna_jobs.py --state-file upload_dna_state.json
11
12 # 只显示失败的作业
13 python scripts/aliyun_dna/query_dna_jobs.py --state-file upload_dna_state.json --filter failed
14 """
15
16 import argparse
17 import json
18 import logging
19 import os
20 import sys
21 from pathlib import Path
22
23 sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
24
25 from dotenv import load_dotenv
26 load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
27
28 from alibabacloud_ice20201109.client import Client as ICEClient
29 from alibabacloud_tea_openapi.models import Config as OpenApiConfig
30 from alibabacloud_ice20201109 import models as ice_models
31
32 logger = logging.getLogger(__name__)
33
34 STATUS_EMOJI = {
35 "Success": "✅",
36 "Fail": "❌",
37 "Queuing": "⏳",
38 "Analysing": "🔄",
39 }
40
41
42 def _get_ice_client() -> ICEClient:
43 access_key_id = os.environ["ALIYUN_ICE_ACCESS_KEY_ID"]
44 access_key_secret = os.environ["ALIYUN_ICE_ACCESS_KEY_SECRET"]
45 region = os.environ.get("ALIYUN_ICE_REGION", "cn-hangzhou")
46
47 config = OpenApiConfig(
48 access_key_id=access_key_id,
49 access_key_secret=access_key_secret,
50 endpoint=f"ice.{region}.aliyuncs.com",
51 region_id=region,
52 )
53 return ICEClient(config)
54
55
56 def load_jobs_from_state(state_file: str) -> dict[str, str]:
57 """从 state-file 提取 job_id 映射 {audio_path: job_id}。"""
58 if not Path(state_file).exists():
59 logger.error("状态文件不存在: %s", state_file)
60 return {}
61 with open(state_file, encoding="utf-8") as f:
62 state = json.load(f)
63 return {k: v for k, v in state.items() if k.endswith("::job_id")}
64
65
66 def query_jobs(ice_client: ICEClient, job_ids: list[str]) -> list[dict]:
67 """批量查询 DNA 作业状态。"""
68 request = ice_models.QueryDNAJobListRequest(job_ids=",".join(job_ids))
69 response = ice_client.query_dnajob_list(request)
70
71 results = []
72 for job in response.body.job_list or []:
73 results.append({
74 "job_id": job.id,
75 "status": job.status,
76 "primary_key": job.primary_key,
77 "creation_time": job.creation_time,
78 "finish_time": job.finish_time,
79 "message": job.message or "",
80 "dna_result_url": job.dnaresult or "",
81 "config": job.config or "",
82 })
83 return results
84
85
86 def main() -> None:
87 logging.basicConfig(
88 level=logging.INFO,
89 format="%(asctime)s [%(levelname)s] %(message)s",
90 )
91
92 parser = argparse.ArgumentParser(description="查询阿里云 DNA 作业状态")
93 parser.add_argument("--job-ids", help="JobId 列表,逗号分隔")
94 parser.add_argument("--state-file", help="从上传状态文件中提取 JobId")
95 parser.add_argument("--filter", choices=["all", "success", "failed", "pending"],
96 default="all", help="过滤作业状态")
97 args = parser.parse_args()
98
99 # 验证配置
100 required_env = ["ALIYUN_ICE_ACCESS_KEY_ID", "ALIYUN_ICE_ACCESS_KEY_SECRET", "ALIYUN_DNA_DB_ID"]
101 for key in required_env:
102 if not os.environ.get(key):
103 logger.error("缺少环境变量: %s", key)
104 sys.exit(1)
105
106 # 收集 JobId
107 job_ids = []
108 if args.job_ids:
109 job_ids = [j.strip() for j in args.job_ids.split(",") if j.strip()]
110 elif args.state_file:
111 state_jobs = load_jobs_from_state(args.state_file)
112 job_ids = list(state_jobs.values())
113 logger.info("从状态文件提取到 %d 个 JobId", len(job_ids))
114 else:
115 logger.error("请指定 --job-ids 或 --state-file")
116 sys.exit(1)
117
118 if not job_ids:
119 logger.info("没有找到 JobId")
120 return
121
122 # 分批查询(每次最多 10 个)
123 ice_client = _get_ice_client()
124 all_results = []
125
126 for batch_start in range(0, len(job_ids), 10):
127 batch = job_ids[batch_start:batch_start + 10]
128 results = query_jobs(ice_client, batch)
129 all_results.extend(results)
130
131 # 过滤
132 if args.filter == "success":
133 all_results = [r for r in all_results if r["status"] == "Success"]
134 elif args.filter == "failed":
135 all_results = [r for r in all_results if r["status"] == "Fail"]
136 elif args.filter == "pending":
137 all_results = [r for r in all_results if r["status"] in ("Queuing", "Analysing")]
138
139 # 输出
140 status_count = {}
141 for r in all_results:
142 s = r["status"]
143 status_count[s] = status_count.get(s, 0) + 1
144
145 print(f"\n作业状态汇总 (共 {len(all_results)} 个):")
146 for status, count in sorted(status_count.items()):
147 emoji = STATUS_EMOJI.get(status, "❓")
148 print(f" {emoji} {status}: {count}")
149
150 print(f"\n{'JobId':<40} {'状态':<12} {'PrimaryKey':<20} {'创建时间':<22} {'错误信息'}")
151 print("-" * 140)
152 for r in sorted(all_results, key=lambda x: x.get("creation_time", "")):
153 emoji = STATUS_EMOJI.get(r["status"], "❓")
154 print(f"{emoji} {r['job_id']:<38} {r['status']:<12} {r['primary_key']:<20} "
155 f"{r['creation_time'] or 'N/A':<22} {r['message']}")
156
157 # 汇总输出
158 print(f"\n总计: {len(all_results)} 个作业")
159
160
161 if __name__ == "__main__":
162 main()
...@@ -6,10 +6,7 @@ expected_song_id 的 top-k/top1 命中只作为诊断字段。 ...@@ -6,10 +6,7 @@ expected_song_id 的 top-k/top1 命中只作为诊断字段。
6 输出 precision/recall/F1。 6 输出 precision/recall/F1。
7 7
8 用法: 8 用法:
9 python scripts/evaluate_composition.py \ 9
10 --dsn "postgresql:///lyric_dedup" \
11 --queries composition_testset/test_samples.csv \
12 --out composition_dedup/composition_eval/nohop_result.csv
13 """ 10 """
14 11
15 import argparse 12 import argparse
...@@ -20,10 +17,10 @@ import sys ...@@ -20,10 +17,10 @@ import sys
20 import time 17 import time
21 from pathlib import Path 18 from pathlib import Path
22 19
23 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 20 sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
24 21
25 from dotenv import load_dotenv 22 from dotenv import load_dotenv
26 load_dotenv(Path(__file__).resolve().parent.parent / ".env") 23 load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
27 24
28 from composition_dedup.service import CompositionConfig, CompositionDedupService 25 from composition_dedup.service import CompositionConfig, CompositionDedupService
29 26
......
...@@ -8,9 +8,9 @@ ...@@ -8,9 +8,9 @@
8 python scripts/generate_composition_testset.py \ 8 python scripts/generate_composition_testset.py \
9 --audio-dir /Volumes/移动硬盘/composition_test \ 9 --audio-dir /Volumes/移动硬盘/composition_test \
10 --negative-audio-dir /Volumes/移动硬盘/composition_drop \ 10 --negative-audio-dir /Volumes/移动硬盘/composition_drop \
11 --out-dir composition_testset \ 11 --out-dir composition_testset_20 \
12 --num-songs 100 \ 12 --num-songs 20 \
13 --num-negative-songs 100 \ 13 --num-negative-songs 20 \
14 --negative-variants \ 14 --negative-variants \
15 --seed 123 15 --seed 123
16 16
...@@ -58,10 +58,12 @@ logger = logging.getLogger(__name__) ...@@ -58,10 +58,12 @@ logger = logging.getLogger(__name__)
58 # -------------------------------------------------------------------------- 58 # --------------------------------------------------------------------------
59 DSP_VARIANTS: list[tuple[str, str]] = [ 59 DSP_VARIANTS: list[tuple[str, str]] = [
60 # Pitch Shift(±1、±2 半音) 60 # Pitch Shift(±1、±2 半音)
61 ("pitch_up1", "asetrate=22050*1.0595,aresample=22050"), # +1 半音 61 # aresample=22050 先把源文件归一化到 22050Hz,再用 asetrate 计算正确的变调偏移,
62 ("pitch_up2", "asetrate=22050*1.1225,aresample=22050"), # +2 半音 62 # 避免源文件为 44100/48000Hz 等非 22050Hz 时 asetrate 基准算错导致时长异常。
63 ("pitch_down1", "asetrate=22050*0.9439,aresample=22050"), # -1 半音 63 ("pitch_up1", "aresample=22050,asetrate=22050*1.0595,aresample=22050"), # +1 半音
64 ("pitch_down2", "asetrate=22050*0.8909,aresample=22050"), # -2 半音 64 ("pitch_up2", "aresample=22050,asetrate=22050*1.1225,aresample=22050"), # +2 半音
65 ("pitch_down1", "aresample=22050,asetrate=22050*0.9439,aresample=22050"), # -1 半音
66 ("pitch_down2", "aresample=22050,asetrate=22050*0.8909,aresample=22050"), # -2 半音
65 # Tempo Shift 67 # Tempo Shift
66 ("tempo_slow", "atempo=0.90"), # 0.9x 68 ("tempo_slow", "atempo=0.90"), # 0.9x
67 ("tempo_fast", "atempo=1.10"), # 1.1x 69 ("tempo_fast", "atempo=1.10"), # 1.1x
...@@ -84,7 +86,7 @@ HARD_POSITIVE_VARIANTS: list[tuple[str, str]] = [ ...@@ -84,7 +86,7 @@ HARD_POSITIVE_VARIANTS: list[tuple[str, str]] = [
84 # 只保留副歌:截取中间 40%(模拟短视频截段) 86 # 只保留副歌:截取中间 40%(模拟短视频截段)
85 ("chorus_only", None), # 特殊处理,用 -ss + -t 参数 87 ("chorus_only", None), # 特殊处理,用 -ss + -t 参数
86 # Pitch + Tempo 叠加(模拟 Live 版同时有调整) 88 # Pitch + Tempo 叠加(模拟 Live 版同时有调整)
87 ("pitch_up1_tempo_slow", "asetrate=22050*1.0595,aresample=22050,atempo=0.92"), 89 ("pitch_up1_tempo_slow", "aresample=22050,asetrate=22050*1.0595,aresample=22050,atempo=0.92"),
88 ] 90 ]
89 91
90 # 负样本变体只使用相对温和的处理,避免把负样本评估变成极端音质测试。 92 # 负样本变体只使用相对温和的处理,避免把负样本评估变成极端音质测试。
...@@ -93,6 +95,10 @@ NEGATIVE_VARIANTS: list[tuple[str, str | None]] = [ ...@@ -93,6 +95,10 @@ NEGATIVE_VARIANTS: list[tuple[str, str | None]] = [
93 ("negative_codec_128k", "acodec=libmp3lame,b:a=128k"), 95 ("negative_codec_128k", "acodec=libmp3lame,b:a=128k"),
94 ] 96 ]
95 97
98 # 片段拼接负样本:每个样本从几首不同歌曲各取一段拼接,测试系统对多曲混合音频的误报率。
99 SPLICE_SONGS_PER_SAMPLE = 3 # 每个拼接样本使用的源歌曲数
100 SPLICE_FRAGMENT_SECS = 20.0 # 每段截取时长(秒)
101
96 102
97 def _ffmpeg_variant(src: Path, dst: Path, af: str) -> bool: 103 def _ffmpeg_variant(src: Path, dst: Path, af: str) -> bool:
98 """普通 audio filter 变换。""" 104 """普通 audio filter 变换。"""
...@@ -126,6 +132,43 @@ def _ffmpeg_variant(src: Path, dst: Path, af: str) -> bool: ...@@ -126,6 +132,43 @@ def _ffmpeg_variant(src: Path, dst: Path, af: str) -> bool:
126 return _run_ffmpeg(cmd) 132 return _run_ffmpeg(cmd)
127 133
128 134
135 def _ffmpeg_splice(sources: list[Path], dst: Path, fragment_secs: float = SPLICE_FRAGMENT_SECS) -> bool:
136 """从多首歌曲各取一段片段,拼接为单个音频文件。
137
138 每段从歌曲 20%~60% 处随机取起点,避开开头和结尾。
139 使用 filter_complex concat 在一次 ffmpeg 调用内完成,无临时文件。
140 """
141 inputs: list[str] = []
142 filter_parts: list[str] = []
143
144 for i, src in enumerate(sources):
145 duration = _probe_duration(src)
146 if duration is None:
147 return False
148 usable_end = max(0.0, duration - fragment_secs)
149 start_min = min(duration * 0.20, usable_end)
150 start_max = min(duration * 0.60, usable_end)
151 ss = random.uniform(start_min, start_max) if start_max > start_min else start_min
152 inputs += ["-i", str(src)]
153 filter_parts.append(
154 f"[{i}]atrim=start={ss:.3f}:duration={fragment_secs:.3f},aresample=22050[seg{i}]"
155 )
156
157 n = len(sources)
158 concat_inputs = "".join(f"[seg{i}]" for i in range(n))
159 filter_parts.append(f"{concat_inputs}concat=n={n}:v=0:a=1[out]")
160
161 cmd = [
162 "ffmpeg", "-y",
163 *inputs,
164 "-filter_complex", ";".join(filter_parts),
165 "-map", "[out]",
166 "-ar", "22050", "-ac", "1",
167 str(dst),
168 ]
169 return _run_ffmpeg(cmd)
170
171
129 def _ffmpeg_trim(src: Path, dst: Path, start_ratio: float, duration_ratio: float) -> bool: 172 def _ffmpeg_trim(src: Path, dst: Path, start_ratio: float, duration_ratio: float) -> bool:
130 """按相对位置截取片段。需要先探测时长。""" 173 """按相对位置截取片段。需要先探测时长。"""
131 duration = _probe_duration(src) 174 duration = _probe_duration(src)
...@@ -311,6 +354,25 @@ def main() -> None: ...@@ -311,6 +354,25 @@ def main() -> None:
311 "expected": "not_duplicate", 354 "expected": "not_duplicate",
312 }) 355 })
313 356
357 # 片段拼接负样本:数量与其他负样本变体保持一致
358 if args.negative_variants and len(negative_selected) >= SPLICE_SONGS_PER_SAMPLE:
359 for i in _tqdm(range(len(negative_selected)), desc="生成片段拼接负样本", total=len(negative_selected)):
360 srcs = random.sample(negative_selected, SPLICE_SONGS_PER_SAMPLE)
361 splice_id = f"splice_{i:04d}"
362 dst = variants_dir / f"{splice_id}_negative_splice.wav"
363 ok = _ffmpeg_splice(srcs, dst)
364 if not ok:
365 logger.warning("片段拼接生成失败,跳过: splice %d", i)
366 continue
367 query_rows.append({
368 "song_id": splice_id,
369 "audio_path": str(dst),
370 "variant": "negative_splice",
371 "sample_class": "negative",
372 "expected_song_id": "",
373 "expected": "not_duplicate",
374 })
375
314 ref_path = out_dir / "reference.csv" 376 ref_path = out_dir / "reference.csv"
315 query_path = out_dir / "queries.csv" 377 query_path = out_dir / "queries.csv"
316 378
......
...@@ -7,6 +7,12 @@ python scripts/import_audio_composition.py \ ...@@ -7,6 +7,12 @@ python scripts/import_audio_composition.py \
7 --ext .wav 7 --ext .wav
8 8
9 支持通过 --file-list 指定一个包含音频路径的文本文件(每行一个路径)。 9 支持通过 --file-list 指定一个包含音频路径的文本文件(每行一个路径)。
10
11 --update-chroma-full 模式:
12 仅为 chroma_full 列为 NULL 的已入库歌曲补写固定帧率 Chromagram,
13 跳过 feature_vector 重提取和 Dejavu 指纹计算,速度更快。
14 适用于开启子序列 DTW(COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true)后的
15 首次全量补全,无需清空重建。
10 """ 16 """
11 17
12 import argparse 18 import argparse
...@@ -14,10 +20,10 @@ import logging ...@@ -14,10 +20,10 @@ import logging
14 import sys 20 import sys
15 from pathlib import Path 21 from pathlib import Path
16 22
17 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) 23 sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
18 24
19 from dotenv import load_dotenv 25 from dotenv import load_dotenv
20 load_dotenv(Path(__file__).resolve().parent.parent / ".env") 26 load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
21 27
22 from tqdm import tqdm 28 from tqdm import tqdm
23 29
...@@ -79,6 +85,13 @@ def main() -> None: ...@@ -79,6 +85,13 @@ def main() -> None:
79 parser.add_argument("--ext", default=".wav", help="音频文件扩展名(默认 .wav)") 85 parser.add_argument("--ext", default=".wav", help="音频文件扩展名(默认 .wav)")
80 parser.add_argument("--batch-size", type=int, default=10, help="批次大小(默认 10)") 86 parser.add_argument("--batch-size", type=int, default=10, help="批次大小(默认 10)")
81 parser.add_argument("--clear", action="store_true", help="导入前清空 composition_feature 和 dejavu_fingerprints 表数据(保留表结构)") 87 parser.add_argument("--clear", action="store_true", help="导入前清空 composition_feature 和 dejavu_fingerprints 表数据(保留表结构)")
88 parser.add_argument(
89 "--update-chroma-full",
90 action="store_true",
91 help="仅为 chroma_full=NULL 的已入库歌曲补写固定帧率 Chromagram,"
92 "跳过 feature_vector 重提取和 Dejavu 指纹,速度更快。"
93 "需先执行 migrate_add_chroma_full.sql 并确认 COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true。",
94 )
82 args = parser.parse_args() 95 args = parser.parse_args()
83 96
84 config = CompositionConfig(dsn=args.dsn) 97 config = CompositionConfig(dsn=args.dsn)
...@@ -95,6 +108,10 @@ def main() -> None: ...@@ -95,6 +108,10 @@ def main() -> None:
95 audio_files = discover_audio_files(args.audio_dir, args.file_list, args.ext) 108 audio_files = discover_audio_files(args.audio_dir, args.file_list, args.ext)
96 logger.info("发现 %d 个音频文件", len(audio_files)) 109 logger.info("发现 %d 个音频文件", len(audio_files))
97 110
111 if args.update_chroma_full:
112 _update_chroma_full(config, audio_files)
113 return
114
98 success_count = 0 115 success_count = 0
99 fail_count = 0 116 fail_count = 0
100 117
...@@ -111,5 +128,64 @@ def main() -> None: ...@@ -111,5 +128,64 @@ def main() -> None:
111 logger.info("导入完成: 成功 %d, 失败 %d", success_count, fail_count) 128 logger.info("导入完成: 成功 %d, 失败 %d", success_count, fail_count)
112 129
113 130
131 def _update_chroma_full(config: "CompositionConfig", audio_files: list[tuple[str, str]]) -> None:
132 """仅补写 chroma_full 列,跳过 feature_vector 和 Dejavu 计算。"""
133 import psycopg
134 from composition_dedup.extractor import (
135 TARGET_SR,
136 extract_chroma_fixed_fps_from_samples,
137 load_audio_mono_22050hz,
138 )
139
140 # 查询库中 chroma_full 为 NULL 的 song_id
141 with psycopg.connect(config.dsn) as conn:
142 with conn.cursor() as cur:
143 cur.execute("SELECT song_id FROM composition_feature WHERE chroma_full IS NULL")
144 missing_ids = {str(row[0]) for row in cur.fetchall()}
145
146 if not missing_ids:
147 logger.info("所有已入库歌曲均已有 chroma_full,无需补全")
148 return
149
150 # 过滤:只处理缺少 chroma_full 的歌曲
151 to_update = [(sid, path) for sid, path in audio_files if sid in missing_ids]
152 logger.info(
153 "库中 chroma_full=NULL 的歌曲: %d 首,匹配到本地音频: %d 首(未匹配: %d 首)",
154 len(missing_ids),
155 len(to_update),
156 len(missing_ids) - len(to_update),
157 )
158
159 success_count = 0
160 fail_count = 0
161
162 for song_id, audio_path in tqdm(to_update, desc="补全 chroma_full"):
163 try:
164 samples = load_audio_mono_22050hz(audio_path)
165 chroma_full = extract_chroma_fixed_fps_from_samples(
166 samples, TARGET_SR,
167 target_fps=config.chroma_full_fps,
168 hop_length=config.chroma_hop_length,
169 win_len_smooth=config.chroma_win_len_smooth,
170 )
171 with psycopg.connect(config.dsn) as conn:
172 with conn.cursor() as cur:
173 cur.execute(
174 """
175 UPDATE composition_feature
176 SET chroma_full = %s, chroma_n_frames = %s
177 WHERE song_id = %s
178 """,
179 (chroma_full.flatten().tolist(), int(chroma_full.shape[1]), int(song_id)),
180 )
181 conn.commit()
182 success_count += 1
183 except Exception as e:
184 logger.error("补全失败: song_id=%s, path=%s, error=%s", song_id, audio_path, e)
185 fail_count += 1
186
187 logger.info("chroma_full 补全完成: 成功 %d, 失败 %d", success_count, fail_count)
188
189
114 if __name__ == "__main__": 190 if __name__ == "__main__":
115 main() 191 main()
......
1 -- 为 composition_feature 表新增 audio_url 字段,用于 WebUI 预览播放
2 -- 已有记录保持 NULL(无 URL 则 WebUI 不显示播放器)
3 alter table composition_feature
4 add column if not exists audio_url text;
1 -- 迁移:为 composition_feature 表添加子序列 DTW 所需的列
2 --
3 -- 适用场景:已有运行中的数据库,需要升级以支持 COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true。
4 --
5 -- 执行方式:
6 -- psql postgresql:///lyric_dedup -f scripts/migrate_add_chroma_full.sql
7 --
8 -- 执行后还需对已入库歌曲重新跑 ingest(chroma_full 列为 NULL 的行在查询时
9 -- 会自动 fallback 到 feature_vector,精排精度与旧版本相同,不影响正常使用)。
10
11 ALTER TABLE composition_feature
12 ADD COLUMN IF NOT EXISTS chroma_full float4[],
13 ADD COLUMN IF NOT EXISTS chroma_n_frames integer;
...@@ -47,6 +47,10 @@ create table if not exists composition_feature ( ...@@ -47,6 +47,10 @@ create table if not exists composition_feature (
47 id bigserial primary key, 47 id bigserial primary key,
48 song_id bigint not null unique, 48 song_id bigint not null unique,
49 feature_vector vector(1536) not null, 49 feature_vector vector(1536) not null,
50 -- 子序列 DTW:固定帧率 Chromagram(帧数随曲长变化,由 COMPOSITION_CHROMA_FULL_FPS 决定)
51 -- 仅在 COMPOSITION_SUBSEQUENCE_DTW_ENABLED=true 时由 ingest 写入
52 chroma_full float4[],
53 chroma_n_frames integer,
50 created_at timestamptz not null default now() 54 created_at timestamptz not null default now()
51 ); 55 );
52 56
......
1 """歌词去重 API 测试脚本 1 """歌词去重 & 曲去重 API 测试脚本
2 2
3 用法: 3 用法:
4 # ---- 歌词去重 ----
4 # 上传指定歌词文件并调用去重 API 5 # 上传指定歌词文件并调用去重 API
5 python test_api/test_dedup_api.py --file data/library/None_WHHY134166.lrc 6 python test_api/test_dedup_api.py --file data/library/None_WHHY134166.lrc
6 7
...@@ -15,6 +16,14 @@ ...@@ -15,6 +16,14 @@
15 16
16 # 指定 API 地址 17 # 指定 API 地址
17 python test_api/test_dedup_api.py --file data/library/None_WHHY134166.lrc --api-url "http://localhost:8000" 18 python test_api/test_dedup_api.py --file data/library/None_WHHY134166.lrc --api-url "http://localhost:8000"
19
20 # ---- 曲去重:入库 ----
21 python test_api/test_dedup_api.py rhythm-ingest --song-id 12345 --file data/audio/song.mp3
22 python test_api/test_dedup_api.py rhythm-ingest --song-id 12345 --url "https://example.com/song.mp3"
23
24 # ---- 曲去重:查询 ----
25 python test_api/test_dedup_api.py rhythm-check --file data/audio/query.mp3
26 python test_api/test_dedup_api.py rhythm-check --url "https://example.com/query.mp3" --top-k 50
18 """ 27 """
19 import argparse 28 import argparse
20 import json 29 import json
...@@ -43,6 +52,16 @@ def upload_lyric_file(file_path: str) -> str: ...@@ -43,6 +52,16 @@ def upload_lyric_file(file_path: str) -> str:
43 return result 52 return result
44 53
45 54
55 def upload_audio_file(file_path: str) -> str:
56 """上传音频文件到 OSS,返回公开 URL"""
57 uploader = OSSUploader()
58 success, result = uploader.upload_file(file_path)
59 if not success:
60 print(f"上传失败: {result}")
61 sys.exit(1)
62 return result
63
64
46 def call_dedup_api(url: str, title: str | None, artist: str | None, api_base: str) -> dict: 65 def call_dedup_api(url: str, title: str | None, artist: str | None, api_base: str) -> dict:
47 """调用去重 API""" 66 """调用去重 API"""
48 payload = json.dumps({ 67 payload = json.dumps({
...@@ -73,17 +92,100 @@ def call_dedup_api(url: str, title: str | None, artist: str | None, api_base: st ...@@ -73,17 +92,100 @@ def call_dedup_api(url: str, title: str | None, artist: str | None, api_base: st
73 92
74 93
75 def main(): 94 def main():
76 parser = argparse.ArgumentParser(description="歌词去重 API 测试") 95 parser = argparse.ArgumentParser(description="去重 API 测试")
96 parser.add_argument("--api-url", default="http://localhost:8000", help="API 服务地址 (默认 http://localhost:8000)")
97
98 subparsers = parser.add_subparsers(dest="command")
99
100 # ---- 曲去重:入库 ----
101 ingest_parser = subparsers.add_parser("rhythm-ingest", help="将音频特征写入曲库")
102 ingest_parser.add_argument("--song-id", "-s", type=int, required=True, help="歌曲 ID")
103 ingest_parser.add_argument("--file", "-f", help="本地音频文件路径")
104 ingest_parser.add_argument("--url", "-u", help="已上传的音频 URL(跳过上传步骤)")
105
106 # ---- 曲去重:查询 ----
107 check_parser = subparsers.add_parser("rhythm-check", help="查询音频是否与曲库重复")
108 check_parser.add_argument("--file", "-f", help="本地音频文件路径")
109 check_parser.add_argument("--url", "-u", help="已上传的音频 URL(跳过上传步骤)")
110 check_parser.add_argument("--top-k", type=int, default=100, help="召回候选数量(默认 100)")
111
112 # ---- 歌词去重(默认,无子命令时走此路径) ----
77 parser.add_argument("--file", "-f", help="本地歌词文件路径") 113 parser.add_argument("--file", "-f", help="本地歌词文件路径")
78 parser.add_argument("--url", "-u", help="已上传的歌词 URL(跳过上传步骤)") 114 parser.add_argument("--url", "-u", help="已上传的歌词 URL(跳过上传步骤)")
79 parser.add_argument("--title", "-t", help="歌曲标题(可选)") 115 parser.add_argument("--title", "-t", help="歌曲标题(可选)")
80 parser.add_argument("--artist", "-a", help="歌手名(可选)") 116 parser.add_argument("--artist", "-a", help="歌手名(可选)")
81 parser.add_argument("--api-url", default="http://localhost:8000", help="API 服务地址 (默认 http://localhost:8000)")
82 parser.add_argument("--upload-only", action="store_true", help="仅上传到 OSS,不调用 API") 117 parser.add_argument("--upload-only", action="store_true", help="仅上传到 OSS,不调用 API")
118
83 args = parser.parse_args() 119 args = parser.parse_args()
84 120
121 # ------------------------------------------------------------------ #
122 # 曲去重:入库
123 # ------------------------------------------------------------------ #
124 if args.command == "rhythm-ingest":
85 if not args.file and not args.url: 125 if not args.file and not args.url:
86 parser.error("需要指定 --file 或 --url") 126 ingest_parser.error("需要指定 --file 或 --url")
127
128 if args.file:
129 abs_path = os.path.join(PROJECT_ROOT, args.file) if not os.path.isabs(args.file) else args.file
130 if not os.path.exists(abs_path):
131 print(f"文件不存在: {abs_path}")
132 sys.exit(1)
133 print(f"正在上传音频: {abs_path}")
134 audio_url = upload_audio_file(abs_path)
135 print(f"上传成功: {audio_url}")
136 else:
137 audio_url = args.url
138 print(f"使用已有 URL: {audio_url}")
139
140 print(f"\n正在调用曲入库 API (song_id={args.song_id})...")
141 result = _call_rhythm_ingest(audio_url, args.song_id, api_base=args.api_url)
142
143 print(f"\n结果:")
144 print(f" song_id: {result.get('song_id')}")
145 print(f" success: {result.get('success')}")
146 print(f" message: {result.get('message', '')}")
147 return
148
149 # ------------------------------------------------------------------ #
150 # 曲去重:查询
151 # ------------------------------------------------------------------ #
152 if args.command == "rhythm-check":
153 if not args.file and not args.url:
154 check_parser.error("需要指定 --file 或 --url")
155
156 if args.file:
157 abs_path = os.path.join(PROJECT_ROOT, args.file) if not os.path.isabs(args.file) else args.file
158 if not os.path.exists(abs_path):
159 print(f"文件不存在: {abs_path}")
160 sys.exit(1)
161 print(f"正在上传音频: {abs_path}")
162 audio_url = upload_audio_file(abs_path)
163 print(f"上传成功: {audio_url}")
164 else:
165 audio_url = args.url
166 print(f"使用已有 URL: {audio_url}")
167
168 print(f"\n正在调用曲去重 API...")
169 result = _call_rhythm_check(audio_url, top_k=args.top_k, api_base=args.api_url)
170
171 print(f"\n结果:")
172 print(f" duplicate: {result.get('duplicate')}")
173 candidates = result.get("candidates", [])
174 if candidates:
175 print(f" candidates ({len(candidates)}):")
176 for i, c in enumerate(candidates[:10]):
177 print(f" [{i+1}] song_id={c.get('song_id')} similarity={c.get('similarity', 0):.4f} source={c.get('source')}")
178 if len(candidates) > 10:
179 print(f" ... 共 {len(candidates)} 条")
180 else:
181 print(f" candidates: []")
182 return
183
184 # ------------------------------------------------------------------ #
185 # 歌词去重(默认路径,无子命令)
186 # ------------------------------------------------------------------ #
187 if not args.file and not args.url:
188 parser.error("需要指定 --file 或 --url,或使用子命令 rhythm-ingest / rhythm-check")
87 189
88 # Step 1: 上传 190 # Step 1: 上传
89 if args.file: 191 if args.file:
...@@ -113,5 +215,49 @@ def main(): ...@@ -113,5 +215,49 @@ def main():
113 print(f" record_ids: {result.get('record_ids', [])}") 215 print(f" record_ids: {result.get('record_ids', [])}")
114 216
115 217
218 def _call_rhythm_ingest(url: str, song_id: int, api_base: str) -> dict:
219 """调用曲入库 API"""
220 payload = json.dumps({"song_id": song_id, "url": url}).encode("utf-8")
221 req = urllib.request.Request(
222 f"{api_base.rstrip('/')}/api/v1/rhythm/ingest",
223 data=payload,
224 headers={"Content-Type": "application/json"},
225 method="POST",
226 )
227 try:
228 with urllib.request.urlopen(req, timeout=120) as resp:
229 return json.loads(resp.read().decode("utf-8"))
230 except urllib.error.HTTPError as exc:
231 error_body = exc.read().decode("utf-8", errors="replace")
232 print(f"API 请求失败 (HTTP {exc.code}): {error_body}")
233 sys.exit(1)
234 except urllib.error.URLError as exc:
235 print(f"API 请求失败: {exc.reason}")
236 print("请确认 API 服务已启动: uvicorn lyric_dedup_server.app:app --host 0.0.0.0 --port 8000")
237 sys.exit(1)
238
239
240 def _call_rhythm_check(url: str, top_k: int, api_base: str) -> dict:
241 """调用曲去重查询 API"""
242 payload = json.dumps({"url": url, "top_k": top_k}).encode("utf-8")
243 req = urllib.request.Request(
244 f"{api_base.rstrip('/')}/api/v1/rhythm/check",
245 data=payload,
246 headers={"Content-Type": "application/json"},
247 method="POST",
248 )
249 try:
250 with urllib.request.urlopen(req, timeout=120) as resp:
251 return json.loads(resp.read().decode("utf-8"))
252 except urllib.error.HTTPError as exc:
253 error_body = exc.read().decode("utf-8", errors="replace")
254 print(f"API 请求失败 (HTTP {exc.code}): {error_body}")
255 sys.exit(1)
256 except urllib.error.URLError as exc:
257 print(f"API 请求失败: {exc.reason}")
258 print("请确认 API 服务已启动: uvicorn lyric_dedup_server.app:app --host 0.0.0.0 --port 8000")
259 sys.exit(1)
260
261
116 if __name__ == "__main__": 262 if __name__ == "__main__":
117 main() 263 main()
......
1 {
2 "/Volumes/移动硬盘/composition_test/202930109_GA000376.wav": "ok",
3 "/Volumes/移动硬盘/composition_test/203334926_DL002070.wav": "ok",
4 "/Volumes/移动硬盘/composition_test/203450368_GA000544.wav": "ok",
5 "/Volumes/移动硬盘/composition_test/203461409_DL001808.wav": "ok",
6 "/Volumes/移动硬盘/composition_test/203599405_DL001021.wav": "ok",
7 "/Volumes/移动硬盘/composition_test/203642418_GA001691.wav": "ok",
8 "/Volumes/移动硬盘/composition_test/203648006_GA000003.wav": "ok",
9 "/Volumes/移动硬盘/composition_test/203676351_GA000200.wav": "ok",
10 "/Volumes/移动硬盘/composition_test/203764838_DL002119.wav": "ok",
11 "/Volumes/移动硬盘/composition_test/203795984_GA000828.wav": "ok",
12 "/Volumes/移动硬盘/composition_test/203844120_GA000067.wav": "ok",
13 "/Volumes/移动硬盘/composition_test/203866845_DL002604.wav": "ok",
14 "/Volumes/移动硬盘/composition_test/203897230_GA001181.wav": "ok",
15 "/Volumes/移动硬盘/composition_test/203899643_GA000559.wav": "ok",
16 "/Volumes/移动硬盘/composition_test/204120902_DL002115.wav": "ok",
17 "/Volumes/移动硬盘/composition_test/204194303_DL002166.wav": "ok",
18 "/Volumes/移动硬盘/composition_test/204194460_DL001888.wav": "ok",
19 "/Volumes/移动硬盘/composition_test/204194494_DL001414.wav": "ok",
20 "/Volumes/移动硬盘/composition_test/204194543_DL001654.wav": "ok",
21 "/Volumes/移动硬盘/composition_test/204194891_DL001805.wav": "ok",
22 "/Volumes/移动硬盘/composition_test/204194998_DL001069.wav": "ok",
23 "/Volumes/移动硬盘/composition_test/204195062_DL001685.wav": "ok",
24 "/Volumes/移动硬盘/composition_test/204195065_DL001686.wav": "ok",
25 "/Volumes/移动硬盘/composition_test/204195067_DL002215.wav": "ok",
26 "/Volumes/移动硬盘/composition_test/204229926_DL001993.wav": "ok",
27 "/Volumes/移动硬盘/composition_test/204425947_GA001690.wav": "ok",
28 "/Volumes/移动硬盘/composition_test/204444119_DL001775.wav": "ok",
29 "/Volumes/移动硬盘/composition_test/204457018_DL002107.wav": "ok",
30 "/Volumes/移动硬盘/composition_test/204457023_DL002183.wav": "ok",
31 "/Volumes/移动硬盘/composition_test/204540829_GA000177.wav": "ok",
32 "/Volumes/移动硬盘/composition_test/204540918_GA000071.wav": "ok",
33 "/Volumes/移动硬盘/composition_test/204541109_DL001970.wav": "ok",
34 "/Volumes/移动硬盘/composition_test/204586870_DL000981.wav": "ok",
35 "/Volumes/移动硬盘/composition_test/205000888_GA001717.wav": "ok",
36 "/Volumes/移动硬盘/composition_test/205006910_DL000850.wav": "ok",
37 "/Volumes/移动硬盘/composition_test/206058550_DL000985.wav": "ok",
38 "/Volumes/移动硬盘/composition_test/206349056_DL002167.wav": "ok",
39 "/Volumes/移动硬盘/composition_test/206349945_DL002113.wav": "ok",
40 "/Volumes/移动硬盘/composition_test/206711872_DL001965.wav": "ok",
41 "/Volumes/移动硬盘/composition_test/206974764_DL000931.wav": "ok",
42 "/Volumes/移动硬盘/composition_test/206974765_DL000932.wav": "ok",
43 "/Volumes/移动硬盘/composition_test/206974766_DL000933.wav": "ok",
44 "/Volumes/移动硬盘/composition_test/206974775_DL001893.wav": "ok",
45 "/Volumes/移动硬盘/composition_test/206974779_DL000934.wav": "ok",
46 "/Volumes/移动硬盘/composition_test/206976058_DL001408.wav": "ok",
47 "/Volumes/移动硬盘/composition_test/209166597_GA001386.wav": "ok",
48 "/Volumes/移动硬盘/composition_test/209167720_GA001358.wav": "ok",
49 "/Volumes/移动硬盘/composition_test/209361104_DL001611.wav": "ok",
50 "/Volumes/移动硬盘/composition_test/210349676_DL002228.wav": "ok",
51 "/Volumes/移动硬盘/composition_test/210351016_DL001954.wav": "ok",
52 "/Volumes/移动硬盘/composition_test/210352134_DL001953.wav": "ok",
53 "/Volumes/移动硬盘/composition_test/210985464_DL001709.wav": "ok",
54 "/Volumes/移动硬盘/composition_test/210985511_DL001894.wav": "ok",
55 "/Volumes/移动硬盘/composition_test/210985512_DL001896.wav": "ok",
56 "/Volumes/移动硬盘/composition_test/210985513_DL000935.wav": "ok",
57 "/Volumes/移动硬盘/composition_test/210985514_DL001895.wav": "ok",
58 "/Volumes/移动硬盘/composition_test/210985515_DL000936.wav": "ok",
59 "/Volumes/移动硬盘/composition_test/210985525_DL002226.wav": "ok",
60 "/Volumes/移动硬盘/composition_test/210985649_DL000926.wav": "ok",
61 "/Volumes/移动硬盘/composition_test/211885778_DL001891.wav": "ok",
62 "/Volumes/移动硬盘/composition_test/211925190_DL001085.wav": "ok",
63 "/Volumes/移动硬盘/composition_test/211932514_DL001320.wav": "ok",
64 "/Volumes/移动硬盘/composition_test/212019324_GA000768.wav": "ok",
65 "/Volumes/移动硬盘/composition_test/212030195_GA000576.wav": "ok",
66 "/Volumes/移动硬盘/composition_test/212034152_DL001024.wav": "ok",
67 "/Volumes/移动硬盘/composition_test/212037852_DL000851.wav": "ok",
68 "/Volumes/移动硬盘/composition_test/212071825_DL000432.wav": "ok",
69 "/Volumes/移动硬盘/composition_test/212081995_DL001359.wav": "ok",
70 "/Volumes/移动硬盘/composition_test/212081996_DL001357.wav": "ok",
71 "/Volumes/移动硬盘/composition_test/212081997_DL001358.wav": "ok",
72 "/Volumes/移动硬盘/composition_test/212081998_DL001361.wav": "ok",
73 "/Volumes/移动硬盘/composition_test/212081999_DL001360.wav": "ok",
74 "/Volumes/移动硬盘/composition_test/212085899_DL001086.wav": "ok",
75 "/Volumes/移动硬盘/composition_test/212440713_GA000045.wav": "ok",
76 "/Volumes/移动硬盘/composition_test/212447042_DL000848.wav": "ok",
77 "/Volumes/移动硬盘/composition_test/212447124_DL001103.wav": "ok",
78 "/Volumes/移动硬盘/composition_test/212597558_DL001581.wav": "ok",
79 "/Volumes/移动硬盘/composition_test/212611861_DL002099.wav": "ok",
80 "/Volumes/移动硬盘/composition_test/212612210_DL002727.wav": "ok",
81 "/Volumes/移动硬盘/composition_test/212612932_GA001913.wav": "ok",
82 "/Volumes/移动硬盘/composition_test/212665934_GA000729.wav": "ok",
83 "/Volumes/移动硬盘/composition_test/212665935_GA000798.wav": "ok",
84 "/Volumes/移动硬盘/composition_test/212668644_DL002087.wav": "ok",
85 "/Volumes/移动硬盘/composition_test/212668672_DL001671.wav": "ok",
86 "/Volumes/移动硬盘/composition_test/212668706_DL001717.wav": "ok",
87 "/Volumes/移动硬盘/composition_test/212668732_DL002728.wav": "ok",
88 "/Volumes/移动硬盘/composition_test/212668737_DL002100.wav": "ok",
89 "/Volumes/移动硬盘/composition_test/212675255_GA000082.wav": "ok",
90 "/Volumes/移动硬盘/composition_test/212675315_GA002000.wav": "ok",
91 "/Volumes/移动硬盘/composition_test/212675363_GA000180.wav": "ok",
92 "/Volumes/移动硬盘/composition_test/212675414_DL001964.wav": "ok",
93 "/Volumes/移动硬盘/composition_test/212676914_GA000530.wav": "ok",
94 "/Volumes/移动硬盘/composition_test/212736695_DL001963.wav": "ok",
95 "/Volumes/移动硬盘/composition_test/212782387_GA000884.wav": "ok",
96 "/Volumes/移动硬盘/composition_test/212782388_GA000885.wav": "ok",
97 "/Volumes/移动硬盘/composition_test/212782389_GA000886.wav": "ok",
98 "/Volumes/移动硬盘/composition_test/212782390_GA000887.wav": "ok",
99 "/Volumes/移动硬盘/composition_test/212782391_GA000888.wav": "ok",
100 "/Volumes/移动硬盘/composition_test/212784198_GA001530.wav": "ok",
101 "/Volumes/移动硬盘/composition_test/212814258_DL002106.wav": "ok"
102 }
...\ No newline at end of file ...\ No newline at end of file
1 # 第一类:数字信号变换样本(必须有)
2
3 这是你目前已经在做的。
4
5 ## Pitch Shift
6
7 ```
8 原曲
9 +1半音
10 +2半音
11 -1半音
12 -2半音
13 ```
14
15 验证:
16
17 ```
18 转调鲁棒性
19 ```
20
21 ------
22
23 ## Tempo Shift
24
25 ```
26 0.9x
27 1.1x
28 1.2x
29 ```
30
31 验证:
32
33 ```
34 速度鲁棒性
35 ```
36
37 ------
38
39 ## EQ变换
40
41 ```
42 低通
43 高通
44 均衡器
45 ```
46
47 验证:
48
49 ```
50 音色鲁棒性
51 ```
52
53 ------
54
55 ## 压缩编码
56
57 ```
58 wav
59 320k mp3
60 128k mp3
61 aac
62 ```
63
64 验证:
65
66 ```
67 编码鲁棒性
68 ```
69
70 ------
71
72 # 第二类:真实版本样本(最重要)
73
74 这一类比DSP测试重要得多。
75
76 ------
77
78 ## 原唱 vs 伴奏版
79
80 例如:
81
82 ```
83 晴天原唱
84 晴天伴奏
85 ```
86
87 期望:
88
89 ```
90 重复
91 ```
92
93 ------
94
95 ## 原唱 vs Live版
96
97 例如:
98
99 ```
100 晴天录音室版
101 晴天Live版
102 ```
103
104 期望:
105
106 ```
107 重复
108 ```
109
110 ------
111
112 ## 原唱 vs 翻唱版
113
114 例如:
115
116 ```
117 周杰伦版
118 路人翻唱版
119 ```
120
121 期望:
122
123 ```
124 重复
125 ```
126
127 ------
128
129 ## 原唱 vs 钢琴版
130
131 例如:
132
133 ```
134 晴天
135
136 纯钢琴演奏
137 ```
138
139 期望:
140
141 ```
142 重复
143 ```
144
145 ------
146
147 ## 原唱 vs 交响乐版
148
149 例如:
150
151 ```
152 晴天
153
154 交响乐改编
155 ```
156
157 期望:
158
159 ```
160 重复
161 ```
162
163 ------
164
165 这一类最能体现:
166
167 ```
168 作曲结构提取能力
169 ```
170
171 ------
172
173 # 第三类:困难正样本(重点)
174
175 这是很多系统最后翻车的地方。
176
177 ------
178
179 ## 同曲不同调
180
181 例如:
182
183 ```
184 原版 C调
185
186 女声版 D调
187 ```
188
189 ------
190
191 ## 同曲不同速度
192
193 例如:
194
195 ```
196 原版 120 BPM
197
198 Live版 105 BPM
199 ```
200
201 ------
202
203 ## 同曲不同段落长度
204
205 例如:
206
207 ```
208 前奏删减版
209 副歌重复版
210 ```
211
212 ------
213
214 ## 只保留副歌
215
216 例如:
217
218 ```
219 完整版
220
221 短视频版
222 ```
223
224 ------
225
226 这一类特别适合验证:
227
228 ```
229 DTW
230 ```
231
232 是否真的有效。
233
234 ------
235
236 # 第四类:困难负样本(非常重要)
237
238 如果不做这一类,
239
240 ```
241 Precision=1
242 ```
243
244 没有意义。
245
246 ------
247
248 ## 同和弦进行
249
250 例如:
251
252 ```
253 C G Am F
254 ```
255
256 套路歌。
257
258 ------
259
260 现实里很多歌:
261
262 ```
263 和弦一样
264 ```
265
266 但曲不同。
267
268 ------
269
270 这是你们最大的风险来源。
271
272 ------
273
274 ## 同风格歌曲
275
276 例如:
277
278 ```
279 周杰伦A歌
280 周杰伦B歌
281 ```
282
283 ------
284
285 编曲很像。
286
287 ------
288
289 ## 同歌手歌曲
290
291 例如:
292
293 ```
294 Taylor Swift A
295 Taylor Swift B
296 ```
297
298 ------
299
300 音色很像。
301
302 ------
303
304 ## 同伴奏模板
305
306 例如:
307
308 ```
309 抖音BGM A
310 抖音BGM B
311 ```
312
313 ------
314
315 这些非常容易误召回。
...\ No newline at end of file ...\ No newline at end of file
1 # 基于 Chromagram Sequence 的歌曲作曲结构去重系统 V1 实施方案
2
3 # 1 项目目标
4
5 ## 1.1 项目背景
6
7 当前音乐去重体系划分为三个独立模块:
8
9 ### 歌词去重
10
11 负责识别:
12
13 * 歌词重复
14 * 改编歌词
15 * 部分重复歌词
16
17 当前已采用:
18
19 * Jaccard Similarity
20 * PostgreSQL pg_trgm
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 > 作曲结构(Composition)
52
53 包括:
54
55 * 音级关系
56 * 和声结构
57 * 曲式演化
58 * 音乐时间结构
59
60 不关注:
61
62 * 歌手
63 * 音色
64 * 乐器品牌
65 * 录音设备
66 * 混音质量
67 * 母带版本
68
69 ---
70
71 ## 1.2 项目目标
72
73 ### V1 适用范围
74
75 **仅处理完整版 vs 完整版的对比。**
76
77 识别:
78
79 ```text
80 晴天(原唱)
81 晴天(伴奏版)
82 晴天(钢琴版)
83 晴天(交响乐版)
84 晴天(翻唱版)
85 ```
86
87 为同一作品。
88
89 区分:
90
91 ```text
92 晴天
93 七里香
94 稻香
95 ```
96
97 等不同作品。
98
99 ### V1 不覆盖的场景
100
101 * 全曲 vs 片段(属于 V2+ 规划)
102 * 片段 vs 片段(属于 V2+ 规划)
103
104 ---
105
106 # 2 总体技术路线
107
108 系统流程:
109
110 ```text
111 音频
112
113 Chromagram提取
114
115 时间归一化(12×512)
116
117 Chromagram Sequence
118
119 PostgreSQL + pgvector存储
120
121 Cosine Similarity召回
122
123 重复判定
124 ```
125
126 ---
127
128 # 3 技术选型依据
129
130 ## 3.1 为什么不做人声分离
131
132 人声分离主要用于:
133
134 ```text
135 歌手分析
136 主唱分析
137 音色分析
138 ```
139
140 而本项目关注:
141
142 ```text
143 作曲结构
144 ```
145
146 Chromagram本身对乐器和人声具有一定鲁棒性。
147
148 因此V1阶段不引入额外模型。
149
150 降低系统复杂度。
151
152 ---
153
154 ## 3.2 为什么不做和弦识别
155
156 和弦识别属于:
157
158 ```text
159 音频
160
161 和弦推理
162
163 离散标签
164 ```
165
166 过程。
167
168 每增加一次推理:
169
170 ```text
171 误差增加
172 信息损失增加
173 ```
174
175 跨版本研究表明,同一首歌不同录音版本的和弦识别序列一致率仅约 64%,
176 不同版本(乐队翻唱、失真吉他等)会引入不一致的和弦检测错误,
177 导致文本相似度不稳定。
178
179 而曲去重更关注:
180
181 ```text
182 原始结构信息
183 ```
184
185 因此直接使用Chromagram。
186
187 ---
188
189 ## 3.3 为什么选择Chromagram
190
191 Chromagram是音乐信息检索(MIR)领域最经典的作曲特征之一。
192
193 其核心思想:
194
195 将所有音高映射到:
196
197 ```text
198 C
199 C#
200 D
201 D#
202 E
203 F
204 F#
205 G
206 G#
207 A
208 A#
209 B
210 ```
211
212 12个音级。
213
214 因此:
215
216 ```text
217 钢琴版
218 交响乐版
219 翻唱版
220 伴奏版
221 ```
222
223 即使音色完全不同,
224
225 仍然会保留较高相似的色度结构。
226
227 ---
228
229 # 4 特征提取
230
231 ## 4.1 技术选型
232
233 Python
234
235 librosa
236
237 核心接口:
238
239 ```python
240 librosa.feature.chroma_cqt()
241 ```
242
243 ---
244
245 ## 4.2 音频预处理
246
247 统一格式:
248
249 ```text
250 采样率:
251 22050Hz
252
253 声道:
254 Mono
255
256 格式:
257 WAV
258 ```
259
260 转换工具:
261
262 ffmpeg
263
264 ---
265
266 ## 4.3 Chromagram生成
267
268 输入:
269
270 ```text
271 audio.wav
272 ```
273
274 输出:
275
276 ```text
277 12 × T
278 ```
279
280 矩阵。
281
282 示例:
283
284 ```text
285 C
286 C#
287 D
288 ...
289 B
290 ```
291
292 对应:
293
294 ```text
295 时间帧1
296 时间帧2
297 时间帧3
298 ...
299 时间帧T
300 ```
301
302 ---
303
304 # 5 时间归一化
305
306 ## 5.1 问题
307
308 不同歌曲长度不同:
309
310 ```text
311 3分钟歌曲
312 5分钟歌曲
313 8分钟歌曲
314 ```
315
316 得到:
317
318 ```text
319 12×2000
320
321 12×3500
322
323 12×6000
324 ```
325
326 无法直接比较。
327
328 ---
329
330 ## 5.2 解决方案
331
332 统一重采样:
333
334 ```text
335 12 × 512
336 ```
337
338 实现:
339
340 ```python
341 from scipy.signal import resample
342
343 chroma_fixed = resample(
344 chroma,
345 512,
346 axis=1
347 )
348 ```
349
350 帧数选择依据:
351
352 * 128 帧:每帧约 1.8 秒(4 分钟歌曲),时间分辨率过粗
353 * 512 帧:每帧约 0.47 秒,结构细节保留充分
354 * 512 帧展开后 6144 维,pgvector 性能可接受
355
356 ---
357
358 ## 5.3 设计思想
359
360 采用:
361
362 ```text
363 相对时间轴
364 ```
365
366 而非:
367
368 ```text
369 绝对时间轴
370 ```
371
372 即:
373
374 ```text
375 0%
376 10%
377 20%
378 ...
379 100%
380 ```
381
382 描述歌曲结构。
383
384 保证:
385
386 ```text
387 原版
388 钢琴版
389 翻唱版
390 ```
391
392 长度不同仍可比较。
393
394 注:V1 仅处理完整版对比,Live 版等结构变体的鲁棒性在 V2 中通过 DTW 精排优化。
395
396 ---
397
398 # 6 曲特征表示
399
400 最终统一表示为:
401
402 ```text
403 12 × 512
404 ```
405
406 矩阵。
407
408 即:
409
410 ```text
411 6144维特征
412 ```
413
414 展开后:
415
416 ```python
417 feature = chroma_fixed.flatten()
418 ```
419
420 得到:
421
422 ```text
423 6144维向量
424 ```
425
426 作为:
427
428 ```text
429 Composition Fingerprint
430 ```
431
432 ---
433
434 # 7 数据库存储设计
435
436 ## 7.1 表结构
437
438 ```sql
439 CREATE TABLE composition_feature
440 (
441 id BIGSERIAL PRIMARY KEY,
442 song_id BIGINT NOT NULL,
443 feature_vector vector(6144),
444 created_time TIMESTAMP DEFAULT NOW()
445 );
446 ```
447
448 ## 7.2 字段说明
449
450 feature_vector
451
452 存储:
453
454 ```text
455 6144维Chromagram特征(pgvector vector类型)
456 ```
457
458 使用 pgvector 的 `vector` 类型而非 `FLOAT4[]`
459 直接支持 Cosine 相似度索引和检索。
460
461 ---
462
463 ## 7.3 索引设计
464
465 使用 pgvector:
466
467 ```sql
468 CREATE INDEX idx_composition_feature_vector
469 ON composition_feature
470 USING ivfflat (feature_vector vector_cosine_ops)
471 WITH (lists = 100);
472 ```
473
474 支持:
475
476 ```text
477 Cosine Similarity
478 ```
479
480 检索。
481
482 ---
483
484 # 8 入库流程
485
486 ## Step1
487
488 上传音频
489
490 ---
491
492 ## Step2
493
494 音频标准化
495
496 ```text
497 22050Hz
498 Mono
499 ```
500
501 ---
502
503 ## Step3
504
505 提取Chromagram
506
507 ```text
508 12 × T
509 ```
510
511 ---
512
513 ## Step4
514
515 重采样
516
517 ```text
518 12 × 512
519 ```
520
521 ---
522
523 ## Step5
524
525 展开
526
527 ```text
528 6144维
529 ```
530
531 ---
532
533 ## Step6
534
535 写入数据库
536
537 ---
538
539 # 9 查询流程
540
541 ## Step1
542
543 新歌曲进入系统
544
545 ---
546
547 ## Step2
548
549 重复执行:
550
551 ```text
552 音频
553
554 Chromagram
555
556 12×512
557
558 6144维
559 ```
560
561 ---
562
563 ## Step3
564
565 Cosine Similarity检索
566
567 返回:
568
569 ```text
570 Top100
571 ```
572
573 候选。
574
575 ---
576
577 ## Step4
578
579 重复判定
580
581 根据阈值判断:
582
583 ```text
584 重复
585
586 疑似重复
587
588 非重复
589 ```
590
591 ---
592
593 # 10 相似度计算
594
595 采用:
596
597 Cosine Similarity
598
599 公式:
600
601 ```text
602 sim(A,B)
603 =
604 (A·B)
605 /
606 (|A||B|)
607 ```
608
609 结果:
610
611 ```text
612 0~1
613 ```
614
615 ---
616
617 建议阈值(通过实验调整):
618
619 ```text
620 >0.95
621 ```
622
623 高度重复。
624
625 ---
626
627 ```text
628 0.85~0.95
629 ```
630
631 疑似重复。
632
633 ---
634
635 ```text
636 <0.85
637 ```
638
639 非重复。
640
641 最终通过实验调整。
642
643 ---
644
645 # 11 V1验证目标
646
647 验证以下问题:
648
649 ## 问题1
650
651 同一首歌不同版本:
652
653 ```text
654 原唱
655 伴奏版
656 钢琴版
657 翻唱版
658 ```
659
660 是否具有较高相似度。
661
662 ---
663
664 ## 问题2
665
666 不同歌曲之间:
667
668 ```text
669 晴天
670 七里香
671 稻香
672 ```
673
674 是否能够有效区分。
675
676 ---
677
678 ## 问题3
679
680 Chromagram Sequence是否具备作曲结构表达能力。
681
682 ---
683
684 ## 问题4
685
686 转调翻唱对相似度的影响程度,
687 是否需要引入 Key 归一化。
688
689 ---
690
691 # 12 V2升级路线
692
693 ### 片段 vs 全曲 / 片段 vs 片段
694
695 引入:
696
697 ```text
698 帧级 CENS 特征 + FAISS 倒排索引
699 ```
700
701 支持:
702
703 * 全曲 vs 片段去重
704 * 片段 vs 片段去重
705
706 ### DTW 精排
707
708 保持:
709
710 ```text
711 12 × 512
712 ```
713
714 数据结构不变。
715
716 新增:
717
718 ```text
719 DTW
720 ```
721
722 作为精排算法。
723
724 流程:
725
726 ```text
727 Cosine召回
728
729 Top100
730
731 DTW精排
732
733 Top10
734 ```
735
736 无需修改数据库。
737
738 ---
739
740 # 13 V3升级路线
741
742 引入:
743
744 ```text
745 Faiss
746 ```
747
748 或:
749
750 ```text
751 Milvus
752 ```
753
754 实现百万级曲库检索。
755
756 同时保留:
757
758 ```text
759 DTW精排
760 ```
761
762 机制。
763
764 ---
765
766 # 14 最终方案总结
767
768 V1采用:
769
770 ```text
771 音频
772 → Chromagram
773 → 时间归一化(12×512)
774 → 6144维作曲指纹
775 → pgvector
776 → Cosine Similarity
777 → 重复判定
778 ```
779
780 方案特点:
781
782 * 仅处理完整版 vs 完整版
783 * 不依赖声纹模型
784 * 不依赖人声分离
785 * 不依赖和弦识别
786 * 保留完整时间结构
787 * 与录音去重模块职责完全分离
788 * 可直接升级至DTW方案
789 * 避免后续数据结构重构
790 * 工程实现复杂度低
791 * 适合作为曲去重系统V1版本
1 # 歌曲作曲结构去重系统 V2 优化方案
2
3 ## 1 V1 评估基线
4
5 测试集:20 首歌 × 5 种变换 + 20 条负样本,共 120 条。
6
7 | 指标 | V1 结果 |
8 |---|---|
9 | Accuracy | 0.6917 |
10 | Precision | 1.0000 |
11 | Recall | 0.6300 |
12 | F1 | 0.7730 |
13 | 假阳性(FP) | 0 |
14 | 漏判(FN) | 37 |
15
16 按变换类型分解:
17
18 | 变换类型 | V1 Accuracy | 说明 |
19 |---|---|---|
20 | lowpass(低通滤波)| 1.00 | 音色变化,CENS 完全应对 |
21 | negative(不同曲)| 1.00 | 假阳性为零,区分度良好 |
22 | tempo_fast(加速 10%)| 0.80 | 时间压缩,部分吸收 |
23 | pitch_down(降半音)| 0.50 | 转调鲁棒性不足 |
24 | tempo_slow(减速 10%)| 0.45 | 时间拉伸,信息密度变化大 |
25 | pitch_up(升半音)| 0.40 | 转调鲁棒性最差 |
26
27 **核心结论:**
28
29 - 精确率满分,系统不会误判不同歌曲,可以放心部署
30 - 主要缺陷集中在转调(pitch)和减速(tempo_slow),合计贡献了 37 条漏判中的约 30 条
31 - V1 的主音对齐(`np.roll` 到能量最大音级)对连续半音偏移效果有限
32
33 ---
34
35 ## 2 问题根因分析
36
37 ### 2.1 转调(pitch_up / pitch_down)失败的根本原因
38
39 ffmpeg `asetrate` 变调是连续音高偏移(非整数半音),而 CENS 的 12 个 bin 是离散的。`np.roll` 只能对齐整数个半音,偏移量不是整数半音时无法对齐,导致相似度显著下降(失败样本 top1 相似度集中在 0.78~0.90,未过阈值)。
40
41 ### 2.2 减速(tempo_slow)失败的根本原因
42
43 时间拉伸后每帧重复内容增多,resample 到 128 帧时对应的音乐内容在时间轴上与原版错位。问题本质是**时间对齐失败**,而非特征表达力不足——多尺度拼接解决的是后者,对前者无效。唯一有效的解法是允许时间轴弹性匹配的 DTW。
44
45 ---
46
47 ## 3 V2 优化方案
48
49 ### 3.1 转调鲁棒性:12 路循环对齐
50
51 **方案:** 查询时对特征矩阵穷举 12 种半音偏移(`np.roll(chroma, -k, axis=0)`,k=0..11),取与库中向量相似度最高的那一路作为最终相似度。
52
53 **实现位置:** `service.py``query()` 方法,特征提取本身不变。
54
55 **代价:** 每次查询变为 12 次向量检索,pgvector 单次检索延迟约 10ms 级别,12 路合计约 100ms,可接受。
56
57 **预期提升:** pitch_up / pitch_down accuracy 从 0.4/0.5 提升至接近 1.0。
58
59 ```python
60 # 伪代码示意
61 best: dict[int, float] = {}
62 for shift in range(12):
63 shifted = np.roll(chroma, -shift, axis=0).flatten()
64 for song_id, sim in query_vector(shifted, top_k):
65 best[song_id] = max(best.get(song_id, -1.0), sim)
66 ```
67
68 ---
69
70 ### 3.2 速度变化鲁棒性:DTW 精排
71
72 **方案:** Cosine 召回 Top-K 后,用 DTW 对原始 `12×128` 矩阵做精排,替换纯相似度阈值判断。
73
74 **实现:**
75
76 ```python
77 from scipy.spatial.distance import cdist
78
79 def dtw_distance(chroma_a: np.ndarray, chroma_b: np.ndarray) -> float:
80 # chroma shape: (12, 128),按列(时间帧)计算帧间欧氏距离矩阵,再做 DTW
81 ...
82 ```
83
84 **适用场景:** 对 tempo 变化(±20% 以内)效果显著,对大幅转调效果有限(需配合 3.1)。
85
86 **部署策略:** 不修改数据库结构,仅在召回后增加一个 Python 计算步骤。
87
88 **预期提升:** tempo_slow accuracy 从 0.45 提升至 0.85 以上。
89
90 ---
91
92 ### 3.3 调性估计升级(可选)
93
94 **方案:** 用 Krumhansl-Schmuckler 调性轮廓替代"能量最大音级"作为主音估计,使 `np.roll` 对齐更精准。
95
96 **评估方式:** 先跑 3.1(穷举 12 路),若效果已满足需求则跳过本项。
97
98 ---
99
100 ## 4 优先级与实施顺序
101
102 | 优先级 | 方案 | 改动范围 | 预期收益 |
103 |---|---|---|---|
104 | P0 | 3.1 12 路循环对齐 | `service.py` query 逻辑 | pitch 问题基本解决 |
105 | P1 | 3.2 DTW 精排 | 新增精排函数,不改数据库 | tempo 问题显著改善 |
106 | P2 | 3.3 调性估计升级 | `extractor.py` | 按需实施 |
107
108 P0 和 P1 不需要重新入库,改动最小,建议优先实施并重新跑评估基线后再决定是否做 P2。
109
110 ---
111
112 ## 5 V2 目标指标
113
114 | 变换类型 | V1 | V2 目标 |
115 |---|---|---|
116 | lowpass | 1.00 | 1.00 |
117 | negative | 1.00 | 1.00 |
118 | tempo_fast | 0.80 | ≥ 0.95 |
119 | pitch_down | 0.50 | ≥ 0.95 |
120 | tempo_slow | 0.45 | ≥ 0.85 |
121 | pitch_up | 0.40 | ≥ 0.95 |
122 | **整体 Recall** | **0.63** | **≥ 0.92** |
123 | **整体 F1** | **0.773** | **≥ 0.958** |
124
125 Precision 维持 1.0(不引入假阳性)。