更新从数据库查询歌曲上传dna库,修正测试集生成逻辑
Showing
3 changed files
with
139 additions
and
23 deletions
| ... | @@ -333,11 +333,9 @@ def main() -> None: | ... | @@ -333,11 +333,9 @@ def main() -> None: |
| 333 | "expected": "duplicate", | 333 | "expected": "duplicate", |
| 334 | }) | 334 | }) |
| 335 | 335 | ||
| 336 | # ---- 不应去重(expected=not_duplicate):负样本 + 破坏指纹的变换 ---- | 336 | # ---- 不应去重(expected=not_duplicate):composition_drop 原始文件 ---- |
| 337 | for wav in _tqdm(negative_selected, desc="生成负样本变体", total=len(negative_selected)): | 337 | for wav in _tqdm(negative_selected, desc="生成负样本原始", total=len(negative_selected)): |
| 338 | song_id = _song_id(wav) | 338 | song_id = _song_id(wav) |
| 339 | |||
| 340 | # 负样本原始 | ||
| 341 | query_rows.append({ | 339 | query_rows.append({ |
| 342 | "song_id": song_id, | 340 | "song_id": song_id, |
| 343 | "audio_path": str(wav.resolve()), | 341 | "audio_path": str(wav.resolve()), |
| ... | @@ -347,8 +345,11 @@ def main() -> None: | ... | @@ -347,8 +345,11 @@ def main() -> None: |
| 347 | "expected": "not_duplicate", | 345 | "expected": "not_duplicate", |
| 348 | }) | 346 | }) |
| 349 | 347 | ||
| 350 | if not args.negative_variants: | 348 | # ---- 不应去重(expected=not_duplicate):参照歌 + 破坏指纹的变换 ---- |
| 351 | continue | 349 | # 用已入库的参照歌做破坏性变换,测试 ACRCloud 在何种变换幅度下无法识别 |
| 350 | if args.negative_variants: | ||
| 351 | for wav in _tqdm(selected, desc="生成参照歌破坏性变体(负样本)", total=len(selected)): | ||
| 352 | song_id = _song_id(wav) | ||
| 352 | 353 | ||
| 353 | for variant_name, af in NEGATIVE_VARIANTS: | 354 | for variant_name, af in NEGATIVE_VARIANTS: |
| 354 | dst = variants_dir / f"{song_id}_{variant_name}.wav" | 355 | dst = variants_dir / f"{song_id}_{variant_name}.wav" |
| ... | @@ -371,21 +372,21 @@ def main() -> None: | ... | @@ -371,21 +372,21 @@ def main() -> None: |
| 371 | else: | 372 | else: |
| 372 | ok = _ffmpeg_variant(wav, dst, af) | 373 | ok = _ffmpeg_variant(wav, dst, af) |
| 373 | if not ok: | 374 | if not ok: |
| 374 | logger.warning("负样本变换失败,跳过: %s %s", wav.name, variant_name) | 375 | logger.warning("破坏性变换失败,跳过: %s %s", wav.name, variant_name) |
| 375 | continue | 376 | continue |
| 376 | query_rows.append({ | 377 | query_rows.append({ |
| 377 | "song_id": song_id, | 378 | "song_id": song_id, |
| 378 | "audio_path": str(dst.resolve()), | 379 | "audio_path": str(dst.resolve()), |
| 379 | "variant": variant_name, | 380 | "variant": variant_name, |
| 380 | "sample_class": "negative", | 381 | "sample_class": "negative", |
| 381 | "expected_song_id": "", | 382 | "expected_song_id": song_id, |
| 382 | "expected": "not_duplicate", | 383 | "expected": "not_duplicate", |
| 383 | }) | 384 | }) |
| 384 | 385 | ||
| 385 | # 片段拼接负样本:每个样本从几首不同歌曲各取一段拼接 | 386 | # 片段拼接负样本:从参照歌中各取一段拼接,混合内容不应归属任何单首歌 |
| 386 | if args.negative_variants and len(negative_selected) >= SPLICE_SONGS_PER_SAMPLE: | 387 | if len(selected) >= SPLICE_SONGS_PER_SAMPLE: |
| 387 | for i in _tqdm(range(len(negative_selected)), desc="生成片段拼接负样本", total=len(negative_selected)): | 388 | for i in _tqdm(range(len(selected)), desc="生成片段拼接负样本", total=len(selected)): |
| 388 | srcs = random.sample(negative_selected, SPLICE_SONGS_PER_SAMPLE) | 389 | srcs = random.sample(selected, SPLICE_SONGS_PER_SAMPLE) |
| 389 | splice_id = f"splice_{i:04d}" | 390 | splice_id = f"splice_{i:04d}" |
| 390 | dst = variants_dir / f"{splice_id}_negative_splice.wav" | 391 | dst = variants_dir / f"{splice_id}_negative_splice.wav" |
| 391 | ok = _ffmpeg_splice(srcs, dst) | 392 | ok = _ffmpeg_splice(srcs, dst) | ... | ... |
| ... | @@ -3,7 +3,7 @@ | ... | @@ -3,7 +3,7 @@ |
| 3 | """批量上传音频到阿里云 OSS 并提交 DNA 入库作业。 | 3 | """批量上传音频到阿里云 OSS 并提交 DNA 入库作业。 |
| 4 | 4 | ||
| 5 | 流程: | 5 | 流程: |
| 6 | 1. 扫描音频文件 | 6 | 1. 扫描音频文件(来源:本地目录 / CSV / 数据库) |
| 7 | 2. 降采样(可选,默认 16kHz 单声道) | 7 | 2. 降采样(可选,默认 16kHz 单声道) |
| 8 | 3. 上传到 OSS | 8 | 3. 上传到 OSS |
| 9 | 4. 调用 SubmitDNAJob 提交入库作业(SaveType=save, MediaType=audio) | 9 | 4. 调用 SubmitDNAJob 提交入库作业(SaveType=save, MediaType=audio) |
| ... | @@ -21,6 +21,13 @@ python scripts/aliyun_dna/upload_dna_library.py --retry-failed | ... | @@ -21,6 +21,13 @@ python scripts/aliyun_dna/upload_dna_library.py --retry-failed |
| 21 | 21 | ||
| 22 | # 不降采样(上传原始文件) | 22 | # 不降采样(上传原始文件) |
| 23 | python scripts/aliyun_dna/upload_dna_library.py --ref-csv ... --no-resample | 23 | python scripts/aliyun_dna/upload_dna_library.py --ref-csv ... --no-resample |
| 24 | |||
| 25 | # 从数据库读取(从指定表的 id/url 列拉取音频 URL 后下载入库) | ||
| 26 | python scripts/aliyun_dna/upload_dna_library.py \ | ||
| 27 | --db-table crawler_kugou_songs \ | ||
| 28 | --db-id-column id \ | ||
| 29 | --db-url-column url \ | ||
| 30 | --state-file upload_dna_state.json | ||
| 24 | """ | 31 | """ |
| 25 | 32 | ||
| 26 | import argparse | 33 | import argparse |
| ... | @@ -31,6 +38,8 @@ import os | ... | @@ -31,6 +38,8 @@ import os |
| 31 | import sys | 38 | import sys |
| 32 | import tempfile | 39 | import tempfile |
| 33 | import time | 40 | import time |
| 41 | import urllib.request | ||
| 42 | import urllib.error | ||
| 34 | from pathlib import Path | 43 | from pathlib import Path |
| 35 | 44 | ||
| 36 | sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) | 45 | sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) |
| ... | @@ -180,6 +189,68 @@ def discover_audio_files(ref_csv: str | None, audio_dir: str | None) -> list[tup | ... | @@ -180,6 +189,68 @@ def discover_audio_files(ref_csv: str | None, audio_dir: str | None) -> list[tup |
| 180 | return results | 189 | return results |
| 181 | 190 | ||
| 182 | 191 | ||
| 192 | def discover_audio_from_db( | ||
| 193 | db_url: str, | ||
| 194 | table: str, | ||
| 195 | id_column: str, | ||
| 196 | url_column: str, | ||
| 197 | where: str | None = None, | ||
| 198 | limit: int | None = None, | ||
| 199 | ) -> list[tuple[str, str]]: | ||
| 200 | """从数据库查询歌曲,返回 [(song_id, audio_url), ...]。 | ||
| 201 | |||
| 202 | audio_url 是远端 HTTP(S) 地址,后续由 download_audio_url 下载到本地临时文件。 | ||
| 203 | """ | ||
| 204 | try: | ||
| 205 | import psycopg | ||
| 206 | except ImportError: | ||
| 207 | logger.error("缺少 psycopg 驱动,请执行: pip install psycopg") | ||
| 208 | sys.exit(1) | ||
| 209 | |||
| 210 | # DATABASE_URL 格式为 postgresql+asyncpg://...,需转为 psycopg 格式 | ||
| 211 | dsn = db_url.replace("postgresql+asyncpg://", "postgresql://").replace("postgresql+psycopg2://", "postgresql://") | ||
| 212 | |||
| 213 | sql = f'SELECT "{id_column}", "{url_column}" FROM "{table}"' | ||
| 214 | if where: | ||
| 215 | sql += f" WHERE {where}" | ||
| 216 | if limit: | ||
| 217 | sql += f" LIMIT {limit}" | ||
| 218 | |||
| 219 | logger.info("查询数据库: %s", sql) | ||
| 220 | results = [] | ||
| 221 | with psycopg.connect(dsn) as conn: | ||
| 222 | with conn.cursor() as cur: | ||
| 223 | cur.execute(sql) | ||
| 224 | for row in cur.fetchall(): | ||
| 225 | song_id = str(row[0]).strip() | ||
| 226 | audio_url = (row[1] or "").strip() | ||
| 227 | if not audio_url: | ||
| 228 | logger.warning(" 跳过 song_id=%s: url 为空", song_id) | ||
| 229 | continue | ||
| 230 | results.append((song_id, audio_url)) | ||
| 231 | |||
| 232 | logger.info("数据库查询到 %d 条记录", len(results)) | ||
| 233 | return results | ||
| 234 | |||
| 235 | |||
| 236 | def download_audio_url(audio_url: str, song_id: str, timeout: int = 60) -> str | None: | ||
| 237 | """从 HTTP(S) URL 下载音频到临时文件,返回本地路径。失败返回 None。""" | ||
| 238 | ext = Path(audio_url.split("?")[0]).suffix or ".mp3" | ||
| 239 | tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False) | ||
| 240 | tmp.close() | ||
| 241 | try: | ||
| 242 | req = urllib.request.Request(audio_url, headers={"User-Agent": "Mozilla/5.0"}) | ||
| 243 | with urllib.request.urlopen(req, timeout=timeout) as resp: | ||
| 244 | data = resp.read() | ||
| 245 | Path(tmp.name).write_bytes(data) | ||
| 246 | logger.info(" 下载完成: %s (%s)", song_id, _human_size(len(data))) | ||
| 247 | return tmp.name | ||
| 248 | except Exception as e: | ||
| 249 | logger.warning(" 下载失败 song_id=%s: %s", song_id, e) | ||
| 250 | Path(tmp.name).unlink(missing_ok=True) | ||
| 251 | return None | ||
| 252 | |||
| 253 | |||
| 183 | def load_state(state_file: str) -> dict: | 254 | def load_state(state_file: str) -> dict: |
| 184 | if not Path(state_file).exists(): | 255 | if not Path(state_file).exists(): |
| 185 | return {} | 256 | return {} |
| ... | @@ -211,6 +282,16 @@ def main() -> None: | ... | @@ -211,6 +282,16 @@ def main() -> None: |
| 211 | help=f"降采样目标采样率(默认 {DEFAULT_SAMPLE_RATE}Hz)") | 282 | help=f"降采样目标采样率(默认 {DEFAULT_SAMPLE_RATE}Hz)") |
| 212 | parser.add_argument("--no-resample", action="store_true", | 283 | parser.add_argument("--no-resample", action="store_true", |
| 213 | help="不降采样,上传原始文件") | 284 | help="不降采样,上传原始文件") |
| 285 | # 数据库模式参数 | ||
| 286 | db_group = parser.add_argument_group("数据库模式(与 --audio-dir/--ref-csv 互斥)") | ||
| 287 | db_group.add_argument("--db-table", help="要查询的数据库表名,例如 crawler_kugou_songs") | ||
| 288 | db_group.add_argument("--db-id-column", default="id", help="主键列名(默认 id)") | ||
| 289 | db_group.add_argument("--db-url-column", default="url", help="音频 URL 列名(默认 url)") | ||
| 290 | db_group.add_argument("--db-where", help="可选的 WHERE 子句,例如 \"url IS NOT NULL\"") | ||
| 291 | db_group.add_argument("--db-limit", type=int, help="最多查询的行数限制") | ||
| 292 | db_group.add_argument("--db-url", help="数据库连接串(默认读取 DATABASE_URL 环境变量)") | ||
| 293 | db_group.add_argument("--download-timeout", type=int, default=60, | ||
| 294 | help="下载音频 URL 的超时秒数(默认 60)") | ||
| 214 | args = parser.parse_args() | 295 | args = parser.parse_args() |
| 215 | 296 | ||
| 216 | # 验证配置 | 297 | # 验证配置 |
| ... | @@ -221,15 +302,37 @@ def main() -> None: | ... | @@ -221,15 +302,37 @@ def main() -> None: |
| 221 | logger.error("缺少环境变量: %s", key) | 302 | logger.error("缺少环境变量: %s", key) |
| 222 | sys.exit(1) | 303 | sys.exit(1) |
| 223 | 304 | ||
| 305 | use_db_mode = bool(args.db_table) | ||
| 306 | |||
| 307 | if use_db_mode: | ||
| 308 | db_url = args.db_url or os.environ.get("DATABASE_URL", "") | ||
| 309 | if not db_url: | ||
| 310 | logger.error("数据库模式需要 --db-url 参数或 DATABASE_URL 环境变量") | ||
| 311 | sys.exit(1) | ||
| 312 | # 返回 [(song_id, audio_url), ...] 其中 audio_url 为远端地址 | ||
| 313 | audio_files = discover_audio_from_db( | ||
| 314 | db_url=db_url, | ||
| 315 | table=args.db_table, | ||
| 316 | id_column=args.db_id_column, | ||
| 317 | url_column=args.db_url_column, | ||
| 318 | where=args.db_where, | ||
| 319 | limit=args.db_limit, | ||
| 320 | ) | ||
| 321 | else: | ||
| 224 | audio_files = discover_audio_files(args.ref_csv, args.audio_dir) | 322 | audio_files = discover_audio_files(args.ref_csv, args.audio_dir) |
| 225 | logger.info("发现 %d 个音频文件", len(audio_files)) | 323 | |
| 324 | logger.info("发现 %d 个音频条目", len(audio_files)) | ||
| 226 | 325 | ||
| 227 | state = load_state(args.state_file) | 326 | state = load_state(args.state_file) |
| 228 | 327 | ||
| 229 | # 过滤已成功入库的文件 | 328 | # 过滤已成功入库的条目(以 song_id 作为 key 避免 URL 变化导致重复) |
| 329 | def _state_key(song_id: str, audio_path: str) -> str: | ||
| 330 | return f"db:{song_id}" if use_db_mode else audio_path | ||
| 331 | |||
| 230 | pending = [] | 332 | pending = [] |
| 231 | for song_id, audio_path in audio_files: | 333 | for song_id, audio_path in audio_files: |
| 232 | status = state.get(audio_path) | 334 | key = _state_key(song_id, audio_path) |
| 335 | status = state.get(key) | ||
| 233 | if status == "ok": | 336 | if status == "ok": |
| 234 | continue | 337 | continue |
| 235 | if status == "failed" and not args.retry_failed: | 338 | if status == "failed" and not args.retry_failed: |
| ... | @@ -256,15 +359,27 @@ def main() -> None: | ... | @@ -256,15 +359,27 @@ def main() -> None: |
| 256 | tmp_files = [] # 跟踪临时文件,结束时清理 | 359 | tmp_files = [] # 跟踪临时文件,结束时清理 |
| 257 | 360 | ||
| 258 | for i, (song_id, audio_path) in enumerate(pending, 1): | 361 | for i, (song_id, audio_path) in enumerate(pending, 1): |
| 259 | logger.info("[%d/%d] 处理: %s (song_id=%s)", i, len(pending), Path(audio_path).name, song_id) | 362 | logger.info("[%d/%d] 处理: %s (song_id=%s)", i, len(pending), |
| 363 | audio_path if not use_db_mode else f"url={audio_path[:80]}...", song_id) | ||
| 260 | 364 | ||
| 365 | key = _state_key(song_id, audio_path) | ||
| 261 | upload_path = audio_path | 366 | upload_path = audio_path |
| 367 | downloaded_tmp = None | ||
| 368 | |||
| 262 | try: | 369 | try: |
| 263 | t0 = time.perf_counter() | 370 | t0 = time.perf_counter() |
| 264 | 371 | ||
| 372 | # 数据库模式:先从 URL 下载到本地临时文件 | ||
| 373 | if use_db_mode: | ||
| 374 | downloaded_tmp = download_audio_url(audio_path, song_id, timeout=args.download_timeout) | ||
| 375 | if downloaded_tmp is None: | ||
| 376 | raise RuntimeError("音频 URL 下载失败") | ||
| 377 | upload_path = downloaded_tmp | ||
| 378 | tmp_files.append(downloaded_tmp) | ||
| 379 | |||
| 265 | # 1. 降采样 | 380 | # 1. 降采样 |
| 266 | if not args.no_resample: | 381 | if not args.no_resample: |
| 267 | resampled = _resample_audio(audio_path, target_sr=args.sample_rate) | 382 | resampled = _resample_audio(upload_path, target_sr=args.sample_rate) |
| 268 | if resampled: | 383 | if resampled: |
| 269 | upload_path = resampled | 384 | upload_path = resampled |
| 270 | tmp_files.append(resampled) | 385 | tmp_files.append(resampled) |
| ... | @@ -278,15 +393,15 @@ def main() -> None: | ... | @@ -278,15 +393,15 @@ def main() -> None: |
| 278 | elapsed_ms = (time.perf_counter() - t0) * 1000 | 393 | elapsed_ms = (time.perf_counter() - t0) * 1000 |
| 279 | 394 | ||
| 280 | logger.info(" 入库作业已提交: JobId=%s (%.0fms)", job_id, elapsed_ms) | 395 | logger.info(" 入库作业已提交: JobId=%s (%.0fms)", job_id, elapsed_ms) |
| 281 | state[audio_path] = "ok" | 396 | state[key] = "ok" |
| 282 | state[f"{audio_path}::job_id"] = job_id | 397 | state[f"{key}::job_id"] = job_id |
| 283 | state[f"{audio_path}::oss_url"] = oss_url | 398 | state[f"{key}::oss_url"] = oss_url |
| 284 | success += 1 | 399 | success += 1 |
| 285 | 400 | ||
| 286 | except Exception as e: | 401 | except Exception as e: |
| 287 | logger.error(" 处理失败: %s", e) | 402 | logger.error(" 处理失败: %s", e) |
| 288 | state[audio_path] = "failed" | 403 | state[key] = "failed" |
| 289 | state[f"{audio_path}::error"] = str(e) | 404 | state[f"{key}::error"] = str(e) |
| 290 | fail += 1 | 405 | fail += 1 |
| 291 | 406 | ||
| 292 | save_state(args.state_file, state) | 407 | save_state(args.state_file, state) | ... | ... |
This diff could not be displayed because it is too large.
-
Please register or sign in to post a comment