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 +15 -0
- package/package.json +1 -1
- package/src/config.ts +39 -0
- package/src/library.ts +2 -1
- package/src/notify.ts +68 -0
- package/src/sing.ts +11 -3
- package/src/world.ts +23 -2
- package/worker/engine.py +93 -2
- package/worker/server.py +1 -1
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
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
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
|
-
|
|
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
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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=
|
|
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,
|