@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.
Files changed (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. 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 = float(await page.evaluate("window.DURATION"))
283
- fps = float(await page.evaluate("window.FPS || 30"))
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 = float(await page.evaluate("window.FPS || 30"))
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 = float(await page.evaluate("window.DURATION"))
382
- fps = float(await page.evaluate("window.FPS || 30"))
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
- proc = subprocess.Popen(
536
- _encoder_cmd(part, fps, crf, preset),
537
- stdin=subprocess.PIPE,
538
- stderr=subprocess.PIPE,
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
- err = (proc.stderr.read() or b"").decode("utf-8", "replace")
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
- proc.stderr.close()
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 = float(await page.evaluate("window.DURATION"))
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
- times = _parse_times(args.times)
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())