cortico-world-fish-sing 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -232,6 +232,21 @@ fish_sing(song="晴天 女声翻唱", pick=3) # 指定第 3 条
232
232
  > 男声原调的歌她唱不了(模型只学过女声音域)。遇到就找女声翻唱版,
233
233
  > 别用 `pitch` 硬顶——那只会把音色拉走样。
234
234
 
235
+ ### 变调推荐(0.1.4)
236
+
237
+ `pitch` 支持 `"auto"`:分离出源曲人声后,自动对比源曲音域与模型音域
238
+ (来自训练数据,缓存在 `logs/<实验>/range.json`),算出该移几个半音。
239
+ 唱完的返回里带 `pitchSuggest.reason`,说明为什么推荐这个值。
240
+
241
+ ### 完成通知(0.1.4)
242
+
243
+ 训练完成/中断时主动说一声。配置 → 完成通知:
244
+
245
+ - 渠道 `auto`:配了 QQ 目标就走 QQ 文本,失败自动退桌宠
246
+ - `pet`:只走桌宠(`pushEvent`,她会自己接话)
247
+ - `qq`:只走 QQ(失败同样退桌宠)
248
+ - QQ 目标:`group:<群号>` 或 `private:<QQ号>`,留空 = 只用桌宠
249
+
235
250
  ## 命令行(不起桌宠也能用)
236
251
 
237
252
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-fish-sing",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "让她真的开口唱:B 站点歌或本地文件 → 分离 → 换声 → 混音,本机出声或发 QQ 语音条",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/config.ts CHANGED
@@ -50,6 +50,14 @@ export interface FishSingConfigSection {
50
50
  /** 多条之间的间隔(毫秒),防风控 */
51
51
  gapMs: number;
52
52
  };
53
+ notify?: {
54
+ /** 训练/长任务结束时主动说一声;默认开 */
55
+ enabled?: boolean;
56
+ /** auto=配了 QQ 目标就走 QQ(失败退桌宠),否则桌宠 */
57
+ channel?: 'auto' | 'pet' | 'qq';
58
+ /** QQ 通知发到哪:group:<群号> 或 private:<QQ号>;空 = 只用桌宠 */
59
+ qqTarget?: string;
60
+ };
53
61
  worker: {
54
62
  /** 自托管根目录;留空 = <数据目录>/runtime(推荐) */
55
63
  runtimeRoot: string;
@@ -115,6 +123,11 @@ export const FISHSING_DEFAULTS: FishSingConfigSection = {
115
123
  autoStart: true,
116
124
  startTimeoutMs: 240000,
117
125
  },
126
+ notify: {
127
+ enabled: true,
128
+ channel: 'auto',
129
+ qqTarget: '',
130
+ },
118
131
  };
119
132
 
120
133
  const at = (path: string): string => `worlds.${FISHSING_ID}.${path}`;
@@ -297,6 +310,32 @@ export function fishsingConfigGroups(): ConfigGroup[] {
297
310
  },
298
311
  },
299
312
  },
313
+ {
314
+ id: `world:${FISHSING_ID}.notify`,
315
+ owner: `world:${FISHSING_ID}`,
316
+ schema: {
317
+ type: 'object',
318
+ title: '完成通知(训练/长任务结束时主动说一声)',
319
+ properties: {
320
+ [at('notify.enabled')]: {
321
+ type: 'boolean',
322
+ title: '启用通知',
323
+ description: '训练完成或中断时主动告知。默认开。',
324
+ },
325
+ [at('notify.channel')]: {
326
+ type: 'string',
327
+ enum: ['auto', 'pet', 'qq'],
328
+ title: '渠道',
329
+ description: 'auto=配了 QQ 目标就走 QQ(失败退桌宠),否则桌宠;pet=只桌宠;qq=只 QQ(失败也退桌宠)。',
330
+ },
331
+ [at('notify.qqTarget')]: {
332
+ type: 'string',
333
+ title: 'QQ 通知目标',
334
+ description: 'group:<群号> 或 private:<QQ号>;留空 = 只用桌宠。',
335
+ },
336
+ },
337
+ },
338
+ },
300
339
  {
301
340
  id: `world:${FISHSING_ID}.worker`,
302
341
  owner: `world:${FISHSING_ID}`,
package/src/library.ts CHANGED
@@ -16,7 +16,8 @@ export interface SingParams {
16
16
  index: string;
17
17
  indexRate: number;
18
18
  protect: number;
19
- pitch: number;
19
+ /** 半音数;'auto' = worker 分离后按模型音域自动推荐 */
20
+ pitch: number | 'auto';
20
21
  mode: string;
21
22
  }
22
23
 
package/src/notify.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 长任务(训练、数据集)结束时的主动通知。
3
+ *
4
+ * 渠道:
5
+ * · 桌宠 —— host.pushEvent 把事件送进上下文,Coo 会自己接话(她的人设还能顺便营业);
6
+ * · QQ —— OneBot 文本消息,发到 notify.qqTarget 指定的群/私聊。
7
+ *
8
+ * 兜底规则(用户定的):auto=配了 qqTarget 先走 QQ,失败退桌宠;没配 qqTarget 直接桌宠。
9
+ * 显式 channel='qq' 时 QQ 失败同样退桌宠——通知这种事,宁可多嘴不能哑火。
10
+ */
11
+ import { parseTarget, sendText, type OneBotOptions } from './deliver.ts';
12
+ import type { LoggerLike } from './worker.ts';
13
+
14
+ export interface NotifyConfig {
15
+ enabled?: boolean;
16
+ channel?: 'auto' | 'pet' | 'qq';
17
+ /** QQ 通知发到哪:group:<群号> 或 private:<QQ号>;空 = 只用桌宠 */
18
+ qqTarget?: string;
19
+ }
20
+
21
+ export interface NotifyDeps {
22
+ cfg: NotifyConfig;
23
+ /** WorldHost.pushEvent 的薄封装;事件进上下文,桌宠自己决定怎么说 */
24
+ pushEvent: (e: Record<string, unknown>) => unknown;
25
+ onebot: OneBotOptions;
26
+ log: LoggerLike;
27
+ }
28
+
29
+ export interface NotifyOutcome {
30
+ via: 'pet' | 'qq' | 'off' | 'none';
31
+ note: string;
32
+ }
33
+
34
+ export async function notify(deps: NotifyDeps, title: string, body = ''): Promise<NotifyOutcome> {
35
+ if (deps.cfg.enabled === false) return { via: 'off', note: '通知已关' };
36
+ const text = body ? `${title}\n${body}` : title;
37
+
38
+ const channel = deps.cfg.channel ?? 'auto';
39
+ const target = parseTarget(String(deps.cfg.qqTarget ?? ''));
40
+ const wantQQ = channel === 'qq' || (channel === 'auto' && Boolean(target) && Boolean(deps.onebot.httpUrl));
41
+ if (wantQQ) {
42
+ if (target && deps.onebot.httpUrl) {
43
+ try {
44
+ await sendText(deps.onebot, target, text);
45
+ return { via: 'qq', note: `已发到${target.label}` };
46
+ } catch (e) {
47
+ deps.log.warn(`QQ 通知失败,退回桌宠:${e instanceof Error ? e.message : String(e)}`);
48
+ }
49
+ } else {
50
+ deps.log.warn(`QQ 通知发不了(${!target ? '目标没配' : 'OneBot 地址没配'}),退回桌宠`);
51
+ }
52
+ }
53
+
54
+ try {
55
+ await deps.pushEvent({
56
+ type: 'fishsing.notify',
57
+ origin: 'internal',
58
+ source: 'fish-sing',
59
+ senderKey: 'fish-sing:notify',
60
+ ts: new Date().toISOString(),
61
+ text,
62
+ meta: { title },
63
+ });
64
+ return { via: 'pet', note: '已告诉桌宠' };
65
+ } catch (e) {
66
+ return { via: 'none', note: `桌宠通知也失败:${e instanceof Error ? e.message : String(e)}` };
67
+ }
68
+ }
package/src/sing.ts CHANGED
@@ -52,7 +52,8 @@ export interface SingRequest {
52
52
  song: string;
53
53
  to?: string;
54
54
  mode?: 'full' | 'vocals';
55
- pitch?: number;
55
+ /** 半音数;'auto' = 分离后按模型音域自动推荐(推荐,能挡住"男歌女唱忘移调"的翻车) */
56
+ pitch?: number | 'auto';
56
57
  indexRate?: number;
57
58
  protect?: number;
58
59
  start?: number;
@@ -222,7 +223,7 @@ export async function singSong(req: SingRequest, deps: SingDeps): Promise<SingRe
222
223
  index: deps.voice.index,
223
224
  indexRate: Number(req.indexRate ?? deps.voice.indexRate),
224
225
  protect: Number(req.protect ?? deps.voice.protect),
225
- pitch: Number(req.pitch ?? deps.voice.pitch),
226
+ pitch: req.pitch === 'auto' ? 'auto' : Number(req.pitch ?? deps.voice.pitch),
226
227
  mode: req.mode ?? deps.voice.mode,
227
228
  };
228
229
 
@@ -273,6 +274,8 @@ export async function singSong(req: SingRequest, deps: SingDeps): Promise<SingRe
273
274
  }
274
275
  });
275
276
  info = job.result ?? {};
277
+ const suggest = info.pitchSuggest as { pitch: number; reason: string } | undefined;
278
+ if (suggest?.reason) deps.log.info(`变调推荐:${suggest.reason}`);
276
279
  const raw = String(info.output ?? '');
277
280
  if (!raw) throw new Error('worker 没给出输出文件');
278
281
 
@@ -345,7 +348,11 @@ export async function singSong(req: SingRequest, deps: SingDeps): Promise<SingRe
345
348
  }
346
349
 
347
350
  const elapsed = Math.round((Date.now() - t0) / 1000);
348
- const head = `唱完了《${src.title}》,${human(totalSec)}${cached ? '(走缓存)' : `,用了 ${elapsed} 秒`}`;
351
+ const suggest = info.pitchSuggest as { pitch: number; reason: string } | undefined;
352
+ const autoNote = params.pitch === 'auto' && suggest
353
+ ? `;变调 ${suggest.pitch >= 0 ? '+' : ''}${suggest.pitch}(${suggest.reason})`
354
+ : '';
355
+ const head = `唱完了《${src.title}》,${human(totalSec)}${cached ? '(走缓存)' : `,用了 ${elapsed} 秒`}${autoNote}`;
349
356
  const tail = delivered.length ? `;${delivered.join(',')}` : ';没投递(文件进了作品库,可以用 fish_sing_library 回放)';
350
357
  const warn = problems.length ? `;但${problems.join(';')}` : '';
351
358
  return {
@@ -354,6 +361,7 @@ export async function singSong(req: SingRequest, deps: SingDeps): Promise<SingRe
354
361
  detail: {
355
362
  title: src.title, bvid: src.bvid, duration: totalSec, segments: Math.ceil(totalSec / deps.maxSegmentSec),
356
363
  cached, elapsed, file: mp3, params, delivered, problems,
364
+ pitchSuggest: info.pitchSuggest,
357
365
  },
358
366
  };
359
367
  } catch (e) {
package/src/world.ts CHANGED
@@ -68,6 +68,7 @@ import { duration, type FfmpegDeps } from './audio.ts';
68
68
  import { Player } from './deliver.ts';
69
69
  import { dropFromLibrary, listLibrary, loadLibrary, pruneCache, renameInLibrary, type LibraryOptions } from './library.ts';
70
70
  import { singSong, type SingDeps } from './sing.ts';
71
+ import { notify } from './notify.ts';
71
72
  import { WorkerClient } from './worker.ts';
72
73
 
73
74
  const ENV_PROMPT_FILE = fileURLToPath(new URL('../ENV_PROMPT.md', import.meta.url));
@@ -291,6 +292,23 @@ export class FishSingWorld implements World {
291
292
  return String(this.opts.cfg.send?.token ?? '').trim();
292
293
  }
293
294
 
295
+ /** 长任务结果主动说一声:QQ 配了走 QQ(失败退桌宠),没配就桌宠。详见 notify.ts。 */
296
+ private notifyEvent(title: string, body = ''): Promise<unknown> {
297
+ return notify(
298
+ {
299
+ cfg: this.opts.cfg.notify ?? {},
300
+ pushEvent: (e) => this.host?.pushEvent(e as never),
301
+ onebot: { httpUrl: String(this.opts.cfg.send?.httpUrl ?? ''), token: this.onebotToken() },
302
+ log: this.host?.log ?? { info: () => undefined, warn: () => undefined },
303
+ },
304
+ title,
305
+ body,
306
+ ).then((r) => {
307
+ if (r.via !== 'off') this.host?.log.info(`[fish-sing] 通知(${r.via}):${r.note}`);
308
+ return r;
309
+ });
310
+ }
311
+
294
312
  private deps(): SingDeps {
295
313
  const cfg = this.opts.cfg;
296
314
  return {
@@ -394,7 +412,7 @@ export class FishSingWorld implements World {
394
412
  song: { type: 'string', description: '歌名(可带歌手)、BV 号、B 站链接,或本机音频文件绝对路径' },
395
413
  to: { type: 'string', description: 'local / group:<群号> / private:<QQ号> / both;不填按配置默认' },
396
414
  mode: { type: 'string', enum: ['full', 'vocals'], description: 'full=带伴奏;vocals=清唱(更容易听出音色)' },
397
- pitch: { type: 'number', description: '移调半音;男歌女唱常要 +2~+4,默认 0' },
415
+ pitch: { type: ['number', 'string'], description: "移调半音;'auto'=按模型音域自动推荐(推荐,默认选项);男歌女唱常要 +2~+4" },
398
416
  indexRate: { type: 'number', description: '音色检索强度 0~1;越大越像本人,0.9 以上咬字可能歪' },
399
417
  protect: { type: 'number', description: '气声保护 0~0.5;齿音发毛就调高到 0.4~0.45' },
400
418
  start: { type: 'number', description: '从第几秒开始唱(只想唱副歌时用)' },
@@ -423,7 +441,8 @@ export class FishSingWorld implements World {
423
441
  song: String(args.song ?? ''),
424
442
  to: args.to === undefined ? undefined : String(args.to),
425
443
  mode: args.mode === 'vocals' ? 'vocals' : args.mode === 'full' ? 'full' : undefined,
426
- pitch: args.pitch === undefined ? undefined : Number(args.pitch),
444
+ pitch: args.pitch === undefined ? undefined
445
+ : String(args.pitch).toLowerCase() === 'auto' ? 'auto' : Number(args.pitch),
427
446
  indexRate: args.indexRate === undefined ? undefined : Number(args.indexRate),
428
447
  protect: args.protect === undefined ? undefined : Number(args.protect),
429
448
  start: args.start === undefined ? undefined : Number(args.start),
@@ -963,12 +982,14 @@ export class FishSingWorld implements World {
963
982
  this.host?.log.info(`[fish-sing] 已清理中间档:${done.remove.length} 个 / ${done.freedMb} MB`);
964
983
  }
965
984
  }
985
+ void this.notifyEvent(`训练完成:${exp}`, `${epochs} 轮全部完成。${job.msg}`);
966
986
  })
967
987
  .catch((e: unknown) => {
968
988
  job.state = 'error';
969
989
  job.error = e instanceof Error ? e.message : String(e);
970
990
  job.msg = job.error;
971
991
  this.host?.log.warn(`[fish-sing] 训练中断:${job.error}`);
992
+ void this.notifyEvent(`训练中断:${exp}`, job.error);
972
993
  });
973
994
  return {
974
995
  text: `开始训练「${exp}」:${epochs} 轮 / batch ${batch} / ${m.stats.kept} 片${
package/worker/engine.py CHANGED
@@ -16,6 +16,7 @@ fish-sing worker 的核心:常驻 RVC + 切片换声 + 分离/混音。
16
16
  """
17
17
 
18
18
  import json
19
+ import math
19
20
  import os
20
21
  import re
21
22
  import shutil
@@ -522,19 +523,100 @@ class SingEngine:
522
523
 
523
524
  # -- 一键流程 --------------------------------------------------
524
525
 
526
+ # -- 音域与变调推荐 ---------------------------------------------
527
+
528
+ def _f0_stats(self, path: Path) -> Optional[dict]:
529
+ """一段人声的音域统计。分析失败或人声太少返回 None。"""
530
+ 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)}
539
+ except Exception as e: # noqa: BLE001
540
+ log(f"音域分析失败({path.name}):{e}")
541
+ return None
542
+
543
+ def model_range(self, model: str) -> Optional[dict]:
544
+ """模型(训练数据)的音域。缓存在 logs/<exp>/range.json,训完第一次唱歌时现算一次。"""
545
+ exp = str(model).removesuffix(".pth")
546
+ log_dir = RVC_ROOT / "logs" / exp
547
+ cache = log_dir / "range.json"
548
+ try:
549
+ if cache.is_file():
550
+ d = json.loads(cache.read_text(encoding="utf-8"))
551
+ if d.get("frames", 0) > 0:
552
+ return d
553
+ except Exception: # noqa: BLE001
554
+ pass
555
+ gt = log_dir / "0_gt_wavs"
556
+ if not gt.is_dir():
557
+ return None
558
+ files = sorted(gt.glob("*.wav"))
559
+ if not files:
560
+ return None
561
+ # 隔段采样,覆盖整首歌而不是只听开头
562
+ step = max(1, len(files) // 8)
563
+ picked = files[::step][:8]
564
+ stats = [s for s in (self._f0_stats(f) for f in picked) if s]
565
+ if not stats:
566
+ return None
567
+ merged = {k: sum(s[k] for s in stats) / len(stats) for k in ("p5", "p25", "median", "p95")}
568
+ merged["frames"] = sum(s["frames"] for s in stats)
569
+ merged["files"] = len(stats)
570
+ merged["computedAt"] = time.time()
571
+ try:
572
+ cache.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
573
+ except Exception: # noqa: BLE001
574
+ pass
575
+ return merged
576
+
577
+ @staticmethod
578
+ def suggest_pitch(source: Optional[dict], model: Optional[dict]) -> dict:
579
+ """按低音端(P5)对齐:源曲比模型能唱的更低就往上抬,更高就往下压。"""
580
+ out = {"pitch": 0, "reason": "", "source": source, "model": model}
581
+ if not source or not model:
582
+ out["reason"] = "缺音域数据,保持 0"
583
+ return out
584
+ sp, mp = source.get("p5", 0), model.get("p5", 0)
585
+ if sp <= 0 or mp <= 0:
586
+ out["reason"] = "音域数据无效,保持 0"
587
+ 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}"
595
+ else:
596
+ pitch = 0
597
+ out["reason"] = f"音域匹配(差 {semi:.1f} 半音),保持 0"
598
+ out["pitch"] = pitch
599
+ return out
600
+
525
601
  def song(self, src: Path, work: Path, *, separate: bool = True, mode: str = "full",
526
- pitch: int = 0, index_rate: float = 0.75, protect: float = 0.33,
602
+ pitch: "int | str" = 0, index_rate: float = 0.75, protect: float = 0.33,
527
603
  progress: Optional[Callable[[float, str], None]] = None,
528
604
  cancel: Optional[threading.Event] = None) -> dict:
529
605
  """
530
606
  整首歌:分离 → 换声 → 混音。
531
607
  mode: full=带伴奏 / vocals=只要人声(清唱)/ mixed 同 full 但保留中间文件
608
+ pitch: 半音数;'auto' = 分离后按源曲与模型音域自动推荐。
532
609
  """
533
610
  cancel = cancel or threading.Event()
534
611
  work.mkdir(parents=True, exist_ok=True)
535
612
  t0 = time.time()
536
613
  report = progress or (lambda p, m: None)
537
614
 
615
+ auto_pitch = isinstance(pitch, str) and pitch.strip().lower() == "auto"
616
+ suggestion: Optional[dict] = None
617
+ if auto_pitch:
618
+ pitch = 0
619
+
538
620
  vocal_src = src
539
621
  inst = None
540
622
  if separate:
@@ -544,6 +626,12 @@ class SingEngine:
544
626
  else:
545
627
  vocal_src = to_wav(src, work / "src.wav")
546
628
 
629
+ if auto_pitch:
630
+ report(0.15, "分析音域,推荐变调")
631
+ suggestion = self.suggest_pitch(self._f0_stats(vocal_src), self.model_range(self.model))
632
+ pitch = int(suggestion["pitch"])
633
+ log(f"变调推荐:{suggestion['reason']}")
634
+
547
635
  report(0.2, "加载模型" if not self.ready else "开始换声")
548
636
  conv = work / "vocals_converted.wav"
549
637
  info = self.convert(vocal_src, conv, pitch=pitch, index_rate=index_rate,
@@ -560,7 +648,7 @@ class SingEngine:
560
648
  mix_info = self.mix(conv, inst, out)
561
649
 
562
650
  report(1.0, "完成")
563
- return {
651
+ result = {
564
652
  "output": str(out),
565
653
  "vocals": str(conv),
566
654
  "inst": str(inst) if inst else "",
@@ -569,6 +657,9 @@ class SingEngine:
569
657
  "samplerate": mix_info.get("sr", info["sr"]),
570
658
  "elapsedSec": round(time.time() - t0, 1),
571
659
  }
660
+ if suggestion is not None:
661
+ result["pitchSuggest"] = suggestion
662
+ return result
572
663
 
573
664
 
574
665
  # ---------------------------------------------------------------- 自检
package/worker/server.py CHANGED
@@ -121,7 +121,7 @@ def run_job(jid: str, kind: str, params: dict, cancel: threading.Event) -> dict:
121
121
  src, work,
122
122
  separate=bool(params.get("separate", True)),
123
123
  mode=str(params.get("mode", "full")),
124
- pitch=int(params.get("pitch", 0)),
124
+ pitch=params.get("pitch", 0),
125
125
  index_rate=float(params.get("index_rate", 0.75)),
126
126
  protect=float(params.get("protect", 0.33)),
127
127
  progress=progress,