cortico-world-fish-sing 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # 更新日志
2
+
3
+ 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/);版本号遵循语义化。
4
+
5
+ ## [0.1.7] - 2026-11-01
6
+
7
+ ### 修复
8
+ - **唱歌中途超时(连续两次实战)**:音域分析在 worker 进程内跑,pyin 长时间持有 Python GIL,HTTP 线程无法应答进度轮询(单次请求 10 秒超时),调用端断开后任务连带失败。现在分析挪到**子进程**执行(约 3 秒),HTTP 全程畅通。
9
+
10
+ ## [0.1.6] - 2026-11-01
11
+
12
+ ### 修复
13
+ - **变调推荐改保守策略**:之前按低音端精确对齐,给女声源也推 +6,实测移调的音色损失比低音区稍微出格更伤。现在:源曲与模型音域差 ≤8 半音一律不移调;>8(男声原调区间)才移,上限 +6(负向 -3)。
14
+ - **音域分析限中段 90 秒**:之前对整首歌全量跑基音分析(5 分钟的歌约 40 秒),会把唱歌调用顶超时。现在只分析中段 90 秒,零点几秒完成。
15
+ - `_f0_stats` 接受字符串路径;推荐逻辑 else 分支漏赋值的低级错误。
16
+
17
+ ## [0.1.5] - 2026-11-01
18
+
19
+ ### 修复
20
+ - `pitch` 不传时默认走 `'auto'`,不再静默回落 0——之前"自动推荐"要显式传参才生效,成了摆设。
21
+ - 作品库记录实际使用的半音数(而非 'auto' 字样)。
22
+ - 配置 `voice.pitch` 默认改 `'auto'`;控制台该字段支持填数字(固定移调)或 `'auto'`。
23
+
24
+ ## [0.1.4] - 2026-11-01
25
+
26
+ ### 新增
27
+ - **变调推荐**:`pitch` 支持 `'auto'`。worker 分离出源曲人声后自动对比源曲音域与模型音域(来自训练数据,缓存在 `logs/<实验>/range.json`),唱完的返回带推荐理由。
28
+ - **完成通知**:训练完成/中断时主动告知。渠道可选桌宠(`pushEvent`,她会自己接话)或 QQ(OneBot 文本);QQ 失败自动退桌宠。配置在控制台「完成通知」组。
29
+
30
+ ## [0.1.3] - 2026-11-01
31
+
32
+ ### 修复
33
+ - worker 引擎的 `RVC_ROOT` 默认值不再写死 `D:\AI\rvc`:探测链为 环境变量 → 工作目录上级的 `runtime/rvc` → 旧开发目录(存在才用)。手动起 worker 忘传环境变量时不再直接崩。
34
+ - `FFMPEG` 默认值加 `PATH` 探测兜底。
35
+
36
+ ## [0.1.2] - 2026-10-30
37
+
38
+ ### 新增
39
+ - 首次公开发布到 npm(此前只能手动复制安装)。
40
+ - 控制台「安装与运行时」:一键下载权重(909 MB)+ 装运行时(约 2.5 GB),自托管在数据目录内。
41
+ - 训练面板:音域加权的采样训练、孤儿训练自动发现(重启 Coo 后面板能找回还在跑的训练)、停止按钮可用、中间档清理。
42
+
43
+ ### 修复
44
+ - 训练超时改为"无进展才判卡住"(此前 15 分钟绝对超时会误杀长训练)。
45
+ - Coo 退出不再打断进行中的训练。
46
+
47
+ ## [0.1.1] - 2026-10-30
48
+
49
+ 被 npm 的暂存机制占用,从未真正上线(发布时若遇到 `E409`,升版本号即可)。
50
+
51
+ ## [0.1.0 及以前]
52
+
53
+ 内部版本,手动安装。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-fish-sing",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "让她真的开口唱:B 站点歌或本地文件 → 分离 → 换声 → 混音,本机出声或发 QQ 语音条",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/worker/engine.py CHANGED
@@ -170,6 +170,34 @@ def resample_linear_fallback(data: np.ndarray, src_sr: int, dst_sr: int) -> np.n
170
170
 
171
171
  # ---------------------------------------------------------------- 引擎
172
172
 
173
+ #: 音域分析脚本:由 _f0_stats 在子进程里执行(sys.executable 与引擎同解释器,librosa 可用)。
174
+ #: 输出一行 JSON;无人声/读不了时输出 null。
175
+ _F0_ANALYZE_SRC = r"""
176
+ import json, sys
177
+ import numpy as np
178
+
179
+ def main():
180
+ from pathlib import Path
181
+ import librosa
182
+ path = Path(sys.argv[1])
183
+ max_sec = float(sys.argv[2]) if len(sys.argv) > 2 else 90.0
184
+ dur = librosa.get_duration(path=str(path))
185
+ if dur > max_sec:
186
+ offset = (dur - max_sec) / 2
187
+ y, sr = librosa.load(str(path), sr=16000, mono=True, offset=offset, duration=max_sec)
188
+ else:
189
+ y, sr = librosa.load(str(path), sr=16000, mono=True)
190
+ f0, vflag, _ = librosa.pyin(y, fmin=80.0, fmax=800.0, sr=sr, frame_length=1024)
191
+ v = f0[vflag & ~np.isnan(f0)]
192
+ if v.size < 50:
193
+ print(json.dumps(None))
194
+ return
195
+ q = lambda p: float(np.percentile(v, p))
196
+ print(json.dumps({"p5": q(5), "p25": q(25), "median": q(50), "p95": q(95), "frames": int(v.size)}))
197
+
198
+ main()
199
+ """
200
+
173
201
 
174
202
  class SingEngine:
175
203
  """一个进程里只建一次,之后所有请求共用(模型常驻)。"""
@@ -525,17 +553,22 @@ class SingEngine:
525
553
 
526
554
  # -- 音域与变调推荐 ---------------------------------------------
527
555
 
528
- def _f0_stats(self, path: Path) -> Optional[dict]:
529
- """一段人声的音域统计。分析失败或人声太少返回 None。"""
556
+ def _f0_stats(self, path: "Path | str", *, max_sec: float = 90.0) -> Optional[dict]:
557
+ """一段人声的音域统计。在**子进程**里跑——pyin 会长时间持有 GIL,
558
+ 进程内分析会把 HTTP 线程卡死,进度轮询 10 秒收不到响应就超时(0.1.6 实测)。
559
+ 只分析中段 90 秒,成本可控。"""
560
+ path = Path(path)
530
561
  try:
531
- import librosa
532
- y, sr = librosa.load(str(path), sr=16000, mono=True)
533
- f0, vflag, _ = librosa.pyin(y, fmin=80.0, fmax=800.0, sr=sr, frame_length=1024)
534
- v = f0[vflag & ~np.isnan(f0)]
535
- if v.size < 50:
536
- return None
537
- q = lambda p: float(np.percentile(v, p))
538
- return {"p5": q(5), "p25": q(25), "median": q(50), "p95": q(95), "frames": int(v.size)}
562
+ proc = subprocess.run(
563
+ [sys.executable, "-c", _F0_ANALYZE_SRC, str(path), str(max_sec)],
564
+ capture_output=True, text=True, encoding="utf-8", errors="ignore",
565
+ timeout=180,
566
+ )
567
+ out = (proc.stdout or "").strip().splitlines()
568
+ d = json.loads(out[-1]) if out else None
569
+ if isinstance(d, dict) and d.get("frames", 0) > 0:
570
+ return d
571
+ return None
539
572
  except Exception as e: # noqa: BLE001
540
573
  log(f"音域分析失败({path.name}):{e}")
541
574
  return None
@@ -576,7 +609,11 @@ class SingEngine:
576
609
 
577
610
  @staticmethod
578
611
  def suggest_pitch(source: Optional[dict], model: Optional[dict]) -> dict:
579
- """按低音端(P5)对齐:源曲比模型能唱的更低就往上抬,更高就往下压。"""
612
+ """保守策略:只对明显出音域的源(男声原调)移调,女声源一律 0。
613
+
614
+ 实测教训(0.1.5):按 P5 精确对齐会给女声源推 +6,移调带来的音色损失
615
+ 比低音区稍微出格更伤——模型对数据下限以下有泛化能力,8 个半音以内都兜得住。
616
+ """
580
617
  out = {"pitch": 0, "reason": "", "source": source, "model": model}
581
618
  if not source or not model:
582
619
  out["reason"] = "缺音域数据,保持 0"
@@ -585,16 +622,16 @@ class SingEngine:
585
622
  if sp <= 0 or mp <= 0:
586
623
  out["reason"] = "音域数据无效,保持 0"
587
624
  return out
588
- semi = 12 * math.log2(mp / sp) # >0:源曲低音低于模型下限
589
- if semi > 1.0:
590
- pitch = min(6, math.ceil(semi))
591
- out["reason"] = f"源曲低音 {sp:.0f}Hz 低于模型下限 {mp:.0f}Hz(差 {semi:.1f} 半音),建议 +{pitch}"
592
- elif semi < -1.5:
593
- pitch = max(-6, math.floor(semi))
594
- out["reason"] = f"源曲低音 {sp:.0f}Hz 高于模型下限 {mp:.0f}Hz(差 {-semi:.1f} 半音),建议 {pitch}"
625
+ semi = 12 * math.log2(mp / sp) # >0:源曲低音比模型数据下限低
626
+ if semi > 8:
627
+ pitch = min(6, math.ceil(semi - 8))
628
+ out["reason"] = f"源曲低音 {sp:.0f}Hz 比模型数据下限低 {semi:.1f} 半音(男声原调区间),建议 +{pitch}"
629
+ elif semi < -8:
630
+ pitch = max(-3, math.floor(semi + 8))
631
+ out["reason"] = f"源曲比模型数据高 {(-semi):.1f} 半音,建议 {pitch}"
595
632
  else:
596
633
  pitch = 0
597
- out["reason"] = f"音域匹配(差 {semi:.1f} 半音),保持 0"
634
+ out["reason"] = f"源曲低音 {sp:.0f}Hz 比模型数据下限低 {max(0.0, semi):.1f} 半音,在模型可覆盖范围内(8 半音才移调),保持 0"
598
635
  out["pitch"] = pitch
599
636
  return out
600
637