@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.7.2
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 +93 -18
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/index.js +20 -7
- package/package.json +13 -3
- package/schemas/film.schema.json +599 -0
- package/skills/video-studio/SKILL.md +252 -28
- package/skills/video-studio/assets/film.json +78 -24
- package/skills/video-studio/assets/scenes.py +1005 -42
- package/skills/video-studio/assets/web/engine.js +18 -1
- package/skills/video-studio/assets/web/film.js +29 -4
- package/skills/video-studio/assets/web/index.html +5 -0
- package/skills/video-studio/references/audio-variation.md +158 -0
- package/skills/video-studio/references/audio.md +96 -6
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +13 -1
- package/skills/video-studio/scripts/filmcheck.py +725 -0
- package/skills/video-studio/scripts/filmkit.py +953 -76
- package/skills/video-studio/scripts/gates.py +509 -0
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +942 -45
- package/skills/video-studio/scripts/scorekit.py +1887 -27
- package/skills/video-studio/scripts/selfcheck.py +648 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +405 -0
- package/skills/video-studio/scripts/stylekit.py +1012 -0
- package/skills/video-studio/scripts/voicekit.py +880 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +450 -0
- package/tools/estimate.py +657 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -32,6 +32,7 @@ import asyncio
|
|
|
32
32
|
import base64
|
|
33
33
|
import hashlib
|
|
34
34
|
import json
|
|
35
|
+
import math
|
|
35
36
|
import os
|
|
36
37
|
import re
|
|
37
38
|
import shutil
|
|
@@ -186,6 +187,31 @@ def _format_contract(contract: dict) -> str:
|
|
|
186
187
|
"有" if contract.get("hasFK") else "无"))
|
|
187
188
|
|
|
188
189
|
|
|
190
|
+
async def page_number(page, key: str, *, default=None) -> float:
|
|
191
|
+
"""读页面上的数值契约(window.DURATION / window.FPS),缺了或不是正数就给可操作报错。
|
|
192
|
+
|
|
193
|
+
v0.5 以前直接 float(await page.evaluate("window.DURATION")):页面忘了导出 DURATION
|
|
194
|
+
时得到的是 None,报错只剩一句 "float() argument must be ... 'NoneType'",看不出
|
|
195
|
+
是页面契约缺字段(实测 page_meta / export_cues 都会这样)。
|
|
196
|
+
default 传 None 表示必填(DURATION);传数字表示缺省值(FPS 历史口径是 30)。
|
|
197
|
+
"""
|
|
198
|
+
raw = await page.evaluate("k => window[k]", key)
|
|
199
|
+
if raw is None:
|
|
200
|
+
if default is not None:
|
|
201
|
+
return float(default)
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
"页面没有导出 window.%s(页面契约见 references/web-engine.md §3)。"
|
|
204
|
+
"index.html 里在 film.js 之前写 window.WINDOW_DURATION = 秒数,"
|
|
205
|
+
"或让模板按场景表自动推导(assets/web/film.js 的 resolveDuration)。" % key)
|
|
206
|
+
try:
|
|
207
|
+
val = float(raw)
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
raise RuntimeError("页面 window.%s 不是数字:%r(必须是数字)" % (key, raw))
|
|
210
|
+
if not math.isfinite(val) or val <= 0:
|
|
211
|
+
raise RuntimeError("页面 window.%s 非法:%r(必须是正数)" % (key, raw))
|
|
212
|
+
return val
|
|
213
|
+
|
|
214
|
+
|
|
189
215
|
async def open_page(pw, url, width=W, height=1080, log=False, deterministic=None,
|
|
190
216
|
ready_timeout=300_000):
|
|
191
217
|
"""打开页面并等到 window.ready === true,返回 (browser, page, cdp)。
|
|
@@ -279,8 +305,8 @@ async def _page_meta_async(html_path, deterministic=None):
|
|
|
279
305
|
async with _playwright()() as pw:
|
|
280
306
|
browser, page, _cdp = await open_page(pw, url, deterministic=deterministic)
|
|
281
307
|
try:
|
|
282
|
-
duration =
|
|
283
|
-
fps =
|
|
308
|
+
duration = await page_number(page, "DURATION")
|
|
309
|
+
fps = await page_number(page, "FPS", default=FPS)
|
|
284
310
|
return duration, fps
|
|
285
311
|
finally:
|
|
286
312
|
try:
|
|
@@ -315,7 +341,7 @@ async def _snap_async(html_path, times, out: Path, tag: str, deterministic=None)
|
|
|
315
341
|
async with _playwright()() as pw:
|
|
316
342
|
browser, page, cdp = await open_page(pw, url, W, H, deterministic=deterministic)
|
|
317
343
|
try:
|
|
318
|
-
fps =
|
|
344
|
+
fps = await page_number(page, "FPS", default=FPS)
|
|
319
345
|
for t in times:
|
|
320
346
|
png = await _grab(page, cdp, int(round(t * fps)))
|
|
321
347
|
path = out / ("%st%07.2f.png" % (tag, t))
|
|
@@ -353,6 +379,13 @@ def _norm_cue_list(raw, src: str) -> list:
|
|
|
353
379
|
|
|
354
380
|
|
|
355
381
|
def _norm_events(raw) -> list:
|
|
382
|
+
"""规整页面导出的视觉事件;统一打 src="web" 标记。
|
|
383
|
+
|
|
384
|
+
为什么打标记:render.py 在 engine=web 时会把 film.json / scenes.py 的 cue 与页面 cue
|
|
385
|
+
**三路合并**(三路都是合法的登记方式),但 scenes.py 的 cue 是按 PIL 时间轴算的,
|
|
386
|
+
与页面动画时间基准不同。qa_frames 的 sync_check 靠 src 判断"能不能配",
|
|
387
|
+
否则会拿 29s PIL 时间轴上的 ui_blip 去配 12s 页面上的 ui_blip,报出假 FAIL。
|
|
388
|
+
"""
|
|
356
389
|
out = []
|
|
357
390
|
for item in raw or []:
|
|
358
391
|
if not isinstance(item, dict):
|
|
@@ -362,6 +395,7 @@ def _norm_events(raw) -> list:
|
|
|
362
395
|
continue
|
|
363
396
|
ev["t"] = round(float(ev["t"]), 3)
|
|
364
397
|
ev.setdefault("kind", "misc")
|
|
398
|
+
ev.setdefault("src", "web")
|
|
365
399
|
out.append(ev)
|
|
366
400
|
out.sort(key=lambda e: e["t"])
|
|
367
401
|
return out
|
|
@@ -378,8 +412,8 @@ async def _export_cues_async(html_path, deterministic=None) -> dict:
|
|
|
378
412
|
async with _playwright()() as pw:
|
|
379
413
|
browser, page, _cdp = await open_page(pw, url, W, H, deterministic=deterministic)
|
|
380
414
|
try:
|
|
381
|
-
duration =
|
|
382
|
-
fps =
|
|
415
|
+
duration = await page_number(page, "DURATION")
|
|
416
|
+
fps = await page_number(page, "FPS", default=FPS)
|
|
383
417
|
cues = await page.evaluate("window.getCues ? window.getCues() : []")
|
|
384
418
|
events = await page.evaluate("window.getEvents ? window.getEvents() : []")
|
|
385
419
|
finally:
|
|
@@ -532,17 +566,26 @@ async def _write_chunk(br: _Browser, f0: int, f1: int, dst: Path, fps: float,
|
|
|
532
566
|
part = dst.with_name(dst.name[: -len(".mp4")] + PART_SUFFIX)
|
|
533
567
|
if part.exists():
|
|
534
568
|
part.unlink()
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
569
|
+
# stderr 落临时文件,不用 PIPE:编码器一旦刷出超过管道缓冲(Windows 64 KB)的 stderr,
|
|
570
|
+
# 没人读就会写阻塞;而我们这边 proc.stdin.write 是**同步**阻塞调用(不交出事件循环),
|
|
571
|
+
# 两边互等就是死锁 —— asyncio.wait_for 也救不了它。临时文件没有缓冲上限,也不会堵。
|
|
572
|
+
errf = tempfile.TemporaryFile()
|
|
573
|
+
try:
|
|
574
|
+
proc = subprocess.Popen(
|
|
575
|
+
_encoder_cmd(part, fps, crf, preset),
|
|
576
|
+
stdin=subprocess.PIPE,
|
|
577
|
+
stderr=errf,
|
|
578
|
+
)
|
|
579
|
+
except BaseException:
|
|
580
|
+
errf.close()
|
|
581
|
+
raise
|
|
540
582
|
try:
|
|
541
583
|
for f in range(f0, f1):
|
|
542
584
|
proc.stdin.write(await br.frame(f))
|
|
543
585
|
proc.stdin.close()
|
|
544
586
|
rc = proc.wait(timeout=300)
|
|
545
|
-
|
|
587
|
+
errf.seek(0)
|
|
588
|
+
err = errf.read().decode("utf-8", "replace")
|
|
546
589
|
except BaseException:
|
|
547
590
|
try:
|
|
548
591
|
proc.kill()
|
|
@@ -560,7 +603,7 @@ async def _write_chunk(br: _Browser, f0: int, f1: int, dst: Path, fps: float,
|
|
|
560
603
|
raise
|
|
561
604
|
finally:
|
|
562
605
|
try:
|
|
563
|
-
|
|
606
|
+
errf.close()
|
|
564
607
|
except Exception:
|
|
565
608
|
pass
|
|
566
609
|
if rc != 0:
|
|
@@ -702,7 +745,7 @@ async def _qa_text_async(html_path, t0, t1, step, deterministic=None) -> dict:
|
|
|
702
745
|
browser, page, _cdp = await open_page(pw, url, W, H, deterministic=deterministic)
|
|
703
746
|
try:
|
|
704
747
|
if t1 is None:
|
|
705
|
-
t1 =
|
|
748
|
+
t1 = await page_number(page, "DURATION")
|
|
706
749
|
result = await page.evaluate(
|
|
707
750
|
"([a, b, c]) => window.qaText(a, b, c)", [float(t0), float(t1), float(step)]
|
|
708
751
|
)
|
|
@@ -1262,7 +1305,18 @@ def main(argv=None) -> int:
|
|
|
1262
1305
|
return 2
|
|
1263
1306
|
|
|
1264
1307
|
if args.cmd == "snap":
|
|
1265
|
-
|
|
1308
|
+
try:
|
|
1309
|
+
times = _parse_times(args.times)
|
|
1310
|
+
except ValueError:
|
|
1311
|
+
print("[错误] --times 里有不是数字的项:%r(要逗号分隔的秒数,如 3,9.5,13)"
|
|
1312
|
+
% (args.times,), flush=True)
|
|
1313
|
+
return 2
|
|
1314
|
+
if args.every is not None and float(args.every) <= 0:
|
|
1315
|
+
# 以前 --every 0 被当成"没写"、--every -1 则是死循环:先 page_meta 开一次浏览器,
|
|
1316
|
+
# 之后 while k <= end 永远成立,times 一路追加到内存爆掉(实测 40 s 都出不来)。
|
|
1317
|
+
print("[错误] --every 必须是正数秒(当前 %r);不想等间隔采样就别传 --every" % (args.every,),
|
|
1318
|
+
flush=True)
|
|
1319
|
+
return 2
|
|
1266
1320
|
if args.every:
|
|
1267
1321
|
end = args.t_to if args.t_to is not None else page_meta(args.html)[0]
|
|
1268
1322
|
k = float(args.t_from)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""audio_similarity.py — 配乐/音效同质化诊断 + 音频改造验收(只读,不产片)。
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python tools/audio_similarity.py # 打印 4 组对照
|
|
7
|
+
python tools/audio_similarity.py --strict # 额外按契约阈值判定,失败 exit 1
|
|
8
|
+
python tools/audio_similarity.py --out dir/ # 把对照样本写成 wav 便于人耳试听
|
|
9
|
+
|
|
10
|
+
四组对照(阈值来自 skills/video-studio/references/audio-variation.md §5):
|
|
11
|
+
|
|
12
|
+
A 跨片配乐(v0.7):同风格/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
|
|
13
|
+
换风格 -> 相关性(目标 <= 0.52,且不能比改造前更接近)
|
|
14
|
+
B 跨片音效(v0.7):同 cue 序列,legacy(不传 style/content_fp)必须逐样本相同;
|
|
15
|
+
不同 content_fp 必须不同(否则"跨片音效都一样"没被修掉)
|
|
16
|
+
C 片内分离(v0.7):duck 默认深度(目标 >= 3.5 dB)与配乐/音效的频谱质心对照
|
|
17
|
+
D 旁白优先(v0.7.1):人声段 旁白/背景 响度差(目标 >= 12 dB)+ 侧链让位 >= 4 dB
|
|
18
|
+
|
|
19
|
+
本脚本在改造前是"诊断工具",改造后是"验收工具":--strict 会逐条对阈值判定。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
HERE = Path(__file__).resolve().parent
|
|
31
|
+
SCRIPTS = HERE.parent / "skills" / "video-studio" / "scripts"
|
|
32
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
33
|
+
|
|
34
|
+
# 契约 §5 的阈值(改契约时两边一起改)
|
|
35
|
+
THRESH_A_SAME_STYLE_MAX = 0.85 # 同风格、只换内容指纹的相关性上限
|
|
36
|
+
THRESH_A_CROSS_STYLE_MAX = 0.52 # 换风格的相关性上限(改造前 0.534)
|
|
37
|
+
# 实测余量提示(v0.7.2):本机稳定测到 0.514(连跑 3 次逐位相同),离阈值只剩 0.006。
|
|
38
|
+
# 这个生成链是确定性的,所以换平台/换 numpy 若把 0.514 推到 0.52 以上,
|
|
39
|
+
# 更可能是「风格差异化真的缩水了」而不是浮点噪声 —— 请先查 scorekit 的变奏参数,
|
|
40
|
+
# 别第一反应去抬阈值(抬到 0.53 就离改造前 0.534 只剩 0.004,等于这条门禁白设)。
|
|
41
|
+
THRESH_C_DUCK_MIN = 3.5 # duck 默认深度下限(dB)
|
|
42
|
+
THRESH_D_SNR_MIN = 12.0 # 人声段 旁白/背景 响度差下限(dB)
|
|
43
|
+
|
|
44
|
+
FAILS = []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ok(name, cond, detail):
|
|
48
|
+
print(" [%s] %s %s" % ("PASS" if cond else "FAIL", name, detail))
|
|
49
|
+
if not cond:
|
|
50
|
+
FAILS.append("%s: %s" % (name, detail))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fmt_db(v):
|
|
54
|
+
"""dB 值格式化:NaN(无可判定区间)打印成 'n/a' 而不是让 %f 抛 TypeError。"""
|
|
55
|
+
try:
|
|
56
|
+
v = float(v)
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return "n/a"
|
|
59
|
+
return "n/a" if v != v else ("%.1f" % v)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _rms(x):
|
|
63
|
+
a = np.asarray(x, dtype=np.float64)
|
|
64
|
+
return float(np.sqrt(np.mean(np.square(a)))) if a.size else 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _corr(x, y):
|
|
68
|
+
x = np.asarray(x, dtype=np.float64)
|
|
69
|
+
y = np.asarray(y, dtype=np.float64)
|
|
70
|
+
if x.ndim == 2:
|
|
71
|
+
x = x.mean(axis=1)
|
|
72
|
+
if y.ndim == 2:
|
|
73
|
+
y = y.mean(axis=1)
|
|
74
|
+
n = min(len(x), len(y))
|
|
75
|
+
x, y = x[:n], y[:n]
|
|
76
|
+
if n == 0:
|
|
77
|
+
return float("nan")
|
|
78
|
+
sx, sy = float(np.std(x)), float(np.std(y))
|
|
79
|
+
if sx <= 1e-12 or sy <= 1e-12:
|
|
80
|
+
# 常量/全静音信号:相关系数没有定义。旧实现返回 0,于是"配乐渲染成静音"这种
|
|
81
|
+
# 最严重的回归会让 A1/A2 反而判 PASS(0 <= 0.85)。
|
|
82
|
+
return float("nan")
|
|
83
|
+
return float(np.mean((x - x.mean()) * (y - y.mean())) / (sx * sy))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _centroid_hz(x, sr=48000):
|
|
87
|
+
x = np.asarray(x, dtype=np.float64)
|
|
88
|
+
if x.ndim == 2:
|
|
89
|
+
x = x.mean(axis=1)
|
|
90
|
+
spec = np.abs(np.fft.rfft(x))
|
|
91
|
+
freqs = np.fft.rfftfreq(len(x), 1.0 / sr)
|
|
92
|
+
return float((spec * freqs).sum() / (spec.sum() + 1e-12))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _utf8_stdio():
|
|
96
|
+
for st in (sys.stdout, sys.stderr):
|
|
97
|
+
try:
|
|
98
|
+
if st is not None and not st.isatty():
|
|
99
|
+
st.reconfigure(encoding="utf-8", errors="replace")
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _music(dur, style, fp):
|
|
105
|
+
import scorekit as SK
|
|
106
|
+
samples, _rep = SK.render_styled(dur, style=style, bpm=110, key="Dm",
|
|
107
|
+
mood="steady", seed=7, fingerprint=fp)
|
|
108
|
+
return samples
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cue_track(content_fp=None, style=None, key=None):
|
|
112
|
+
import filmkit as K
|
|
113
|
+
scenes = [{"id": "l01", "kind": "hook", "start": 0.0, "end": 2.0},
|
|
114
|
+
{"id": "l02", "kind": "body", "start": 2.0, "end": 6.0}]
|
|
115
|
+
rows = [{"at": "l02", "type": "ui_click"}, {"at": "l02+0.5", "type": "whoosh"}]
|
|
116
|
+
bus = K.CueBus(scenes=scenes, total=6.0)
|
|
117
|
+
bus.extend(rows)
|
|
118
|
+
return bus.build_audio(total=6.0, verbose=False, style=style,
|
|
119
|
+
content_fp=content_fp, key=key)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _voice_priority_snr(sr=48000, guard_snr_db=12.0):
|
|
123
|
+
"""量"人声比背景响多少":用真实 scorekit 配乐 + 类人声,按 st_audio 的默认参数混音。
|
|
124
|
+
|
|
125
|
+
返回 (人声活跃区 SNR dB, 间隙背景 RMS, 人声处背景额外让位 dB)。
|
|
126
|
+
口径与 `filmkit.voice_snr` 一致:只统计人声活跃区(含间隙会稀释问题)。
|
|
127
|
+
|
|
128
|
+
guard_snr_db 默认 12(= st_audio 默认,量"最终交付"的 SNR);测"侧链本身让位多少"
|
|
129
|
+
时传 0 关掉兜底——否则兜底的整体补压会被算成侧链效果,D2 在侧链被删掉时也能过。
|
|
130
|
+
"""
|
|
131
|
+
import filmkit as K
|
|
132
|
+
import numpy as np
|
|
133
|
+
import scorekit as SK
|
|
134
|
+
n = int(6.0 * sr)
|
|
135
|
+
t = np.arange(n) / float(sr)
|
|
136
|
+
voice = np.zeros(n, dtype=np.float64)
|
|
137
|
+
for a, b in ((0.2, 2.6), (3.4, 5.8)): # 两段"说话",中间留间隙
|
|
138
|
+
i, j = int(a * sr), int(b * sr)
|
|
139
|
+
voice[i:j] = 0.12 * np.sin(2 * np.pi * 220 * t[i:j])
|
|
140
|
+
# 真实配乐(scorekit 输出 RMS 约 0.15,比"类人声"响 —— 正是需要侧链/兜底的场景)
|
|
141
|
+
music, _ = SK.render_styled(6.0, style="tech-deep", bpm=110, key="Am",
|
|
142
|
+
mood="steady", seed=7, fingerprint="d-group")
|
|
143
|
+
music = np.asarray(music, dtype=np.float32)
|
|
144
|
+
out = K.mix_film(voice.astype(np.float32), music, None, total=6.0, fade=0.0,
|
|
145
|
+
music_gain_db=-12.0, voice_duck_db=6.0,
|
|
146
|
+
guard_snr_db=float(guard_snr_db), verbose=False)
|
|
147
|
+
v2 = np.stack([voice, voice], axis=1)
|
|
148
|
+
bg = out - v2
|
|
149
|
+
snr = K.voice_snr(voice.astype(np.float32), bg)
|
|
150
|
+
gap = slice(int(2.8 * sr), int(3.2 * sr))
|
|
151
|
+
gap_rms = float(np.sqrt(np.mean(bg[gap] ** 2)))
|
|
152
|
+
# 人声处背景相对"未压背景"的额外让位:未压背景 = music * 10^(-12/20)
|
|
153
|
+
base_bg = music * 10 ** (-12.0 / 20.0)
|
|
154
|
+
b_talk = slice(int(0.5 * sr), int(2.2 * sr))
|
|
155
|
+
duck_eff = 20 * np.log10((float(np.sqrt(np.mean(base_bg[b_talk] ** 2))) + 1e-12) /
|
|
156
|
+
(float(np.sqrt(np.mean(bg[b_talk] ** 2))) + 1e-12))
|
|
157
|
+
return snr, gap_rms, duck_eff
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _duck_default():
|
|
161
|
+
src = (SCRIPTS / "render.py").read_text(encoding="utf-8")
|
|
162
|
+
m = re.search(r"duck_default\s*=\s*([0-9.]+)", src)
|
|
163
|
+
return float(m.group(1)) if m else float("nan")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv=None):
|
|
167
|
+
_utf8_stdio()
|
|
168
|
+
ap = argparse.ArgumentParser(description="配乐/音效相似度诊断(v0.7 验收)")
|
|
169
|
+
ap.add_argument("--out", default="", help="把对照样本写成 wav 到这个目录")
|
|
170
|
+
ap.add_argument("--dur", type=float, default=20.0, help="配乐对照时长(秒)")
|
|
171
|
+
ap.add_argument("--strict", action="store_true", help="按契约 §5 阈值判定,失败 exit 1")
|
|
172
|
+
args = ap.parse_args(argv)
|
|
173
|
+
|
|
174
|
+
import sfxkit as SX
|
|
175
|
+
|
|
176
|
+
print("=" * 72)
|
|
177
|
+
print("A. 跨片配乐(差异来源 = 风格 / bpm / 调 / 内容指纹 / seed)")
|
|
178
|
+
print("=" * 72)
|
|
179
|
+
a1 = _music(args.dur, "tech-deep", "content-a")
|
|
180
|
+
a2 = _music(args.dur, "tech-deep", "content-b")
|
|
181
|
+
a3 = _music(args.dur, "magazine", "content-a")
|
|
182
|
+
a4 = _music(args.dur, "tech-deep", "content-a") # 同指纹复渲染,验证确定性
|
|
183
|
+
same_style = _corr(a1, a2)
|
|
184
|
+
cross_style = _corr(a1, a3)
|
|
185
|
+
rms_a1 = _rms(a1)
|
|
186
|
+
print(" 配乐非静音(RMS > -60 dBFS) : %s (rms=%.4f,静音时相关系数无定义)"
|
|
187
|
+
% (rms_a1 > 0.001, rms_a1))
|
|
188
|
+
print(" 同风格·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
|
|
189
|
+
% (same_style, THRESH_A_SAME_STYLE_MAX))
|
|
190
|
+
print(" 换风格(tech-deep -> magazine) : corr = %.3f (目标 <= %.2f)"
|
|
191
|
+
% (cross_style, THRESH_A_CROSS_STYLE_MAX))
|
|
192
|
+
print(" 同指纹两次渲染逐样本相同 : %s" % bool(np.array_equal(a1, a4)))
|
|
193
|
+
if args.strict:
|
|
194
|
+
# A0 是前置条件:若配乐渲染成静音/常量,A1/A2 的相关系数没有定义(NaN),
|
|
195
|
+
# 必须显式判 FAIL,而不是让 NaN 比较悄悄变成 False 后只剩一句 corr=nan。
|
|
196
|
+
_ok("A0 配乐非静音", rms_a1 > 0.001, "rms=%.4f" % rms_a1)
|
|
197
|
+
_ok("A1 同风格换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
|
|
198
|
+
"corr=%.3f" % same_style)
|
|
199
|
+
_ok("A2 换风格更不像", cross_style <= THRESH_A_CROSS_STYLE_MAX,
|
|
200
|
+
"corr=%.3f" % cross_style)
|
|
201
|
+
_ok("A3 配乐可复现", bool(np.array_equal(a1, a4)), "同指纹逐样本一致")
|
|
202
|
+
|
|
203
|
+
print()
|
|
204
|
+
print("=" * 72)
|
|
205
|
+
print("B. 跨片音效(CueBus 内容派生 seed)")
|
|
206
|
+
print("=" * 72)
|
|
207
|
+
b_legacy1, b_legacy2 = _cue_track(), _cue_track()
|
|
208
|
+
b_new_a = _cue_track("aaaa1111", "tech-deep", "Dm")
|
|
209
|
+
b_new_b = _cue_track("bbbb2222", "tech-deep", "Dm")
|
|
210
|
+
b_new_a2 = _cue_track("aaaa1111", "tech-deep", "Dm")
|
|
211
|
+
legacy_same = bool(np.array_equal(b_legacy1, b_legacy2))
|
|
212
|
+
cross_diff = not bool(np.array_equal(b_new_a, b_new_b))
|
|
213
|
+
repeat_same = bool(np.array_equal(b_new_a, b_new_a2))
|
|
214
|
+
print(" legacy(不传 style/fp)两次相同 : %s (必须 True,v0.4 兼容)" % legacy_same)
|
|
215
|
+
print(" 不同内容指纹 -> 音效轨不同 : %s (必须 True)" % cross_diff)
|
|
216
|
+
print(" 同内容指纹两次相同 : %s (必须 True,可复现)" % repeat_same)
|
|
217
|
+
if args.strict:
|
|
218
|
+
_ok("B1 legacy 逐样本兼容", legacy_same, "两次渲染相同")
|
|
219
|
+
_ok("B2 跨片音效可区分", cross_diff, "不同 content_fp 不再逐样本相同")
|
|
220
|
+
_ok("B3 音效可复现", repeat_same, "同 content_fp 逐样本一致")
|
|
221
|
+
|
|
222
|
+
print()
|
|
223
|
+
print("=" * 72)
|
|
224
|
+
print("C. 片内分离:混音 duck 深度 + 频谱质心对照")
|
|
225
|
+
print("=" * 72)
|
|
226
|
+
duck = _duck_default()
|
|
227
|
+
print(" duck 默认深度 : %.1f dB (目标 >= %.1f)"
|
|
228
|
+
% (duck, THRESH_C_DUCK_MIN))
|
|
229
|
+
for name, x in (("impact", SX.render("impact", seed=1000)),
|
|
230
|
+
("whoosh", SX.render("whoosh", seed=1000)),
|
|
231
|
+
("riser", SX.render("riser", seed=1000)),
|
|
232
|
+
("music(前4s)", a1[: 48000 * 4])):
|
|
233
|
+
print(" %-12s 频谱质心 ~ %6.0f Hz" % (name, _centroid_hz(x)))
|
|
234
|
+
print(" 提示音调性跟随:in_key 现在接受 key=(配乐调性),不再硬编码 Dm。")
|
|
235
|
+
if args.strict:
|
|
236
|
+
_ok("C1 duck 加深", duck >= THRESH_C_DUCK_MIN, "duck=%.1f dB" % duck)
|
|
237
|
+
|
|
238
|
+
print()
|
|
239
|
+
print("=" * 72)
|
|
240
|
+
print("D. 旁白优先:人声与背景的响度差(v0.7.1)")
|
|
241
|
+
print("=" * 72)
|
|
242
|
+
snr_db, base_db, _duck_with_guard = _voice_priority_snr()
|
|
243
|
+
# 侧链让位单独量一次(guard_snr_db=0):兜底的整体补压也是"背景变低",
|
|
244
|
+
# 混在一起算会把 D2 变成"guard 生效"的证据,侧链被删掉时 D2 照样 PASS。
|
|
245
|
+
_snr_no_guard, _gap_no_guard, duck_db_eff = _voice_priority_snr(guard_snr_db=0.0)
|
|
246
|
+
print(" 人声段 旁白/背景 响度差 : %s dB (目标 >= %.1f,含 guard 兜底)"
|
|
247
|
+
% (_fmt_db(snr_db), THRESH_D_SNR_MIN))
|
|
248
|
+
print(" 人声处背景额外让位 : %s dB (目标 >= 4,仅侧链,guard 关)"
|
|
249
|
+
% _fmt_db(duck_db_eff))
|
|
250
|
+
print(" 间隙里背景电平 : %.3f(应为恢复后的基线)" % base_db)
|
|
251
|
+
if args.strict:
|
|
252
|
+
_ok("D1 旁白听得清", snr_db is not None and snr_db == snr_db
|
|
253
|
+
and snr_db >= THRESH_D_SNR_MIN, "SNR=%s dB" % _fmt_db(snr_db))
|
|
254
|
+
_ok("D2 旁白侧链生效", duck_db_eff >= 4.0, "让位 %s dB" % _fmt_db(duck_db_eff))
|
|
255
|
+
|
|
256
|
+
if args.out:
|
|
257
|
+
out = Path(args.out)
|
|
258
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
from scorekit import save as music_save
|
|
260
|
+
music_save(out / "music_tech_deep_a.wav", a1)
|
|
261
|
+
music_save(out / "music_tech_deep_b.wav", a2)
|
|
262
|
+
music_save(out / "music_magazine.wav", a3)
|
|
263
|
+
for t in ("whoosh", "riser", "impact"):
|
|
264
|
+
SX.save(out / ("sfx_%s.wav" % t), SX.render(t, seed=1000))
|
|
265
|
+
print("\n对照样本已写入: %s" % out)
|
|
266
|
+
|
|
267
|
+
if args.strict:
|
|
268
|
+
print()
|
|
269
|
+
if FAILS:
|
|
270
|
+
print("STRICT FAIL: %d 项未达标" % len(FAILS))
|
|
271
|
+
for f in FAILS:
|
|
272
|
+
print(" - %s" % f)
|
|
273
|
+
return 1
|
|
274
|
+
print("STRICT PASS: 全部阈值达标(A/B/C/D 四组)")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
raise SystemExit(main())
|