a3216 commited on
Commit
8a492ea
·
verified ·
1 Parent(s): 021a975

sync from GitHub c561a5f: feat(music): 提升识曲准确率与访客模式可播放率,修复换源错配

Browse files

Auto-synced from GitHub commit c561a5f4b957d428b706c8d3cf29c8b7c221eb2b

Files changed (1) hide show
  1. app/services/music_service.py +643 -175
app/services/music_service.py CHANGED
@@ -3,15 +3,28 @@
3
  链路:手表上传 AMR 录音 → ffmpeg 转 PCM(8000Hz mono f32le) → node genfp.js 生成指纹
4
  → 调网易云 audio_match 识别 → 返回结果列表。
5
 
6
- 歌曲 URL 获取走网易云 weapi 加密接口;unblock=true 时若网易云无 URL 则尝试酷我换源。
7
-
8
- 安全:本模块不接触任何用凭证所有网易云调用均为匿名(无 cookie)。
 
 
 
 
 
 
 
 
 
 
 
 
9
  """
10
  from __future__ import annotations
11
 
12
  import asyncio
13
  import base64
14
  import binascii
 
15
  import json
16
  import logging
17
  import os
@@ -105,23 +118,187 @@ def _weapi_headers() -> dict:
105
  ip = _random_cn_ip()
106
  return {
107
  "Content-Type": "application/x-www-form-urlencoded",
108
- "User-Agent": "Mozilla/5.0 (Watch; Linux) AppleWebKit/537.36",
 
 
109
  "Referer": "https://music.163.com",
110
  "X-Real-IP": ip,
111
  "X-Forwarded-For": ip,
112
  }
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  # ===== ffmpeg + node 指纹生成 =====
116
 
117
- async def _amr_to_float32_pcm(amr_bytes: bytes, enhance: bool = False) -> bytes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  """用 ffmpeg 把 AMR 字节流转成 8000Hz 单声道 f32le 原始 PCM 字节。
119
 
120
- enhance=True 时应用实验性音频增强滤镜(降噪+增亮+高通),于改善
121
- 手表录音质量差导致识别率低的问题。滤镜链:
122
- highpass=f=80 去除 80Hz 以下低频杂音(电源嗡声/手柄震动)
123
- afftdn=nr=10 FFT 降噪,强度 10(轻度,避免损伤音乐信号)
124
- volume=3dB 提升 3dB(手表录音普遍偏小,mean_volume 约 -24dB
 
 
 
 
 
 
125
  """
126
  if not shutil.which(_FFMPEG_BIN) and not os.path.exists(_FFMPEG_BIN):
127
  raise HttpError(
@@ -135,8 +312,8 @@ async def _amr_to_float32_pcm(amr_bytes: bytes, enhance: bool = False) -> bytes:
135
  try:
136
  args = [_FFMPEG_BIN, "-y", "-i", fin_path]
137
  if enhance:
138
- # 实验性音频增强:高通去低频杂音 FFT 降噪 → 提升音量
139
- args += ["-af", "highpass=f=80,afftdn=nr=10,volume=3dB"]
140
  args += [
141
  "-f", "f32le", "-acodec", "pcm_f32le",
142
  "-ar", "8000", "-ac", "1", out_path,
@@ -204,21 +381,102 @@ async def _call_audio_match(fp: str, duration: int, client: httpx.AsyncClient) -
204
  return data.get("data", {})
205
 
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  async def _call_song_url(song_id: int, level: str, client: httpx.AsyncClient) -> dict:
208
- """调网易云 weapi /song/enhance/player/url/v1 获取播放 URL匿名,伪装国内 IP。"""
 
 
 
 
 
 
 
 
 
 
 
 
209
  payload = _weapi({"ids": [song_id], "level": level, "encodeType": "flac"})
210
- resp = await client.post(
211
- "https://music.163.com/weapi/song/enhance/player/url/v1",
212
- data=payload,
213
- headers=_weapi_headers(),
214
- timeout=10.0,
215
- )
216
- resp.raise_for_status()
217
- body = resp.json()
218
- if body.get("code") != 200:
 
 
 
 
 
 
219
  return {}
220
- data_arr = body.get("data") or []
221
- return data_arr[0] if data_arr else {}
222
 
223
 
224
  async def _call_song_detail(song_ids: list[int], client: httpx.AsyncClient) -> list[dict]:
@@ -236,29 +494,148 @@ async def _call_song_detail(song_ids: list[int], client: httpx.AsyncClient) -> l
236
  return body.get("songs", []) if body.get("code") == 200 else []
237
 
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  # ===== 酷我换源(unblock 备选) =====
240
 
241
- async def _kuwo_search_and_url(song_name: str, artist: str, client: httpx.AsyncClient) -> Optional[dict]:
 
 
 
242
  """从酷我音乐搜索并获取播放 URL。失��返回 None,不抛异常(仅做备选)。
243
 
244
- 关键:酷我 search.kuwo.cn/r.s 接口返回的是 JS 单引号字面量如 {'key':'value'}
245
- 不是合法 JSONPython json.loads 会报 "Expecting property name enclosed in double quotes"
246
- 用正则把单引号转双引号后再解析。
 
 
 
247
  """
248
  import ast
249
  import re
250
- import html as html_mod
251
 
252
  query = f"{song_name} {artist}".strip()
253
  if not query:
254
  return None
255
  try:
256
- # 搜索:多取几条以便精确匹配歌名
257
  search_url = "http://search.kuwo.cn/r.s"
258
  resp = await client.get(
259
  search_url,
260
  params={"all": query, "ft": "music", "itemset": "ctrl",
261
- "rformat": "json", "encoding": "utf8", "pn": 0, "rn": 10},
262
  headers={"User-Agent": "Mozilla/5.0", "Referer": "http://www.kuwo.cn/"},
263
  timeout=8.0,
264
  )
@@ -266,20 +643,17 @@ async def _kuwo_search_and_url(song_name: str, artist: str, client: httpx.AsyncC
266
  text = resp.text.strip()
267
  if not text:
268
  return None
269
- # 酷我返回 JS 单引号字面量,非合法 JSON。尝试多种解析方式
270
  data = None
271
  try:
272
  data = json.loads(text)
273
  except json.JSONDecodeError:
274
  try:
275
- # ast.literal_eval 兼容单引号字面量,需把 JS 的 null/true/false 转 Python
276
  py_text = text.replace(':null', ':None').replace(',null', ',None').replace('[null', '[None')
277
  py_text = py_text.replace(':true', ':True').replace(',true', ',True')
278
  py_text = py_text.replace(':false', ':False').replace(',false', ',False')
279
  data = ast.literal_eval(py_text)
280
  except Exception:
281
  try:
282
- # 最后 fallback:单引号转双引号
283
  data = json.loads(re.sub(r"'", '"', text))
284
  except Exception:
285
  logger.warning("[kuwo] search response parse failed, head=%s", text[:200])
@@ -288,39 +662,18 @@ async def _kuwo_search_and_url(song_name: str, artist: str, client: httpx.AsyncC
288
  if not abslist:
289
  return None
290
 
291
- # 优先精确匹配歌名(处理   等 HTML 实体),排除 DJ 版/remix 等变体
292
- def _norm(name: str) -> str:
293
- # 去掉 HTML 实体、空白,转小写
294
- return re.sub(r"\s+", "", html_mod.unescape(name or "")).lower()
295
-
296
- target_name = _norm(song_name)
297
- target_base = re.sub(r"\s*[\((].*?[\))]", "", song_name or "").strip() # 去掉括号后缀
298
- target_base_norm = _norm(target_base)
299
-
300
- chosen = None
301
- # 第一轮:歌名完全匹配(含括号后缀)
302
- for item in abslist:
303
- if _norm(item.get("SONGNAME", "")) == target_name:
304
- chosen = item
305
- break
306
- # 第二轮:去掉括号后缀后匹配,且排除 DJ 版/remix/cover 等变体
307
- if not chosen:
308
- for item in abslist:
309
- item_base = _norm(re.sub(r"\s*[\((].*?[\))]", "", item.get("SONGNAME", "")))
310
- if item_base == target_base_norm:
311
- full = _norm(item.get("SONGNAME", ""))
312
- if not re.search(r"dj|remix|cover|伴奏|翻唱", full):
313
- chosen = item
314
- break
315
- # 第三轮:兜底取第一首(排除明显变体)
316
- if not chosen:
317
- for item in abslist:
318
- full = _norm(item.get("SONGNAME", ""))
319
- if not re.search(r"dj|remix|cover|伴奏|翻唱", full):
320
- chosen = item
321
- break
322
  if not chosen:
323
- chosen = abslist[0]
 
324
 
325
  rid = chosen.get("MUSICRID", "").replace("MUSIC_", "")
326
  if not rid:
@@ -328,7 +681,6 @@ async def _kuwo_search_and_url(song_name: str, artist: str, client: httpx.AsyncC
328
  logger.info("[kuwo] chosen rid=%s name=%s for query=%s",
329
  rid, chosen.get("SONGNAME", ""), query)
330
 
331
- # 获取播放 URL(antiserver 接口返回标准 JSON)
332
  play_url = f"http://antiserver.kuwo.cn/anti.s?type=convert_url3&rid={rid}&format=mp3"
333
  resp2 = await client.get(
334
  play_url,
@@ -347,23 +699,23 @@ async def _kuwo_search_and_url(song_name: str, artist: str, client: httpx.AsyncC
347
 
348
 
349
  # ===== QQ 音乐换源(第二备选,best-effort) =====
350
- # 咪咕音乐搜索接口已失效(返回 HTML),改用 QQ 音乐作为第三换源。
351
  # QQ 音乐匿名 vkey 对 VIP 曲通常返回空 purl,仅覆盖部分免费曲,作兜底。
352
 
353
- async def _qq_search_and_url(song_name: str, artist: str, client: httpx.AsyncClient) -> Optional[dict]:
354
- """从 QQ 音乐搜索并获取播放 URL(匿名,best-effort 兜底)。失败返回 None。"""
355
- import re
356
- import html as html_mod
357
- import urllib.parse
358
 
 
 
359
  query = f"{song_name} {artist}".strip()
360
  if not query:
361
  return None
362
  try:
363
- # 搜索
364
  resp = await client.get(
365
  "https://c.y.qq.com/soso/fcgi-bin/client_search_cp",
366
- params={"w": query, "format": "json", "n": 10, "p": 1, "cr": 1},
367
  headers={"User-Agent": "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36",
368
  "Referer": "https://y.qq.com/"},
369
  timeout=8.0,
@@ -374,27 +726,34 @@ async def _qq_search_and_url(song_name: str, artist: str, client: httpx.AsyncCli
374
  if not lst:
375
  return None
376
 
377
- # 精确匹配歌名排除 remix/cover 等变体
378
- def _norm(name: str) -> str:
379
- return re.sub(r"\s+", "", html_mod.unescape(name or "")).lower()
380
-
381
- target = _norm(song_name)
382
- chosen = None
383
  for s in lst:
384
- if _norm(s.get("songname", "")) == target:
385
- full = _norm(s.get("songname", ""))
386
- if not re.search(r"dj|remix|cover|伴奏|翻唱", full):
387
- chosen = s
388
- break
 
 
 
 
 
 
 
 
 
 
 
389
  if not chosen:
390
- chosen = lst[0]
 
 
391
  songmid = chosen.get("songmid")
392
  if not songmid:
393
  return None
394
  logger.info("[qq] chosen mid=%s name=%s for query=%s",
395
  songmid, chosen.get("songname", ""), query)
396
 
397
- # 获取 vkey(匿名,VIP 曲 purl 通常为空)
398
  payload = json.dumps({
399
  "req_0": {
400
  "module": "vkey.GetVkeyServer",
@@ -461,119 +820,117 @@ async def recognize(
461
  logger.info("[recognize rid=%d] archive created, amr_bytes=%d, enhance=%s",
462
  rid, len(amr_bytes), enhance)
463
 
 
 
464
  try:
465
- pcm_bytes = await _amr_to_float32_pcm(amr_bytes, enhance=enhance)
466
  except Exception as e:
467
  music_record_store.update_record(
468
  rid, error_step="ffmpeg", error_msg=str(e),
469
  elapsed_ms=int((time.time() - t0) * 1000),
470
  )
471
  raise
472
- music_record_store.update_record(rid, pcm_size=len(pcm_bytes))
473
- logger.info("[recognize rid=%d] ffmpeg decode done, pcm bytes=%d, elapsed=%dms",
474
- rid, len(pcm_bytes), int((time.time() - t0) * 1000))
475
 
476
  # 网易云 shazam_v2 算法硬要求固定 3 秒片段(24000 samples @ 8000Hz)。
477
- # 本地测试证明duration=5/8/10 全部 noMatchReason=10,只有 3 秒能识别。
478
- #
479
- # 智能选段(默认行为,不依赖 enhance 开关):
480
- # 1. 用 numpy 向量化扫描所有可能的 3 秒窗口(0.5 秒步长)
481
- # 2. 取平均能量最高的 top 3 候选片段(自动避开噪音/静音段)
482
- # 3. 依次生成指纹并调用网易云识曲
483
- # 4. 合并 result,按 song_id 去重(同一首歌只保留最早 startTime)
484
- #
485
- # 这样不管噪音在开头/中间/结尾都能选到最干净的片段;
486
- # 多片段尝试可显著提高识别成功率(单一片段可能恰好落在歌曲间奏段)。
487
- # numpy 向量化使能量计算从纯 Python 循环的 ~20s 降到 < 10ms。
488
  _FP_DUR = 3
489
  _FP_SAMPLES = _FP_DUR * 8000 # 24000 samples
490
  _STEP_SAMPLES = 4000 # 0.5 秒步长
491
- _TOP_N = 3 # 最多尝试 3 个候选片段
492
 
493
- sample_count = len(pcm_bytes) // 4
 
494
  if sample_count < _FP_SAMPLES:
495
  # 录音不足 3 秒,直接用全部数据(补零由 afp.js 处理)
496
  candidates: list[tuple[int, float]] = [(0, 0.0)]
497
  logger.info("[recognize rid=%d] pcm too short (%d samples < %d), using all",
498
  rid, sample_count, _FP_SAMPLES)
499
  else:
500
- # 零拷贝视图,numpy 直接读 float32
501
- arr = np.frombuffer(pcm_bytes, dtype=np.float32)
502
  max_start = sample_count - _FP_SAMPLES
503
- starts = list(range(0, max_start + 1, _STEP_SAMPLES))
504
- # 向量化计算每个窗口的平均绝对幅值(能量指标)
505
- energies = [float(np.abs(arr[s:s + _FP_SAMPLES]).mean()) for s in starts]
506
- # top N,按能量降序
507
  top_idx = sorted(range(len(energies)), key=lambda i: energies[i], reverse=True)[:_TOP_N]
508
- candidates = [(starts[i], energies[i]) for i in top_idx]
509
  logger.info("[recognize rid=%d] smart pick top %d from %d windows: %s",
510
- rid, len(candidates), len(starts),
511
  ", ".join(f"{s/8000:.1f}s(e={e:.4f})" for s, e in candidates))
512
 
513
- # 依次尝试每个候选片段,合并识别结果并按 song_id 去重
 
 
 
 
 
 
 
514
  merged_songs: dict[int, dict] = {} # song_id -> song_dict
515
  first_ncm_raw: dict = {} # 首次响应入库存档(用于面板排查)
516
  first_fp_len = 0
517
  last_error: Optional[str] = None
 
518
 
519
  async with httpx.AsyncClient() as client:
520
- for cand_idx, (start_s, energy) in enumerate(candidates):
521
- pcm_for_fp = pcm_bytes[start_s * 4 : (start_s + _FP_SAMPLES) * 4]
522
- # 生成指纹
523
- try:
524
- fp = await _generate_fingerprint(pcm_for_fp, _FP_DUR)
525
- except Exception as e:
526
- last_error = str(e)
527
- logger.warning("[recognize rid=%d] cand#%d fp failed (start=%.1fs): %s",
528
- rid, cand_idx + 1, start_s / 8000.0, e)
529
- continue
530
- if cand_idx == 0:
531
- first_fp_len = len(fp)
532
- logger.info("[recognize rid=%d] cand#%d fp ok (start=%.1fs, fp_len=%d)",
533
- rid, cand_idx + 1, start_s / 8000.0, len(fp))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
- # 调网易云识曲
 
 
 
 
 
 
536
  try:
537
- ncm_raw = await _call_audio_match(fp, _FP_DUR, client)
 
538
  except Exception as e:
539
- last_error = str(e)
540
- logger.warning("[recognize rid=%d] cand#%d ncm call failed: %s",
541
- rid, cand_idx + 1, e)
542
- continue
543
-
544
- if cand_idx == 0:
545
- first_ncm_raw = ncm_raw if isinstance(ncm_raw, dict) else {}
546
-
547
- raw_result = ncm_raw.get("result") if isinstance(ncm_raw, dict) else None
548
- if not isinstance(raw_result, list) or not raw_result:
549
- logger.info("[recognize rid=%d] cand#%d no match (reason=%s)",
550
- rid, cand_idx + 1,
551
- ncm_raw.get("noMatchReason") if isinstance(ncm_raw, dict) else "?")
552
- continue
553
-
554
- # 合并到 merged_songs(按 song_id 去重,先到先得)
555
- new_added = 0
556
- for item in raw_result:
557
- song = item.get("song") or {}
558
- sid = song.get("id")
559
- if not sid or sid in merged_songs:
560
- continue
561
- merged_songs[sid] = {
562
- "id": sid,
563
- "name": song.get("name", ""),
564
- "artists": "/".join(a.get("name", "") for a in (song.get("artists") or [])),
565
- "album": (song.get("album") or {}).get("name", ""),
566
- "startTime": item.get("startTime", 0),
567
- }
568
- new_added += 1
569
-
570
- logger.info("[recognize rid=%d] cand#%d matched %d songs (+%d new), merged total %d",
571
- rid, cand_idx + 1, len(raw_result), new_added, len(merged_songs))
572
-
573
- # 合并到足够多时提前停止,避免浪费 API 调用
574
- if len(merged_songs) >= 5:
575
- logger.info("[recognize rid=%d] enough matches, stop early", rid)
576
- break
577
 
578
  # 入库(首次响应归档用于面板排查)
579
  ncm_code = first_ncm_raw.get("code") if isinstance(first_ncm_raw, dict) else None
@@ -613,19 +970,104 @@ async def recognize(
613
  }
614
 
615
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  async def get_song_url(song_id: int, level: str = "standard", unblock: bool = False,
617
  song_name: str = "", artist: str = "") -> dict:
618
- """获取歌曲播放 URL。unblock=true 时若网易云无 URL 则尝试酷我换源。
619
 
620
  所有返回的 URL 强制转 HTTPS,因为手表 audio 组件在部分网络环境下
621
  无法播放 HTTP 流(混合内容限制 / 运营商劫持)。
 
 
 
 
 
622
  """
 
623
  async with httpx.AsyncClient() as client:
624
  ncm_data = await _call_song_url(song_id, level, client)
 
625
 
626
  url = ncm_data.get("url")
627
  if url:
628
- # 网易云返回的 URL 可能是 http://,手表可能无法播放,强制转 https
629
  if url.startswith("http://"):
630
  url_https = "https://" + url[7:]
631
  logger.info("[song_url] convert http->https: %s -> %s",
@@ -639,29 +1081,55 @@ async def get_song_url(song_id: int, level: str = "standard", unblock: bool = Fa
639
  "freeTrialInfo": ncm_data.get("freeTrialInfo"),
640
  "fee": ncm_data.get("fee", 0),
641
  "source": "netease",
 
 
642
  }
643
 
644
  # 网易云无 URL(通常是 VIP/版权曲),尝试换源
645
  if unblock:
646
- kw = await _kuwo_search_and_url(song_name, artist, client)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  if kw and kw.get("url"):
648
  kw_url = kw["url"]
649
  if kw_url.startswith("http://"):
650
  kw_url = "https://" + kw_url[7:]
651
  logger.info("[song_url] kuwo convert http->https")
652
  kw["url"] = kw_url
653
- return {"ok": True, **kw}
 
654
 
655
- # 酷我也无 URL,尝试 QQ 音乐兜底
656
- qq = await _qq_search_and_url(song_name, artist, client)
657
  if qq and qq.get("url"):
658
- return {"ok": True, **qq}
 
659
 
660
  return {
661
  "ok": False,
662
  "url": None,
663
  "message": "无可用播放源" + ("(已尝试网易云/酷我/QQ换源)" if unblock else "(可在设置开启换源尝试)"),
664
  "source": "none",
 
 
665
  }
666
 
667
 
 
3
  链路:手表上传 AMR 录音 → ffmpeg 转 PCM(8000Hz mono f32le) → node genfp.js 生成指纹
4
  → 调网易云 audio_match 识别 → 返回结果列表。
5
 
6
+ 歌曲 URL 获取策略(多层兜底,提升访客模式可播放率):
7
+ 1. /api/song/enhance/player/url (旧 GET 接口) + 匿名 MUSIC_A cookie
8
+ —— 官方 Android 客端走的就是这条路径配合游客 MUSIC_A token
9
+ 能播放大量"游客可听"的免费曲(如 405253632 梦回还 TV size),
10
+ 这些曲子用 weapi/匿名(无 cookie) 是返回 url=null 的。
11
+ 2. weapi /weapi/song/enhance/player/url/v1 (旧路径,作为兜底)
12
+ 3. unblock=true 时尝试酷我/QQ 换源
13
+
14
+ 增强扫描模式 (enhance=True):
15
+ - 用更激进的 ffmpeg 滤镜链(AGC + dynaudnorm + 限幅)适配手表小声录音
16
+ - 多片段尝试:先 top-N 高能量段,未命中再扫描全部 0.5s 步长窗口
17
+ - 多滤镜兜底:第一滤镜链无命中时,再用第二滤镜链重试
18
+ - 实测可将 5 个手表样本的识别率从 3/5 提升到 5/5
19
+
20
+ 安全:本模块不接触任何用户凭证,所有网易云调用均为匿名(MUSIC_A 为游客 token)。
21
  """
22
  from __future__ import annotations
23
 
24
  import asyncio
25
  import base64
26
  import binascii
27
+ import hashlib
28
  import json
29
  import logging
30
  import os
 
118
  ip = _random_cn_ip()
119
  return {
120
  "Content-Type": "application/x-www-form-urlencoded",
121
+ # 伪装成网易云音乐 Android 客户端,比之前 "Watch; Linux" UA 通过率更高
122
+ "User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 "
123
+ "NeteaseMusic/9.1.65.240927161425",
124
  "Referer": "https://music.163.com",
125
  "X-Real-IP": ip,
126
  "X-Forwarded-For": ip,
127
  }
128
 
129
 
130
+ # ===== 匿名游客 MUSIC_A token 管理 =====
131
+ # 关键:网易云官方 Android 客户端在游客模式下会先调用 /api/register/anonimous
132
+ # 注册一个匿名账号,拿到 MUSIC_A cookie。后续请求带上该 cookie 即可播放大量
133
+ # "游客可听"的免费曲(这些曲子用纯 weapi/无 cookie 调用会返回 url=null)。
134
+ # 例:歌曲 405253632(梦回还 TV size)——无 cookie 时 url=null,带 MUSIC_A 后可拿到 mp3 URL。
135
+ #
136
+ # 实现说明:
137
+ # - register/anonimous 是明文 POST 接口(不走 weapi/xeapi 加密),只需构造合法 deviceId
138
+ # - token 缓存在内存,30 分钟过期;并发请求共用同一 token
139
+ # - 失败不抛异常,降级到无 cookie 模式(保持原有行为)
140
+
141
+ # cloudmusic_dll 编码用的 XOR key(公开固定值,反编译自 cloudmusic.dll)
142
+ _ANON_XOR_KEY = "3go8&$8*3*3h0k(2)2"
143
+
144
+ # 缓存:{ music_a, csrf, device_id, expire_ts }
145
+ _anon_token_cache: dict[str, Any] = {}
146
+ _anon_token_lock = asyncio.Lock()
147
+ _ANON_TOKEN_TTL = 30 * 60 # 30 分钟
148
+
149
+
150
+ def _generate_device_id() -> str:
151
+ """生成 52 位大写 hex 字符串的 deviceId(参考 api-enhanced-main generateDeviceId)。"""
152
+ return "".join(random.choice("0123456789ABCDEF") for _ in range(52))
153
+
154
+
155
+ def _encode_anon_username(device_id: str) -> str:
156
+ """构造 register/anonimous 接口的 username 参数。
157
+
158
+ 算法(参考 api-enhanced-main register_anonimous.js):
159
+ 1. xor(deviceId, "3go8&$8*3*3h0k(2)2") 循环异或
160
+ 2. md5(xored) 摘要
161
+ 3. base64(md5_digest) 得到 encoded_inner
162
+ 4. base64("deviceId encoded_inner") 得到最终 username
163
+ """
164
+ xored = "".join(
165
+ chr(ord(c) ^ ord(_ANON_XOR_KEY[i % len(_ANON_XOR_KEY)]))
166
+ for i, c in enumerate(device_id)
167
+ )
168
+ digest = hashlib.md5(xored.encode("latin-1")).digest()
169
+ encoded_inner = base64.b64encode(digest).decode("ascii")
170
+ return base64.b64encode(f"{device_id} {encoded_inner}".encode("utf-8")).decode("ascii")
171
+
172
+
173
+ async def _get_anon_token(client: httpx.AsyncClient) -> dict:
174
+ """获取(或复用缓存的)匿名游客 MUSIC_A token。
175
+
176
+ 返回 {"music_a": str, "csrf": str, "device_id": str}。
177
+ 任何失败都返回空 dict,调用方降级到无 cookie 模式。
178
+ """
179
+ # 命中缓存直接返回
180
+ cache = _anon_token_cache
181
+ if cache.get("music_a") and cache.get("expire_ts", 0) > time.time():
182
+ return cache
183
+
184
+ async with _anon_token_lock:
185
+ # double-check:可能其他协程已经刷新了
186
+ if cache.get("music_a") and cache.get("expire_ts", 0) > time.time():
187
+ return cache
188
+
189
+ # 重试机制:首次注册偶尔返回 code=400,重试 2-3 次通常成功
190
+ # (网易云对同一 deviceId 有频率限制,换新 deviceId 重试即可)
191
+ resp = None
192
+ for attempt in range(3):
193
+ device_id = _generate_device_id()
194
+ username = _encode_anon_username(device_id)
195
+ ip = _random_cn_ip()
196
+ try:
197
+ resp = await client.post(
198
+ "https://music.163.com/api/register/anonimous",
199
+ data={"username": username},
200
+ headers={
201
+ "User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 "
202
+ "NeteaseMusic/9.1.65.240927161425",
203
+ "X-Real-IP": ip,
204
+ "X-Forwarded-For": ip,
205
+ "Referer": "https://music.163.com",
206
+ "Content-Type": "application/x-www-form-urlencoded",
207
+ },
208
+ timeout=10.0,
209
+ )
210
+ body = resp.json()
211
+ if body.get("code") == 200:
212
+ break
213
+ logger.warning("[anon] register attempt #%d failed code=%s",
214
+ attempt + 1, body.get("code"))
215
+ except Exception as e:
216
+ logger.warning("[anon] register attempt #%d exception: %s", attempt + 1, e)
217
+ resp = None
218
+ if attempt < 2:
219
+ await asyncio.sleep(2)
220
+
221
+ if not resp:
222
+ return {}
223
+ body = resp.json() if resp else {}
224
+ if body.get("code") != 200:
225
+ logger.warning("[anon] all 3 attempts failed, last code=%s", body.get("code"))
226
+ return {}
227
+
228
+ # 从 Set-Cookie 提取 MUSIC_A 和 __csrf
229
+ music_a = ""
230
+ csrf = ""
231
+ for cookie in resp.headers.get_list("set-cookie"):
232
+ if cookie.startswith("MUSIC_A="):
233
+ music_a = cookie.split(";", 1)[0].split("=", 1)[1]
234
+ elif cookie.startswith("__csrf="):
235
+ csrf = cookie.split(";", 1)[0].split("=", 1)[1]
236
+
237
+ if not music_a:
238
+ logger.warning("[anon] register ok but no MUSIC_A cookie, set-cookie=%s",
239
+ str(resp.headers.get_list("set-cookie"))[:200])
240
+ return {}
241
+
242
+ cache.clear()
243
+ cache.update({
244
+ "music_a": music_a,
245
+ "csrf": csrf,
246
+ "device_id": device_id,
247
+ "expire_ts": time.time() + _ANON_TOKEN_TTL,
248
+ })
249
+ logger.info("[anon] registered new MUSIC_A token (device=%s..., expire in %ds)",
250
+ device_id[:8], _ANON_TOKEN_TTL)
251
+ return cache
252
+
253
+
254
+ def _build_anon_cookie_str(token: dict) -> str:
255
+ """根据匿名 token 构造 Cookie 头字符串。"""
256
+ if not token or not token.get("music_a"):
257
+ return ""
258
+ parts = [
259
+ f"MUSIC_A={token['music_a']}",
260
+ f"os=android",
261
+ f"appver=9.1.65.240927161425",
262
+ f"osver=14",
263
+ f"channel=xiaomi",
264
+ ]
265
+ if token.get("csrf"):
266
+ parts.append(f"__csrf={token['csrf']}")
267
+ if token.get("device_id"):
268
+ parts.append(f"deviceId={token['device_id']}")
269
+ return "; ".join(parts)
270
+
271
+
272
  # ===== ffmpeg + node 指纹生成 =====
273
 
274
+ # 滤镜链定义:enhance 模式下依次尝试,任一滤镜链命中即停止
275
+ # 1. agc 滤镜:适配手表小声录音,AGC + dynaudnorm 把响度拉到统一水平
276
+ # 实测:对 mean_amp=0.0004 的近乎静音样本能识别成功(normal 模式必失败)
277
+ # 2. spectral 滤镜:谱减法风格,afftdn 高强度降噪 + alimiter 防削顶
278
+ # 实测:对中等噪声样本(mean_amp=0.02)命中率与 agc 接近,互为兜底
279
+ # 3. light 滤镜:轻度增强(与旧 enhance 等价),对干净录音不损伤
280
+ # 实测:对 mean_amp=0.09 的响亮样本命中率最高(避免过度处理)
281
+ _ENHANCE_FILTER_CHAINS = [
282
+ "highpass=f=85,lowpass=f=4000,afftdn=nr=12,volume=10dB,dynaudnorm=p=0.9:s=15:g=15",
283
+ "highpass=f=80,afftdn=nr=20:nf=-25,volume=8dB,alimiter=limit=0.95",
284
+ "highpass=f=80,afftdn=nr=10,volume=3dB",
285
+ ]
286
+
287
+
288
+ async def _amr_to_float32_pcm(amr_bytes: bytes, enhance: bool = False, filter_chain: Optional[str] = None) -> bytes:
289
  """用 ffmpeg 把 AMR 字节流转成 8000Hz 单声道 f32le 原始 PCM 字节。
290
 
291
+ enhance=True 时应用音频增强滤镜(默认 _ENHANCE_FILTER_CHAINS[0])。
292
+ filter_chain 显式指定时优先使用,方便增强模式多滤镜兜底。
293
+
294
+ 滤镜设计原理:
295
+ highpass=f=85 去除 85Hz 以下低频杂音电源嗡声/柄震动/呼吸声)
296
+ lowpass=f=4000 限制 4kHz 以上频段(手表麦克风高频噪声大,且 shazam_v2
297
+ 指纹算法主要用中低频信息,过度保留高���反而不利)
298
+ afftdn=nr=12 FFT 降噪,强度 12(中度,平衡降噪与音乐信号保留)
299
+ volume=10dB 提升 10dB(手表录音普遍偏小声,mean_volume 约 -24dB)
300
+ dynaudnorm 动态范围归一化:把响度拉到统一水平,解决"小声段被淹没"
301
+ alimiter=limit=0.95 限幅防削顶(dynaudnorm + volume 后可能超 0dB 削顶)
302
  """
303
  if not shutil.which(_FFMPEG_BIN) and not os.path.exists(_FFMPEG_BIN):
304
  raise HttpError(
 
312
  try:
313
  args = [_FFMPEG_BIN, "-y", "-i", fin_path]
314
  if enhance:
315
+ af = filter_chain or _ENHANCE_FILTER_CHAINS[0]
316
+ args += ["-af", af]
317
  args += [
318
  "-f", "f32le", "-acodec", "pcm_f32le",
319
  "-ar", "8000", "-ac", "1", out_path,
 
381
  return data.get("data", {})
382
 
383
 
384
+ def _level_to_br(level: str) -> int:
385
+ """weapi 的 level 字符串映射到旧接口的 br(bitrate)整数。
386
+
387
+ 旧 /api/song/enhance/player/url 接口用 br=128000/192000/320000/999000
388
+ 分别对应 standard/exhigh/higher/lossless。
389
+ """
390
+ return {
391
+ "standard": 128000,
392
+ "exhigh": 192000,
393
+ "higher": 320000,
394
+ "lossless": 999000,
395
+ }.get(level, 128000)
396
+
397
+
398
+ async def _call_song_url_via_anon(song_id: int, br: int, client: httpx.AsyncClient) -> dict:
399
+ """用匿名 MUSIC_A token + 旧 GET 接口获取播放 URL。
400
+
401
+ 走 /api/song/enhance/player/url?ids=[X]&br=Y —— 这是官方 Android 客户端
402
+ 游客模式使用的路径,配合 MUSIC_A cookie 能播放大量"游客可听"的免费曲
403
+ (如 405253632 梦回还 TV size),这些曲子用 weapi/无 cookie 都返回 url=null。
404
+
405
+ 失败返回空 dict,由调用方降级到 weapi。
406
+ """
407
+ token = await _get_anon_token(client)
408
+ if not token:
409
+ return {}
410
+ cookie = _build_anon_cookie_str(token)
411
+ ip = _random_cn_ip()
412
+ headers = {
413
+ "User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 "
414
+ "NeteaseMusic/9.1.65.240927161425",
415
+ "Referer": "https://music.163.com",
416
+ "X-Real-IP": ip,
417
+ "X-Forwarded-For": ip,
418
+ "Cookie": cookie,
419
+ }
420
+ try:
421
+ resp = await client.get(
422
+ "https://music.163.com/api/song/enhance/player/url",
423
+ params={"ids": f"[{song_id}]", "br": br},
424
+ headers=headers,
425
+ timeout=10.0,
426
+ )
427
+ resp.raise_for_status()
428
+ body = resp.json()
429
+ if body.get("code") != 200:
430
+ logger.info("[song_url] anon endpoint code=%s for sid=%s",
431
+ body.get("code"), song_id)
432
+ return {}
433
+ data_arr = body.get("data") or []
434
+ if not data_arr:
435
+ return {}
436
+ d = data_arr[0]
437
+ if not d.get("url"):
438
+ logger.info("[song_url] anon endpoint returned url=null for sid=%s (fee=%s)",
439
+ song_id, d.get("fee"))
440
+ return {}
441
+ logger.info("[song_url] anon endpoint OK for sid=%s (fee=%s, br=%s, type=%s)",
442
+ song_id, d.get("fee"), d.get("br"), d.get("type"))
443
+ return d
444
+ except Exception as e:
445
+ logger.warning("[song_url] anon endpoint exception for sid=%s: %s", song_id, e)
446
+ return {}
447
+
448
+
449
  async def _call_song_url(song_id: int, level: str, client: httpx.AsyncClient) -> dict:
450
+ """获取播放 URL。优先用匿名 MUSIC_A token + 旧 GET 接口(覆盖游客可听曲
451
+ 失败再降级到 weapi /song/enhance/player/url/v1。
452
+
453
+ 实测:歌曲 405253632(梦回还 TV size)在 weapi 下返回 url=null,
454
+ 但用匿名 MUSIC_A + /api/song/enhance/player/url 能拿到 mp3 URL。
455
+ """
456
+ br = _level_to_br(level)
457
+ # 1. 优先走匿名 MUSIC_A + 旧接口(覆盖游客可听的免费曲)
458
+ d = await _call_song_url_via_anon(song_id, br, client)
459
+ if d and d.get("url"):
460
+ return d
461
+
462
+ # 2. 降级到 weapi(覆盖部分 anon 接口也拿不到的曲子,如需登录的 VIP 曲的试听片段)
463
  payload = _weapi({"ids": [song_id], "level": level, "encodeType": "flac"})
464
+ try:
465
+ resp = await client.post(
466
+ "https://music.163.com/weapi/song/enhance/player/url/v1",
467
+ data=payload,
468
+ headers=_weapi_headers(),
469
+ timeout=10.0,
470
+ )
471
+ resp.raise_for_status()
472
+ body = resp.json()
473
+ if body.get("code") != 200:
474
+ return {}
475
+ data_arr = body.get("data") or []
476
+ return data_arr[0] if data_arr else {}
477
+ except Exception as e:
478
+ logger.warning("[song_url] weapi exception for sid=%s: %s", song_id, e)
479
  return {}
 
 
480
 
481
 
482
  async def _call_song_detail(song_ids: list[int], client: httpx.AsyncClient) -> list[dict]:
 
494
  return body.get("songs", []) if body.get("code") == 200 else []
495
 
496
 
497
+ # ===== 换源匹配工具 =====
498
+
499
+ def _norm_name(name: str) -> str:
500
+ """归一化歌名/歌手名:去 HTML 实体、空白、转小写。"""
501
+ import re
502
+ import html as html_mod
503
+ return re.sub(r"\s+", "", html_mod.unescape(name or "")).lower()
504
+
505
+
506
+ def _strip_paren_suffix(name: str) -> str:
507
+ """去掉歌名中的括号后缀,如 '梦回还(TV size)' → '梦回还'。"""
508
+ import re
509
+ return re.sub(r"\s*[\((].*?[\))]", "", name or "").strip()
510
+
511
+
512
+ def _name_match_score(candidate: str, target: str) -> int:
513
+ """歌名匹配评分(0-100)。
514
+
515
+ 100: 完全匹配
516
+ 85: 去括号后缀后完全匹配
517
+ 70: 一方是另一方的子串(防止 "梦回还" 匹配到 "梦回还TV size")
518
+ 0: 不匹配
519
+ """
520
+ if not candidate or not target:
521
+ return 0
522
+ if candidate == target:
523
+ return 100
524
+ cand_base = _norm_name(_strip_paren_suffix(candidate))
525
+ tgt_base = _norm_name(_strip_paren_suffix(target))
526
+ if cand_base and tgt_base and cand_base == tgt_base:
527
+ return 85
528
+ if cand_base and tgt_base:
529
+ if cand_base in tgt_base or tgt_base in cand_base:
530
+ return 70
531
+ return 0
532
+
533
+
534
+ def _duration_match_score(candidate_dur: float, target_dur: float) -> int:
535
+ """时长匹配评分(0-100)。target_dur 为 0 时不参与评分(返回 50 中性分)。"""
536
+ if target_dur <= 0 or candidate_dur <= 0:
537
+ return 50 # 未知时长,中性分
538
+ diff = abs(candidate_dur - target_dur)
539
+ if diff <= 2:
540
+ return 100
541
+ if diff <= 5:
542
+ return 80
543
+ if diff <= 10:
544
+ return 50
545
+ return 0
546
+
547
+
548
+ def _pick_best_candidate(
549
+ items: list[dict],
550
+ *,
551
+ name_key: str,
552
+ artist_key: str,
553
+ duration_key: str,
554
+ target_name: str,
555
+ target_artist: str,
556
+ target_duration: float,
557
+ ) -> Optional[dict]:
558
+ """从搜索结果中按 [歌名+歌手+时长] 综合评分选出最佳候选。
559
+
560
+ 评分规则:
561
+ name_score * 0.6 + artist_score * 0.2 + duration_score * 0.2
562
+ 最低门槛:name_score > 0(必须有歌名匹配,防止"兜底取第一首"导致错配)
563
+ 排除 remix/dj/cover/伴奏/翻唱 等变体(除非原曲名本身就含这些词)
564
+ """
565
+ import re
566
+
567
+ target_name_norm = _norm_name(target_name)
568
+ target_artist_norm = _norm_name(target_artist)
569
+ # 原曲名是否本身就含变体词(如 "DJ版XXX")
570
+ target_has_variant = bool(re.search(r"dj|remix|cover|伴奏|翻唱", target_name_norm))
571
+
572
+ best_item = None
573
+ best_score = 0
574
+ best_info = ""
575
+
576
+ for item in items:
577
+ item_name = str(item.get(name_key, "") or "")
578
+ item_artist = str(item.get(artist_key, "") or "")
579
+ item_dur = 0.0
580
+ try:
581
+ dur_raw = item.get(duration_key, 0)
582
+ item_dur = float(dur_raw)
583
+ except (TypeError, ValueError):
584
+ item_dur = 0.0
585
+
586
+ # 排除变体(除非原曲名本身就有变体词)
587
+ if not target_has_variant:
588
+ full = _norm_name(item_name)
589
+ if re.search(r"dj|remix|cover|伴奏|翻唱", full):
590
+ continue
591
+
592
+ name_score = _name_match_score(_norm_name(item_name), target_name_norm)
593
+ if name_score == 0:
594
+ continue # 歌名不匹配,跳过(防止错配)
595
+
596
+ artist_score = _name_match_score(_norm_name(item_artist), target_artist_norm) \
597
+ if target_artist_norm else 50
598
+ dur_score = _duration_match_score(item_dur, target_duration)
599
+ total = name_score * 0.6 + artist_score * 0.2 + dur_score * 0.2
600
+
601
+ if total > best_score:
602
+ best_score = total
603
+ best_item = item
604
+ best_info = f"name={item_name} artist={item_artist} dur={item_dur}s " \
605
+ f"(name={name_score} artist={artist_score} dur={dur_score} total={total:.1f})"
606
+
607
+ if best_item:
608
+ logger.info("[source] best candidate: %s", best_info)
609
+ return best_item
610
+
611
+
612
  # ===== 酷我换源(unblock 备选) =====
613
 
614
+ async def _kuwo_search_and_url(
615
+ song_name: str, artist: str, client: httpx.AsyncClient,
616
+ expected_duration: float = 0,
617
+ ) -> Optional[dict]:
618
  """从酷我音乐搜索并获取播放 URL。失��返回 None,不抛异常(仅做备选)。
619
 
620
+ 关键改进修复换源错配
621
+ - 用综合评分(歌名+歌手+时长)选最佳候选不再"兜底取第一首"
622
+ - 歌名不匹配的候选直接跳过,避免搜索到同名不同曲的情况
623
+ - 有 expected_duration 时优先选时长接近的版本
624
+
625
+ 注意:酷我 search.kuwo.cn/r.s 接口返回 JS 单引号字面量,需特殊解析。
626
  """
627
  import ast
628
  import re
 
629
 
630
  query = f"{song_name} {artist}".strip()
631
  if not query:
632
  return None
633
  try:
 
634
  search_url = "http://search.kuwo.cn/r.s"
635
  resp = await client.get(
636
  search_url,
637
  params={"all": query, "ft": "music", "itemset": "ctrl",
638
+ "rformat": "json", "encoding": "utf8", "pn": 0, "rn": 15},
639
  headers={"User-Agent": "Mozilla/5.0", "Referer": "http://www.kuwo.cn/"},
640
  timeout=8.0,
641
  )
 
643
  text = resp.text.strip()
644
  if not text:
645
  return None
 
646
  data = None
647
  try:
648
  data = json.loads(text)
649
  except json.JSONDecodeError:
650
  try:
 
651
  py_text = text.replace(':null', ':None').replace(',null', ',None').replace('[null', '[None')
652
  py_text = py_text.replace(':true', ':True').replace(',true', ',True')
653
  py_text = py_text.replace(':false', ':False').replace(',false', ',False')
654
  data = ast.literal_eval(py_text)
655
  except Exception:
656
  try:
 
657
  data = json.loads(re.sub(r"'", '"', text))
658
  except Exception:
659
  logger.warning("[kuwo] search response parse failed, head=%s", text[:200])
 
662
  if not abslist:
663
  return None
664
 
665
+ chosen = _pick_best_candidate(
666
+ abslist,
667
+ name_key="SONGNAME",
668
+ artist_key="ARTIST",
669
+ duration_key="DURATION",
670
+ target_name=song_name,
671
+ target_artist=artist,
672
+ target_duration=expected_duration,
673
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
  if not chosen:
675
+ logger.info("[kuwo] no candidate with matching name for query=%s", query)
676
+ return None
677
 
678
  rid = chosen.get("MUSICRID", "").replace("MUSIC_", "")
679
  if not rid:
 
681
  logger.info("[kuwo] chosen rid=%s name=%s for query=%s",
682
  rid, chosen.get("SONGNAME", ""), query)
683
 
 
684
  play_url = f"http://antiserver.kuwo.cn/anti.s?type=convert_url3&rid={rid}&format=mp3"
685
  resp2 = await client.get(
686
  play_url,
 
699
 
700
 
701
  # ===== QQ 音乐换源(第二备选,best-effort) =====
 
702
  # QQ 音乐匿名 vkey 对 VIP 曲通常返回空 purl,仅覆盖部分免费曲,作兜底。
703
 
704
+ async def _qq_search_and_url(
705
+ song_name: str, artist: str, client: httpx.AsyncClient,
706
+ expected_duration: float = 0,
707
+ ) -> Optional[dict]:
708
+ """从 QQ 音乐搜索并获取播放 URL(匿名,best-effort 兜底)。失败返回 None。
709
 
710
+ 改进:用综合评分选最佳候选,歌名不匹配的跳过,防止错配。
711
+ """
712
  query = f"{song_name} {artist}".strip()
713
  if not query:
714
  return None
715
  try:
 
716
  resp = await client.get(
717
  "https://c.y.qq.com/soso/fcgi-bin/client_search_cp",
718
+ params={"w": query, "format": "json", "n": 15, "p": 1, "cr": 1},
719
  headers={"User-Agent": "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36",
720
  "Referer": "https://y.qq.com/"},
721
  timeout=8.0,
 
726
  if not lst:
727
  return None
728
 
729
+ # QQ 的 singer 是列表需要展平为字符串供 _pick_best_candidate 使用
 
 
 
 
 
730
  for s in lst:
731
+ singers = s.get("singer") or []
732
+ if singers and isinstance(singers, list):
733
+ s["singer_name"] = "/".join(
734
+ str(x.get("name", "")) for x in singers if x.get("name"))
735
+ else:
736
+ s["singer_name"] = ""
737
+
738
+ chosen = _pick_best_candidate(
739
+ lst,
740
+ name_key="songname",
741
+ artist_key="singer_name",
742
+ duration_key="interval",
743
+ target_name=song_name,
744
+ target_artist=artist,
745
+ target_duration=expected_duration,
746
+ )
747
  if not chosen:
748
+ logger.info("[qq] no candidate with matching name for query=%s", query)
749
+ return None
750
+
751
  songmid = chosen.get("songmid")
752
  if not songmid:
753
  return None
754
  logger.info("[qq] chosen mid=%s name=%s for query=%s",
755
  songmid, chosen.get("songname", ""), query)
756
 
 
757
  payload = json.dumps({
758
  "req_0": {
759
  "module": "vkey.GetVkeyServer",
 
820
  logger.info("[recognize rid=%d] archive created, amr_bytes=%d, enhance=%s",
821
  rid, len(amr_bytes), enhance)
822
 
823
+ # 第一步:先解码一份"无滤镜" PCM,用于能量计算与选段。
824
+ # 选段基于原始信号能量更稳定(滤镜会改变能量分布)。
825
  try:
826
+ raw_pcm = await _amr_to_float32_pcm(amr_bytes, enhance=False)
827
  except Exception as e:
828
  music_record_store.update_record(
829
  rid, error_step="ffmpeg", error_msg=str(e),
830
  elapsed_ms=int((time.time() - t0) * 1000),
831
  )
832
  raise
833
+ music_record_store.update_record(rid, pcm_size=len(raw_pcm))
834
+ logger.info("[recognize rid=%d] raw pcm decoded, bytes=%d, elapsed=%dms",
835
+ rid, len(raw_pcm), int((time.time() - t0) * 1000))
836
 
837
  # 网易云 shazam_v2 算法硬要求固定 3 秒片段(24000 samples @ 8000Hz)。
838
+ # 智能选段numpy 向量化扫描所有 3 秒窗口(0.5 秒步长),
839
+ # 取平均能量最高的 top-N 候选片段。
 
 
 
 
 
 
 
 
 
840
  _FP_DUR = 3
841
  _FP_SAMPLES = _FP_DUR * 8000 # 24000 samples
842
  _STEP_SAMPLES = 4000 # 0.5 秒步长
843
+ _TOP_N = 3 # 最多尝试 3 个候选片段(首轮)
844
 
845
+ sample_count = len(raw_pcm) // 4
846
+ all_starts: list[int] = []
847
  if sample_count < _FP_SAMPLES:
848
  # 录音不足 3 秒,直接用全部数据(补零由 afp.js 处理)
849
  candidates: list[tuple[int, float]] = [(0, 0.0)]
850
  logger.info("[recognize rid=%d] pcm too short (%d samples < %d), using all",
851
  rid, sample_count, _FP_SAMPLES)
852
  else:
853
+ arr = np.frombuffer(raw_pcm, dtype=np.float32)
 
854
  max_start = sample_count - _FP_SAMPLES
855
+ all_starts = list(range(0, max_start + 1, _STEP_SAMPLES))
856
+ energies = [float(np.abs(arr[s:s + _FP_SAMPLES]).mean()) for s in all_starts]
 
 
857
  top_idx = sorted(range(len(energies)), key=lambda i: energies[i], reverse=True)[:_TOP_N]
858
+ candidates = [(all_starts[i], energies[i]) for i in top_idx]
859
  logger.info("[recognize rid=%d] smart pick top %d from %d windows: %s",
860
+ rid, len(candidates), len(all_starts),
861
  ", ".join(f"{s/8000:.1f}s(e={e:.4f})" for s, e in candidates))
862
 
863
+ # 增强模式下依次尝试多条滤镜链;普通模式只用一条(无滤镜)。
864
+ # 实测:agc 滤镜适合小声录音,spectral 适合中等噪声,light 适合干净录音。
865
+ # 不同录音适配不同滤镜,多滤镜兜底能覆盖更多场景。
866
+ if enhance:
867
+ filter_chains = list(_ENHANCE_FILTER_CHAINS)
868
+ else:
869
+ filter_chains = [None]
870
+
871
  merged_songs: dict[int, dict] = {} # song_id -> song_dict
872
  first_ncm_raw: dict = {} # 首次响应入库存档(用于面板排查)
873
  first_fp_len = 0
874
  last_error: Optional[str] = None
875
+ tried_starts: set[int] = set() # 已尝试过的 start_s,避免全量扫描时重复
876
 
877
  async with httpx.AsyncClient() as client:
878
+ # ---- 第一阶段:每个滤镜链尝试 top-N 候选片段 ----
879
+ for fc_idx, fc in enumerate(filter_chains):
880
+ if merged_songs:
881
+ break
882
+ # 按需重新解码:不同滤镜链需要不同的 PCM
883
+ if fc is None:
884
+ pcm_bytes = raw_pcm
885
+ else:
886
+ try:
887
+ pcm_bytes = await _amr_to_float32_pcm(
888
+ amr_bytes, enhance=True, filter_chain=fc)
889
+ except Exception as e:
890
+ logger.warning("[recognize rid=%d] filter chain #%d decode failed: %s",
891
+ rid, fc_idx + 1, e)
892
+ continue
893
+ logger.info("[recognize rid=%d] filter chain #%d decoded, bytes=%d",
894
+ rid, fc_idx + 1, len(pcm_bytes))
895
+ fc_label = f"fc#{fc_idx+1}({fc[:30]}...)" if fc else "no-filter"
896
+ matched = await _try_candidates(
897
+ pcm_bytes, candidates, client, rid, fc_label,
898
+ merged_songs, first_ncm_raw, tried_starts,
899
+ track_first=(not first_ncm_raw),
900
+ )
901
+ if not first_fp_len and matched["first_fp_len"]:
902
+ first_fp_len = matched["first_fp_len"]
903
+ if matched["last_error"]:
904
+ last_error = matched["last_error"]
905
+ if merged_songs:
906
+ logger.info("[recognize rid=%d] filter chain #%d got %d matches, stop",
907
+ rid, fc_idx + 1, len(merged_songs))
908
+ break
909
 
910
+ # ---- 第二阶段(仅 enhance):top-N 全部未命中时,全量扫描所有片段 ----
911
+ # 实测:audio (1).mp3 的 top-3 能量段全部 noMatch,但 start=6.5s
912
+ # (能量较低)能识别成功。全量扫描兜底能显著提升识别率。
913
+ if enhance and not merged_songs and all_starts:
914
+ logger.info("[recognize rid=%d] top-N all miss, fallback to full scan (%d windows)",
915
+ rid, len(all_starts))
916
+ # 全量扫描用第一条(agc)滤镜链,对小声录音效果最好
917
  try:
918
+ pcm_bytes = await _amr_to_float32_pcm(
919
+ amr_bytes, enhance=True, filter_chain=_ENHANCE_FILTER_CHAINS[0])
920
  except Exception as e:
921
+ logger.warning("[recognize rid=%d] full-scan decode failed: %s", rid, e)
922
+ pcm_bytes = raw_pcm
923
+
924
+ remaining = [(s, 0.0) for s in all_starts if s not in tried_starts]
925
+ # 全量扫描最多尝试 20 个片段(避免过多 API 调用拖慢响应)
926
+ remaining = remaining[:20]
927
+ matched = await _try_candidates(
928
+ pcm_bytes, remaining, client, rid, "full-scan",
929
+ merged_songs, first_ncm_raw, set(),
930
+ track_first=(not first_ncm_raw),
931
+ )
932
+ if matched["last_error"]:
933
+ last_error = matched["last_error"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
 
935
  # 入库(首次响应归档用于面板排查)
936
  ncm_code = first_ncm_raw.get("code") if isinstance(first_ncm_raw, dict) else None
 
970
  }
971
 
972
 
973
+ async def _try_candidates(
974
+ pcm_bytes: bytes,
975
+ candidates: list[tuple[int, float]],
976
+ client: httpx.AsyncClient,
977
+ rid: int,
978
+ label: str,
979
+ merged_songs: dict[int, dict],
980
+ first_ncm_raw: dict,
981
+ tried_starts: set[int],
982
+ track_first: bool = True,
983
+ ) -> dict:
984
+ """对一组候选片段依次生成指纹并调网易云识曲,合并结果到 merged_songs。
985
+
986
+ 返回 {"first_fp_len": int, "last_error": str}。
987
+ merged_songs / tried_starts / first_ncm_raw 为引用传入,直接原地修改。
988
+ track_first=True 时,首次拿到 ncm 响应(含 noMatch)会写入 first_ncm_raw。
989
+ """
990
+ _FP_DUR = 3
991
+ _FP_SAMPLES = _FP_DUR * 8000
992
+ result = {"first_fp_len": 0, "last_error": None}
993
+
994
+ for cand_idx, (start_s, energy) in enumerate(candidates):
995
+ tried_starts.add(start_s)
996
+ pcm_for_fp = pcm_bytes[start_s * 4 : (start_s + _FP_SAMPLES) * 4]
997
+ try:
998
+ fp = await _generate_fingerprint(pcm_for_fp, _FP_DUR)
999
+ except Exception as e:
1000
+ result["last_error"] = str(e)
1001
+ logger.warning("[recognize rid=%d] %s cand#%d fp failed (start=%.1fs): %s",
1002
+ rid, label, cand_idx + 1, start_s / 8000.0, e)
1003
+ continue
1004
+ if cand_idx == 0 and track_first and not first_ncm_raw:
1005
+ result["first_fp_len"] = len(fp)
1006
+ logger.info("[recognize rid=%d] %s cand#%d fp ok (start=%.1fs, fp_len=%d)",
1007
+ rid, label, cand_idx + 1, start_s / 8000.0, len(fp))
1008
+
1009
+ try:
1010
+ ncm_raw = await _call_audio_match(fp, _FP_DUR, client)
1011
+ except Exception as e:
1012
+ result["last_error"] = str(e)
1013
+ logger.warning("[recognize rid=%d] %s cand#%d ncm call failed: %s",
1014
+ rid, label, cand_idx + 1, e)
1015
+ continue
1016
+
1017
+ if track_first and not first_ncm_raw:
1018
+ first_ncm_raw.update(ncm_raw if isinstance(ncm_raw, dict) else {})
1019
+
1020
+ raw_result = ncm_raw.get("result") if isinstance(ncm_raw, dict) else None
1021
+ if not isinstance(raw_result, list) or not raw_result:
1022
+ logger.info("[recognize rid=%d] %s cand#%d no match (reason=%s)",
1023
+ rid, label, cand_idx + 1,
1024
+ ncm_raw.get("noMatchReason") if isinstance(ncm_raw, dict) else "?")
1025
+ continue
1026
+
1027
+ new_added = 0
1028
+ for item in raw_result:
1029
+ song = item.get("song") or {}
1030
+ sid = song.get("id")
1031
+ if not sid or sid in merged_songs:
1032
+ continue
1033
+ merged_songs[sid] = {
1034
+ "id": sid,
1035
+ "name": song.get("name", ""),
1036
+ "artists": "/".join(a.get("name", "") for a in (song.get("artists") or [])),
1037
+ "album": (song.get("album") or {}).get("name", ""),
1038
+ "startTime": item.get("startTime", 0),
1039
+ }
1040
+ new_added += 1
1041
+
1042
+ logger.info("[recognize rid=%d] %s cand#%d matched %d songs (+%d new), merged total %d",
1043
+ rid, label, cand_idx + 1, len(raw_result), new_added, len(merged_songs))
1044
+
1045
+ if len(merged_songs) >= 5:
1046
+ logger.info("[recognize rid=%d] %s enough matches, stop early", rid, label)
1047
+ break
1048
+
1049
+ return result
1050
+
1051
+
1052
  async def get_song_url(song_id: int, level: str = "standard", unblock: bool = False,
1053
  song_name: str = "", artist: str = "") -> dict:
1054
+ """获取歌曲播放 URL。unblock=true 时若网易云无 URL 则尝试酷我/QQ 换源。
1055
 
1056
  所有返回的 URL 强制转 HTTPS,因为手表 audio 组件在部分网络环境下
1057
  无法播放 HTTP 流(混合内容限制 / 运营商劫持)。
1058
+
1059
+ 返回字段新增:
1060
+ - source: 播放源(netease/kuwo/qq/none)
1061
+ - song_id: 回显请求的歌曲 ID(前端用于校验 URL 是否对应正确的歌)
1062
+ - tried_sources: 尝试过的源列表(前端用于显示换源状态)
1063
  """
1064
+ tried_sources: list[str] = []
1065
  async with httpx.AsyncClient() as client:
1066
  ncm_data = await _call_song_url(song_id, level, client)
1067
+ tried_sources.append("netease")
1068
 
1069
  url = ncm_data.get("url")
1070
  if url:
 
1071
  if url.startswith("http://"):
1072
  url_https = "https://" + url[7:]
1073
  logger.info("[song_url] convert http->https: %s -> %s",
 
1081
  "freeTrialInfo": ncm_data.get("freeTrialInfo"),
1082
  "fee": ncm_data.get("fee", 0),
1083
  "source": "netease",
1084
+ "song_id": song_id,
1085
+ "tried_sources": tried_sources,
1086
  }
1087
 
1088
  # 网易云无 URL(通常是 VIP/版权曲),尝试换源
1089
  if unblock:
1090
+ # 先获取歌曲时长,用于换源时验证候选歌曲是否匹配(防止错配)
1091
+ expected_duration = 0.0
1092
+ try:
1093
+ details = await _call_song_detail([song_id], client)
1094
+ if details:
1095
+ dt_ms = details[0].get("dt", 0)
1096
+ if dt_ms:
1097
+ expected_duration = float(dt_ms) / 1000.0
1098
+ # 如果前端没传 song_name/artist,从详情补全
1099
+ if not song_name:
1100
+ song_name = details[0].get("name", "")
1101
+ if not artist:
1102
+ ar = details[0].get("ar") or []
1103
+ artist = "/".join(a.get("name", "") for a in ar)
1104
+ logger.info("[song_url] got duration=%.1fs name=%s for sid=%s",
1105
+ expected_duration, song_name, song_id)
1106
+ except Exception as e:
1107
+ logger.warning("[song_url] fetch detail for duration failed: %s", e)
1108
+
1109
+ kw = await _kuwo_search_and_url(song_name, artist, client, expected_duration)
1110
+ tried_sources.append("kuwo")
1111
  if kw and kw.get("url"):
1112
  kw_url = kw["url"]
1113
  if kw_url.startswith("http://"):
1114
  kw_url = "https://" + kw_url[7:]
1115
  logger.info("[song_url] kuwo convert http->https")
1116
  kw["url"] = kw_url
1117
+ return {"ok": True, **kw, "song_id": song_id,
1118
+ "tried_sources": tried_sources}
1119
 
1120
+ qq = await _qq_search_and_url(song_name, artist, client, expected_duration)
1121
+ tried_sources.append("qq")
1122
  if qq and qq.get("url"):
1123
+ return {"ok": True, **qq, "song_id": song_id,
1124
+ "tried_sources": tried_sources}
1125
 
1126
  return {
1127
  "ok": False,
1128
  "url": None,
1129
  "message": "无可用播放源" + ("(已尝试网易云/酷我/QQ换源)" if unblock else "(可在设置开启换源尝试)"),
1130
  "source": "none",
1131
+ "song_id": song_id,
1132
+ "tried_sources": tried_sources,
1133
  }
1134
 
1135