update
Showing
45 changed files
with
2683 additions
and
34 deletions
| ... | @@ -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 | ... | ... |
lyric_dedup_server/_webui.py
0 → 100644
This diff is collapsed.
Click to expand it.
This diff is collapsed.
Click to expand it.
| ... | @@ -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 | ... | ... |
results/aliyun_dna_eval.csv
0 → 100644
This diff is collapsed.
Click to expand it.
results/aliyun_dna_eval.summary.json
0 → 100644
| 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 |
scripts/acrcloud/_test_acr_single.py
0 → 100644
| 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 |
This file is too large to display.
This file is too large to display.
This file is too large to display.
This file is too large to display.
This file is too large to display.
This file is too large to display.
scripts/acrcloud/evaluate_acrcloud.py
0 → 100644
This diff is collapsed.
Click to expand it.
This diff is collapsed.
Click to expand it.
scripts/acrcloud/upload_acrcloud.py
0 → 100644
This diff is collapsed.
Click to expand it.
scripts/aliyun_dna/docs/CancelDNAJob.md
0 → 100644
| 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://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 |
scripts/aliyun_dna/docs/DeleteDNAFiles.md
0 → 100644
| 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://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 |
scripts/aliyun_dna/docs/ListDNAFiles.md
0 → 100644
| 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://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 |
scripts/aliyun_dna/docs/QueryDNAJobList.md
0 → 100644
| 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://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 |
scripts/aliyun_dna/docs/SubmitDNAJob.md
0 → 100644
| 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://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 |
scripts/aliyun_dna/docs/query_dna_library.md
0 → 100644
| 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://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 |
scripts/aliyun_dna/evaluate_aliyun_dna.py
0 → 100644
This diff is collapsed.
Click to expand it.
scripts/aliyun_dna/query_dna_jobs.py
0 → 100644
| 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() |
scripts/aliyun_dna/upload_dna_library.py
0 → 100644
This diff is collapsed.
Click to expand it.
| ... | @@ -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() | ... | ... |
scripts/local/migrate_add_audio_url.sql
0 → 100644
scripts/local/migrate_add_chroma_full.sql
0 → 100644
| 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() | ... | ... |
upload_acrcloud_state.json
0 → 100644
| 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 |
upload_dna_state.json
0 → 100644
This diff is collapsed.
Click to expand it.
性能瓶颈详细分析.md
0 → 100644
This diff is collapsed.
Click to expand it.
曲去重测试样本类型.md
0 → 100644
| 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 |
歌曲作曲结构去重系统V1实施方案.md
0 → 100644
| 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版本 |
歌曲作曲结构去重系统V2优化方案.md
0 → 100644
| 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(不引入假阳性)。 |
-
Please register or sign in to post a comment