@dsh-gis/dsh-video-studio-skill 0.6.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 (32) hide show
  1. package/README.md +41 -16
  2. package/index.js +20 -7
  3. package/package.json +4 -2
  4. package/schemas/film.schema.json +75 -4
  5. package/skills/video-studio/SKILL.md +95 -23
  6. package/skills/video-studio/assets/film.json +7 -14
  7. package/skills/video-studio/assets/scenes.py +115 -10
  8. package/skills/video-studio/assets/web/engine.js +18 -1
  9. package/skills/video-studio/assets/web/film.js +29 -4
  10. package/skills/video-studio/assets/web/index.html +5 -0
  11. package/skills/video-studio/references/audio-variation.md +158 -0
  12. package/skills/video-studio/references/audio.md +67 -5
  13. package/skills/video-studio/references/change-impact.md +1 -1
  14. package/skills/video-studio/references/quality-gates.md +1 -1
  15. package/skills/video-studio/references/web-engine.md +1 -1
  16. package/skills/video-studio/requirements.txt +2 -1
  17. package/skills/video-studio/scripts/filmcheck.py +81 -12
  18. package/skills/video-studio/scripts/filmkit.py +271 -18
  19. package/skills/video-studio/scripts/gates.py +75 -12
  20. package/skills/video-studio/scripts/qa_frames.py +109 -19
  21. package/skills/video-studio/scripts/render.py +420 -51
  22. package/skills/video-studio/scripts/scorekit.py +289 -46
  23. package/skills/video-studio/scripts/selfcheck.py +302 -2
  24. package/skills/video-studio/scripts/sfxkit.py +132 -30
  25. package/skills/video-studio/scripts/stagewatch.py +141 -31
  26. package/skills/video-studio/scripts/stylekit.py +94 -6
  27. package/skills/video-studio/scripts/voicekit.py +8 -0
  28. package/skills/video-studio/scripts/webengine.py +68 -14
  29. package/tools/audio_similarity.py +279 -0
  30. package/tools/ci_check.py +145 -34
  31. package/tools/estimate.py +133 -54
  32. package/tools/selftest_noscipy.py +70 -0
@@ -33,6 +33,20 @@
33
33
  27 (v0.5.0)内容指纹:同剧本稳定、改标点不换风格、换题材才换
34
34
  28 (v0.5.0)声线计划:4 句样片 >=2 把声音,legacy 仍是单声线
35
35
  29 (v0.5.0)字体族与主题字幕:换族换缓存键,浅色主题不画黑背板
36
+ 30 (v0.6.0)门禁账本与签核:pass/fail/skipped 三档不许混,签核必须绑内容指纹
37
+ 31 (v0.6.0)阶段新鲜度:改了输入必须报"过时",不许用旧产物静默出片
38
+ 32 (v0.6.0)并行渲染主题序列化:JSON 往返后重解风格必须回到 tuple
39
+ 33 (v0.7.0)音效跨片可区分:内容派生 seed 让跨片不同、legacy 逐样本不变
40
+ 34 (v0.7.0)配乐/音效片内分离:duck 默认加深,配乐给音效让位
41
+ 35 (v0.7.1)旁白优先:人声出现时背景必须再压、间隙恢复、人声电平不动
42
+ 36 (v0.7.1)旁白优先默认值:配乐 -12 dB 基线 + 旁白侧链 6 dB(读 st_audio 源码)
43
+ 37 (v0.7.1)旁白清晰度 SNR 兜底:背景天然太响时自动补压到 >=12 dB
44
+ 38 (v0.7.1)无旁白时混音不受影响(voice_env/guard 不得空转改电平)
45
+ 39 (v0.7.2)音频边界:极短旁白不崩、NaN/Inf 拒绝写盘、负起点与 seg=0 有守卫
46
+ 40 (v0.7.2)QC 报告落点只有一个来源(--out 的目录,不许 accept 另算一套)
47
+ 41 (v0.7.2)空门禁账本不许写成"通过"(没记录 != 通过)
48
+ 42 (v0.7.2)单阶段产物归属:不许把别的 stage 写的文件算成自己的(假 stale)
49
+ 43 (v0.7.2)theme_overrides 必须参与主题体检(否则能造出"看不清"的成片)
36
50
  """
37
51
  import argparse
38
52
  import io
@@ -68,7 +82,9 @@ def _setup_stdio():
68
82
  def run(name, fn):
69
83
  try:
70
84
  detail = fn()
71
- if detail == "skip":
85
+ # 前缀判定:SKIP 的理由要能写给人看("skip(缺 xxx.py)"),但 "skipped != 通过"
86
+ # 这条语义不能被理由文字冲掉——旧实现只认精确 "skip",带理由的降级会被记成 PASS。
87
+ if str(detail or "").strip().lower().startswith("skip"):
72
88
  RESULTS.append(("SKIP", name, "-"))
73
89
  print(" SKIP %s" % name)
74
90
  else:
@@ -430,7 +446,11 @@ def t_music_plan():
430
446
  file_peak = float(np.max(np.abs(K.wav_read(file_out)[0])))
431
447
  assert "music.mode=off" in buf.getvalue(), "off 没有打印说明(静默跳过也不行)"
432
448
  assert off_peak < 0.01, "music.mode=off 仍把旧 music.wav 混进来了(峰值 %.3f)" % off_peak
433
- assert file_peak > 0.3, "music.mode=file 没有用外部配乐(峰值 %.3f)" % file_peak
449
+ # v0.7.1:断言改成"相对 off 明显变大"而不是绝对 0.3——
450
+ # 配乐基线降到 -12 dB(旁白优先)后,0.8 幅度的外部配乐混出来约 0.2;
451
+ # 绝对阈值会把"旁白优先调好了"判成"没读到外部配乐",这类断言会被调参牵着走。
452
+ assert file_peak > 0.1 and file_peak > off_peak * 3.0, (
453
+ "music.mode=file 没有用外部配乐(off %.3f -> file %.3f)" % (off_peak, file_peak))
434
454
  return "off 峰值 %.3f / file 峰值 %.3f" % (off_peak, file_peak)
435
455
 
436
456
 
@@ -852,6 +872,275 @@ def _load_scenes_mod():
852
872
  except Exception: # noqa: BLE001
853
873
  return None
854
874
 
875
+ def t_sfx_content_seed():
876
+ """v0.7:音效 seed 必须随内容变化,但 legacy 路径必须逐样本不变。
877
+
878
+ - 不传 style/content_fp -> 两次渲染逐样本相同(v0.4 兼容,不破老片);
879
+ - 传了不同 content_fp -> 必须不同(否则跨片音效还是"都一样");
880
+ - 同一 content_fp -> 逐样本相同(可复现,是程序化视频的底线)。
881
+ """
882
+ import numpy as np
883
+ scenes = [{"id": "l01", "kind": "hook", "start": 0.0, "end": 2.0},
884
+ {"id": "l02", "kind": "body", "start": 2.0, "end": 6.0}]
885
+ rows = [{"at": "l02", "type": "ui_click"}, {"at": "l02+0.5", "type": "whoosh"}]
886
+
887
+ def build(**kw):
888
+ b = K.CueBus(scenes=scenes, total=6.0)
889
+ b.extend(rows)
890
+ return b.build_audio(total=6.0, verbose=False, **kw)
891
+
892
+ try:
893
+ legacy_a, legacy_b = build(), build()
894
+ except Exception: # noqa: BLE001
895
+ return "skip" # sfxkit 不可用
896
+ assert np.array_equal(legacy_a, legacy_b), "legacy 两次渲染不一致(v0.4 兼容被破坏)"
897
+ a = build(style="tech-deep", content_fp="aaaa1111", key="Dm")
898
+ b = build(style="tech-deep", content_fp="bbbb2222", key="Dm")
899
+ assert not np.array_equal(a, b), "不同内容指纹得到相同音效轨:跨片音效仍一模一样"
900
+ a2 = build(style="tech-deep", content_fp="aaaa1111", key="Dm")
901
+ assert np.array_equal(a, a2), "同内容指纹渲染不可复现"
902
+ return "legacy 逐样本相同 / 跨片可区分"
903
+
904
+
905
+ def t_audio_separation():
906
+ """v0.7:混音侧 duck 默认必须加深(片内配乐给音效让位)。
907
+
908
+ 直接读 render.py 的常量文本而不是跑全链:这条断言盯的是"默认值没被改回去"
909
+ (v0.4 的 3.0 dB 在段落过门时配乐几乎和音效等高,是"听起来像同一套"的成因之一)。
910
+ """
911
+ import re
912
+ path = os.path.join(HERE, "render.py")
913
+ src = open(path, encoding="utf-8").read()
914
+ m = re.search(r"duck_default\s*=\s*([0-9.]+)", src)
915
+ assert m, "render.py 里找不到 duck_default"
916
+ base = float(m.group(1))
917
+ assert base >= 3.5, "duck_default=%.1f dB 太浅(v0.7 要求 >= 3.5)" % base
918
+ for sid, want in (("mono", 3.0), ("cinema", 5.5), ("epic", 5.5)):
919
+ mm = re.search(r'"%s"\s*:\s*([0-9.]+)' % sid, src)
920
+ assert mm, "render.py 里找不到风格 %s 的 duck 值" % sid
921
+ assert float(mm.group(1)) >= want, "%s duck=%.1f < %.1f" % (sid, float(mm.group(1)), want)
922
+ return "duck 默认 %.1f dB(cinema/epic 5.5)" % base
923
+
924
+
925
+ def t_voice_priority():
926
+ """v0.7.1 旁白优先:人声出现时背景必须再压,人声间隙背景恢复。
927
+
928
+ 以前配乐基线只比旁白低 1.5 dB,视频以旁白为主时观众要费力分辨字。
929
+ 修法:`mix_film` 用旁白包络做侧链——人声处配乐 -6 dB、音效 -3 dB,间隙回原电平。
930
+ 这里分别验证「人声段被压」和「静音段恢复」,以及旁白自身电平不被影响。
931
+ """
932
+ import numpy as np
933
+ n = int(2.0 * K.SR)
934
+ t = np.arange(n) / float(K.SR)
935
+ voice = np.zeros(n, dtype=np.float32)
936
+ voice[: n // 2] = (0.2 * np.sin(2 * np.pi * 220 * t[: n // 2])).astype(np.float32)
937
+ music = np.full((n, 2), 0.2, dtype=np.float32)
938
+ out = K.mix_film(voice, music, None, total=2.0, fade=0.0,
939
+ music_gain_db=0.0, voice_duck_db=6.0, guard_snr_db=0.0)
940
+ v = np.stack([voice, voice], axis=1)
941
+ music_part = out - v # 混音是线性叠加,减掉人声即背景分量
942
+ # 人声段:取中段(避开包络起音/释放的斜坡)
943
+ talk = music_part[int(0.35 * K.SR): int(0.45 * K.SR)]
944
+ gap = music_part[int(1.55 * K.SR): int(1.95 * K.SR)]
945
+ talk_rms = float(np.sqrt(np.mean(talk * talk)))
946
+ gap_rms = float(np.sqrt(np.mean(gap * gap)))
947
+ # 期望:人声段 ≈ 0.2*10^(-6/20)=0.100;间隙 ≈ 0.2
948
+ assert abs(talk_rms - 0.1003) < 0.03, "人声段背景没被压到 -6 dB(RMS %.3f,期望 0.100)" % talk_rms
949
+ assert abs(gap_rms - 0.2) < 0.03, "人声间隙背景没恢复(RMS %.3f,期望 0.200)" % gap_rms
950
+ # SNR 口径:只统计人声活跃区(含间隙会把问题稀释掉)。
951
+ # 这里配乐基线是 0 dB,只有 -6 dB 侧链 -> SNR 应为 -3+6 = 3 dB 左右。
952
+ snr = K.voice_snr(voice, music_part)
953
+ assert snr is not None and 2.0 <= snr <= 4.0, "voice_snr 口径异常(%.1f dB,期望约 3)" % (snr or -99)
954
+ # 旁白自身电平不能被侧链动过
955
+ voice_part = out[:, 0] - music_part[:, 0]
956
+ seg = voice_part[int(0.35 * K.SR): int(0.45 * K.SR)]
957
+ want = voice[int(0.35 * K.SR): int(0.45 * K.SR)]
958
+ assert float(np.max(np.abs(seg - want))) < 1e-4, "旁白电平被侧链改变(旁白优先不能压人声)"
959
+ return "人声段背景 %.3f / 间隙 %.3f(dB 差 %.1f)" % (
960
+ talk_rms, gap_rms, 20 * np.log10(gap_rms / (talk_rms + 1e-9)))
961
+
962
+
963
+ def t_music_gain_default():
964
+ """v0.7.1:配乐默认增益必须是 -12 dB(beside 人声),不是旧版的 -1.5。
965
+
966
+ 这条盯的是 `render.st_audio` 的默认值:`mus.get("gain_db", -12.0)`。
967
+ 直接读源码文本,避免"跑全链才知道默认值"。
968
+ """
969
+ src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
970
+ assert 'gain_db", -12.0' in src, "st_audio 的配乐默认增益不是 -12 dB"
971
+ assert 'voice_duck_db", 6.0' in src, "st_audio 没有开旁白侧链(voice_duck_db=6)"
972
+ return "配乐 -12 dB + 旁白侧链 6 dB"
973
+
974
+
975
+ def t_snr_guard():
976
+ """v0.7.1 SNR 兜底:背景天然太响时,混音后必须自己把它压到"人声听得清"。
977
+
978
+ 构造一段人声 + 一段刻意很响的配乐(比人声高 10 dB),不设侧链、只靠 guard:
979
+ `mix_film(guard_snr_db=12)` 后实测人声活跃区的 人声/背景 必须 >= 12 dB,
980
+ 且人声本身电平不能被兜底改过(兜底只压背景)。
981
+ """
982
+ import numpy as np
983
+ sr = K.SR
984
+ n = int(3.0 * sr)
985
+ t = np.arange(n) / float(sr)
986
+ voice = np.zeros(n, dtype=np.float32)
987
+ voice[int(0.4*sr):int(2.6*sr)] = (0.2 * np.sin(2*np.pi*220*t[int(0.4*sr):int(2.6*sr)])).astype(np.float32)
988
+ # 配乐 RMS 约 0.6,比人声(RMS 0.141)高 ~12.5 dB —— 刻意做成"盖住旁白"的反例
989
+ loud = np.full((n, 2), 0.6, dtype=np.float32)
990
+ out = K.mix_film(voice, loud, None, total=3.0, fade=0.0,
991
+ music_gain_db=0.0, voice_duck_db=0.0, voice_duck_sfx_db=0.0,
992
+ guard_snr_db=12.0, guard_cap_db=30.0, verbose=False)
993
+ v2 = np.stack([voice, voice], axis=1)
994
+ bg = out - v2
995
+ snr = K.voice_snr(voice, bg)
996
+ assert snr is not None and snr >= 11.9, "SNR 兜底没生效:人声/背景 %.1f dB(要求 >=12)" % (snr or -99)
997
+ # 人声电平不能被兜底动过
998
+ seg_v = (out[:, 0] - bg[:, 0])[int(1.0*sr):int(2.0*sr)]
999
+ want = voice[int(1.0*sr):int(2.0*sr)]
1000
+ assert float(np.max(np.abs(seg_v - want))) < 1e-4, "SNR 兜底改到了人声电平"
1001
+ return "兜底前约 -12.5 dB -> 兜底后 %.1f dB" % snr
1002
+
1003
+
1004
+ def t_mix_no_voice():
1005
+ """没有旁白时混音行为不变:没有 voice_env/guard 可用,背景应原样输出。"""
1006
+ import numpy as np
1007
+ n = int(0.5 * K.SR)
1008
+ music = np.full((n, 2), 0.2, dtype=np.float32)
1009
+ out = K.mix_film(None, music, None, total=0.5, fade=0.0, music_gain_db=0.0, verbose=False)
1010
+ assert abs(float(np.max(np.abs(out))) - 0.2) < 1e-5, "无旁白时配乐被改动了(峰值 %.3f)" % float(np.max(np.abs(out)))
1011
+ return "无旁白时背景不被压"
1012
+
1013
+ # ------------------------------------------------------------------ v0.7.2 新增(全项目深审)
1014
+ def t_audio_edge_inputs():
1015
+ """39 音频入口的边界:极短旁白不能崩、NaN/Inf 必须拒绝、seg=0 不许死循环。
1016
+
1017
+ 这几条都是"真跑一次就会遇到"的输入。v0.7.1 及以前的行为:
1018
+ * voice_env 在极短音频上 np.convolve(mode="same") 返回核长 -> np.maximum 广播失败;
1019
+ * duck_voice 在"旁白比背景短"时包络与背景长度不同 -> 广播失败;
1020
+ * wav_write 对 NaN 做 clip 再 astype(int16),把 NaN 静默写成 0(写出损坏 WAV 却不报错);
1021
+ * AudioBus.place 传负起点时负下标切片 -> n=0 -> 广播崩;
1022
+ * music_bed 的 seg=0 让 t 不前进 -> while 死循环(有 100000 次上限,6 秒片要 2 分钟)。
1023
+ """
1024
+ import numpy as np
1025
+ short = np.full(600, 0.2, dtype=np.float32) # 12.5 ms
1026
+ assert len(K.voice_env(short)) >= 1, "voice_env 对极短音频返回空包络"
1027
+ bg = np.full((K.SR, 2), 0.2, dtype=np.float32)
1028
+ got = K.duck_voice(bg, short, depth_db=6.0)
1029
+ assert got.shape == (K.SR, 2), "duck_voice 在旁白比背景短时形状不对: %r" % (got.shape,)
1030
+ m = np.full((600, 2), 0.2, dtype=np.float32)
1031
+ assert K.duck(m, short).shape == (600, 2), "duck 在短信号上形状不对"
1032
+
1033
+ with tempfile.TemporaryDirectory() as d:
1034
+ for name, bad in (("nan", np.float32("nan")), ("inf", np.float32("inf"))):
1035
+ p = os.path.join(d, name + ".wav")
1036
+ try:
1037
+ K.wav_write(p, np.array([0.1, bad], dtype=np.float32))
1038
+ except ValueError:
1039
+ assert not os.path.exists(p), "wav_write 报错了却仍写出文件(%s)" % p
1040
+ else:
1041
+ raise AssertionError("wav_write 把 %s 静默写进了 WAV(应当报错)" % name)
1042
+
1043
+ bus = K.AudioBus(2, K.SR, 1.0)
1044
+ bus.place(np.ones(1000, dtype=np.float32), -0.25) # 负起点:从 0 秒开始,不许崩
1045
+ bus2 = K.AudioBus(2, K.SR, 0.3)
1046
+ try:
1047
+ bus2.music_bed([[220.0, 277.0, 330.0]], seg=0.0)
1048
+ except ValueError:
1049
+ pass
1050
+ else:
1051
+ raise AssertionError("music_bed(seg=0) 没被拒绝(会死循环)")
1052
+ return "极短旁白不崩 / NaN·Inf 拒绝 / 负起点与 seg=0 有守卫"
1053
+
1054
+
1055
+ def t_report_single_source():
1056
+ """40 QC 报告落点只能有一个来源:--out 的目录。
1057
+
1058
+ v0.7.2 修的真 bug:all/mux/qa 用 dirname(abspath(out)),accept 用
1059
+ dirname(abspath(work))/out。--out 指到 deliver/ 时,签核更新的是另一份没人
1060
+ 交付的文件,交付出去的那份仍是"未签核"——正好打穿"报告是交付凭据"这条承诺。
1061
+ """
1062
+ import types
1063
+ try:
1064
+ import render
1065
+ except Exception as e: # noqa: BLE001
1066
+ return "skip(import render 失败:%s)" % e
1067
+ assert hasattr(render, "_report_path"), "render 没有统一的 _report_path"
1068
+ a = types.SimpleNamespace(out=os.path.join("deliver", "film.mp4"), work="work")
1069
+ got = render._report_path(a)
1070
+ want = os.path.join("deliver", "QC-REPORT.md")
1071
+ assert os.path.abspath(got) == os.path.abspath(want), \
1072
+ "报告没落在 --out 旁边:%s(期望 %s)" % (got, want)
1073
+ src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
1074
+ n = src.count('"QC-REPORT.md"')
1075
+ assert n == 1, "QC-REPORT.md 路径出现了 %d 处(应只有 _report_path 一处,否则又会两边漂移)" % n
1076
+ return "报告落点统一到 --out 目录(源码里只有一处拼接)"
1077
+
1078
+
1079
+ def t_empty_ledger_not_pass():
1080
+ """41 门禁账本为空时,QC 报告不许说"通过"。
1081
+
1082
+ render_report 原来 if counts.get(FAIL): ... else: **通过**——gates.json 被删、
1083
+ 写坏、或根本没跑 qa 时,交付凭据会是一份宣称通过的纸。这条盯的就是那个 else。
1084
+ """
1085
+ try:
1086
+ import gates as GT
1087
+ except Exception as e: # noqa: BLE001
1088
+ return "skip(没有 gates.py:%s)" % e
1089
+ with tempfile.TemporaryDirectory() as d:
1090
+ p = GT.write_report(d, os.path.join(d, "QC-REPORT.md"))
1091
+ txt = open(p, encoding="utf-8").read()
1092
+ assert "**通过**" not in txt, "空门禁账本却写成『通过』(交付凭据撒谎)"
1093
+ assert "未判定" in txt, "空门禁账本没写『未判定』(用户会以为跑过了)"
1094
+ return "0 条门禁 -> 未判定(不是通过)"
1095
+
1096
+
1097
+ def t_stage_io_no_phantom_output():
1098
+ """42 单阶段连跑不许报"自己的产物不见了"(假 stale)。
1099
+
1100
+ v0.7.2 修:stagewatch 的 STAGE_IO[style].outputs 里把 voice_plan.json 也算成
1101
+ style 的产物,而它其实是 tts 阶段写的(render.py 的 st_tts)。于是 --stage style
1102
+ 连跑两次,第二次必然报"产物 voice_plan.json 不见了";打开 --strict-stale 就变成
1103
+ 硬失败——SKILL.md 第 3 步(全新项目第一条生产命令)自己走不通。
1104
+ """
1105
+ try:
1106
+ import stagewatch as SW
1107
+ except Exception as e: # noqa: BLE001
1108
+ return "skip(没有 stagewatch.py:%s)" % e
1109
+ style_out = list((SW.STAGE_IO.get("style") or {}).get("outputs") or [])
1110
+ tts_out = list((SW.STAGE_IO.get("tts") or {}).get("outputs") or [])
1111
+ if "voice_plan.json" in style_out:
1112
+ assert "voice_plan.json" in tts_out, \
1113
+ "voice_plan.json 被算成 style 的产物,但没有别的 stage 认领它(假 stale 会回来)"
1114
+ dup = [(n, st) for st, io in SW.STAGE_IO.items() for n in (io.get("outputs") or [])
1115
+ if n in style_out and st != "style"]
1116
+ assert not dup, \
1117
+ "style 的产物被别的 stage 也声明成输出(会产生假 stale):%r" % (dup,)
1118
+ return "style 产物 %s / tts 产物 %s" % (",".join(sorted(style_out)), ",".join(sorted(tts_out)))
1119
+
1120
+
1121
+ def t_theme_overrides_linted():
1122
+ """43 theme_overrides 必须参与主题体检(否则用户能造出"看不清"的成片)。
1123
+
1124
+ v0.7.2 修的真 bug:render.resolve_style 以前只体检**基础主题**
1125
+ (ST.THEMES[theme_id]),而 scenes 实际用的是 plan[theme](基础主题 + film.json 的
1126
+ visual.theme_overrides)。于是把 ink/bg 调成低对比度时体检照样 PASS,渲完才发现字幕看不清。
1127
+ 实测 overrides ink=[30,30,30]/bg=[10,10,10]:基础主题 lint 为空,最终主题 lint 报对比度 1.09。
1128
+ """
1129
+ import stylekit as ST
1130
+ base = {'title': 'x', 'style': 'auto',
1131
+ 'lines': [{'id': 'l01', 'kind': 'body', 'text': 'y'}]}
1132
+ p = ST.resolve_script_style(dict(base, visual={'theme_overrides': {
1133
+ 'ink': [30, 30, 30], 'bg': [10, 10, 10]}}))
1134
+ assert ST.theme_lint(ST.THEMES[p['theme_id']]) == [], \
1135
+ "前提变了:基础主题本身就有 lint 问题,这条断言失去意义"
1136
+ bad = ST.theme_lint(p['theme'])
1137
+ assert bad, '低对比度的 theme_overrides 没被 lint 抓到(用户能造出看不清的字幕)'
1138
+ src = open(os.path.join(HERE, "render.py"), encoding="utf-8").read()
1139
+ assert "ST.theme_lint(plan.get('theme')" in src or 'ST.theme_lint(plan.get("theme")' in src, \
1140
+ "render.resolve_style 仍在体检基础主题而不是最终主题"
1141
+ return "最终主题参与体检(%s)" % ";".join(bad)[:60]
1142
+
1143
+
855
1144
  CASES = [
856
1145
  ("1 TTS 缓存校验文本", t_cache_key),
857
1146
  ("2 sub_window 覆盖长句", t_sub_window),
@@ -885,6 +1174,17 @@ CASES = [
885
1174
  ("30 门禁账本与签核", t_gates),
886
1175
  ("31 阶段新鲜度守卫", t_stagewatch),
887
1176
  ("32 并行渲染主题序列化", t_parallel_frames),
1177
+ ("33 音效跨片可区分(v0.7 内容派生 seed)", t_sfx_content_seed),
1178
+ ("34 配乐/音效片内分离(v0.7 duck 加深)", t_audio_separation),
1179
+ ("35 旁白优先(v0.7.1 背景让位与恢复)", t_voice_priority),
1180
+ ("36 旁白优先默认值(配乐 -12 dB + 侧链)", t_music_gain_default),
1181
+ ("37 旁白清晰度 SNR 兜底(背景太响时自动补压)", t_snr_guard),
1182
+ ("38 无旁白时混音不受影响", t_mix_no_voice),
1183
+ ("39 音频边界:极短/NaN·Inf/负起点/seg=0", t_audio_edge_inputs),
1184
+ ("40 QC 报告落点只有一个来源", t_report_single_source),
1185
+ ("41 空门禁账本不许写成通过", t_empty_ledger_not_pass),
1186
+ ("42 单阶段产物归属(防假 stale)", t_stage_io_no_phantom_output),
1187
+ ("43 theme_overrides 参与主题体检", t_theme_overrides_linted),
888
1188
  ]
889
1189
 
890
1190
 
@@ -17,6 +17,7 @@ BS.1770 响度、块级动态处理)改编自 ai-history-film(MIT),实
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
+ import hashlib
20
21
  import inspect
21
22
  import math
22
23
  import os
@@ -1683,6 +1684,12 @@ def save(path, x, subtype: str = "PCM_24") -> Path:
1683
1684
  """写 WAV(stdlib wave,不依赖 soundfile)。subtype: PCM_16 / PCM_24 / PCM_32。"""
1684
1685
  p = Path(path)
1685
1686
  a = np.asarray(x, dtype=float)
1687
+ if a.size and not np.all(np.isfinite(a)):
1688
+ # NaN/Inf 经 astype(<i2/<i4) 会被静默写成 0 或未定义值(不同平台结果不同),
1689
+ # 文件听起来是"随机哑音"而调用方毫无察觉——与 filmkit.wav_write 同口径:报错。
1690
+ bad = int(a.size - np.count_nonzero(np.isfinite(a)))
1691
+ raise ValueError("sfxkit.save: 采样里有 %d 个 NaN/Inf(%s)——上游合成有数值错误,"
1692
+ "拒绝写出损坏的 WAV" % (bad, p))
1686
1693
  if a.ndim == 1:
1687
1694
  inter = a
1688
1695
  n_ch = 1
@@ -2011,35 +2018,79 @@ CUE_ALIASES = {
2011
2018
  "stream": "text_stream", # 兼容写法:文字流
2012
2019
  }
2013
2020
 
2014
- _PENTA_D = None
2021
+ # ============================================================================
2022
+ # 调性跟随 / 内容派生 seed(v0.7,references/audio-variation.md §3.1)
2023
+ # ============================================================================
2024
+ # 提示音以前硬编码 D 小调五声:配乐一旦选到 C / Am 等调,提示音就"跑调"。
2025
+ # 这里给 in_key() 加可选的 key 参数,key=None 时逐音保持 v0.6 的 D 小调五声
2026
+ # (自检/legacy 逐样本兼容),C 路(CueBus.build_audio)再把配乐调性透传下来。
2027
+ #
2028
+ # 调名表与 scorekit.KEYS_ALL 对齐(4 个小调 + 6 个大调),不另发明一套写法:
2029
+ # 小调 -> 自然小调五声(0/3/5/7/10 半音,Dm = D F G A C)
2030
+ # 大调 -> 大调五声(0/2/4/7/9 半音,C = C D E G A)
2031
+ _KEY_SPECS = {
2032
+ "Dm": ("D", 2, "minor"), "Am": ("A", 9, "minor"),
2033
+ "Cm": ("C", 0, "minor"), "Em": ("E", 4, "minor"),
2034
+ "C": ("C", 0, "major"), "G": ("G", 7, "major"), "D": ("D", 2, "major"),
2035
+ "F": ("F", 5, "major"), "A": ("A", 9, "major"), "Bb": ("Bb", 10, "major"),
2036
+ }
2037
+ _PENTA_CACHE = {}
2015
2038
 
2016
2039
 
2017
2040
  def _penta(key="Dm"):
2018
- """把提示音的音高吸附到一个五声音阶上(避免"叮"和配乐跑调)。
2041
+ """把某一调的五声音阶算成 [Hz, ...](从小到大,MIDI 24~107)。
2019
2042
 
2020
- 默认 D 小调五声(D F G A C)。返回 [Hz, ...] 从小到大。
2043
+ 小调用自然小调五声、大调用大调五声;结果按调名缓存(纯 numpy,无随机)。
2044
+ key 缺省 / None 仍是 D 小调五声:与 v0.6 的数组逐音一致,legacy 逐样本不变。
2021
2045
  """
2022
- global _PENTA_D
2023
- if _PENTA_D is not None:
2024
- return _PENTA_D
2025
- out = []
2026
- for midi in range(24, 108):
2027
- pc = midi % 12
2028
- if pc in (2, 5, 7, 9, 0): # D F G A C
2029
- out.append(midi)
2030
- _PENTA_D = np.array([440.0 * 2.0 ** ((m - 69) / 12.0) for m in out])
2031
- return _PENTA_D
2032
-
2033
-
2034
- def in_key(freq: float) -> float:
2035
- """把任意频率吸附到最近的 D 小调五声音阶音(配乐同调)。"""
2036
- p = _penta()
2046
+ name = "Dm" if key is None else str(key)
2047
+ got = _PENTA_CACHE.get(name)
2048
+ if got is not None:
2049
+ return got
2050
+ spec = _KEY_SPECS.get(name)
2051
+ if spec is None:
2052
+ raise ValueError("in_key: 未知调名 %r(可用 %s)"
2053
+ % (key, "/".join(_KEY_SPECS)))
2054
+ pc0, mode = int(spec[1]), spec[2] # spec[0] 是音名(只给读代码的人看),不参与计算
2055
+ steps = (0, 3, 5, 7, 10) if mode == "minor" else (0, 2, 4, 7, 9)
2056
+ pcs = {(int(pc0) + s) % 12 for s in steps}
2057
+ out = [m for m in range(24, 108) if (m % 12) in pcs]
2058
+ _PENTA_CACHE[name] = np.array([440.0 * 2.0 ** ((m - 69) / 12.0) for m in out])
2059
+ return _PENTA_CACHE[name]
2060
+
2061
+
2062
+ def in_key(freq: float, key: str | None = None) -> float:
2063
+ """把任意频率吸附到最近的五声音阶音(与配乐同调)。
2064
+
2065
+ key=None -> v0.6 的旧行为:硬编码 D 小调五声(D F G A C),逐样本兼容。
2066
+ key 给 "Dm"/"Am"/"Cm"/"Em"(自然小调五声)或 "C"/"G"/"D"/"F"/"A"/"Bb"
2067
+ (大调五声)-> 吸附到该调;未知调名 raise ValueError,不静默降级(与 _enum 同口径)。
2068
+ """
2069
+ p = _penta(key)
2037
2070
  if freq <= 0:
2038
2071
  return float(p[0])
2039
2072
  i = int(np.argmin(np.abs(np.log(p) - np.log(max(freq, 20.0)))))
2040
2073
  return float(p[i])
2041
2074
 
2042
2075
 
2076
+ def content_seed(base: int, style: str, content_fp: str, recipe: str, t: float) -> int:
2077
+ """内容派生 seed(v0.7 契约 §3.1):同片同 cue 稳定,换片同一位置也换种子。
2078
+
2079
+ 公式(与 filmkit.content_seed 逐字一致,两边同参必须同值):
2080
+ sha1("v0.7|<base>|<style>|<content_fp>|<recipe>|<t 三位小数>") -> 前 8 位 hex
2081
+ -> int(h[:8], 16) % 9000 + 1000(32 位非负 int,落在 1000~9999)。
2082
+
2083
+ 纯 stdlib(hashlib):同进程/跨进程/跨机器同参数同结果,不受 numpy 随机源影响。
2084
+ 为什么不用 seed_base + i:那样只跟 cue 的**出现顺序**有关,两部内容完全不同的片子
2085
+ 只要 cue 序列一样,音效轨就逐样本相同(跨片"音效都一样"的直接来源)。
2086
+ """
2087
+ h = hashlib.sha1(
2088
+ ("v0.7|%d|%s|%s|%s|%.3f"
2089
+ % (int(base), str(style), str(content_fp), str(recipe), float(t)))
2090
+ .encode("utf-8")).hexdigest()
2091
+ return int(h[:8], 16) % 9000 + 1000
2092
+
2093
+
2043
2094
  def _enum(value, allowed, where: str, name: str = "值") -> str:
2044
2095
  """严格校验语义枚举参数:未知值立刻报错并列出可用值,不静默落到默认分支。
2045
2096
 
@@ -2228,10 +2279,14 @@ def recipe(name=None, *, tags=(), master=None):
2228
2279
  # ---------------------------------------------------------------- 界面音
2229
2280
  @_recipe(tags=("界面", "提示音"))
2230
2281
  def ui_blip(seed=0, pitch=2200.0, kind="tick", dur=0.22, **kw):
2231
- """极短的"嘀":玻璃/金属的敲击,音高吸附五声音阶。"""
2282
+ """极短的"嘀":玻璃/金属的敲击,音高吸附五声音阶。
2283
+
2284
+ v0.7:key 从 **kw 透传(C 路把配乐调性放进来)——None/缺省 = 旧行为(D 小调五声),
2285
+ 给 "Dm"/"C"/"Am" 等则吸附到该调,未知调名 raise ValueError(见 in_key)。
2286
+ """
2232
2287
  kind = _enum(kind, ("tick", "ping"), "ui_blip", "kind")
2233
2288
  g = rng(seed)
2234
- f = in_key(float(pitch)) * float(g.uniform(0.985, 1.015))
2289
+ f = in_key(float(pitch), kw.get("key")) * float(g.uniform(0.985, 1.015))
2235
2290
  if kind == "tick":
2236
2291
  body = _tone(f, dur * 0.55, 0.035, attack=0.0004)
2237
2292
  click = contact_noise(1.4, center_hz=6200.0, q=0.9, seed=seed) * 0.35
@@ -2276,9 +2331,16 @@ def relay_click(seed=0, count=1, gap=0.18, **kw):
2276
2331
 
2277
2332
  @_recipe(tags=("界面", "抽签"))
2278
2333
  def roulette_tick(seed=0, last=False, **kw):
2279
- """转盘抽签的木质"咔嗒":硬接触的木块,last=True 时更重、更低。"""
2334
+ """转盘抽签的木质"咔嗒":硬接触的木块,last=True 时更重、更低。
2335
+
2336
+ v0.7:给了 key 时把木块基频吸附到该调(提示音跟配乐同调);key=None 走旧公式,
2337
+ 随机序列与取样点都不变 -> legacy 逐样本兼容。
2338
+ """
2280
2339
  g = rng(seed)
2281
2340
  f1 = (410.0 if not last else 300.0) * float(g.uniform(0.97, 1.03))
2341
+ key = kw.get("key")
2342
+ if key is not None:
2343
+ f1 = in_key(f1, key)
2282
2344
  b = bar(f1, material="wood", n_modes=7, tilt=0.25, seed=seed)
2283
2345
  x = _bus(0.26, stereo=False)
2284
2346
  _place(x, b.render(Hit(0.0, velocity=1.0 if last else 0.75,
@@ -2289,10 +2351,14 @@ def roulette_tick(seed=0, last=False, **kw):
2289
2351
 
2290
2352
  @_recipe(tags=("界面", "提示音", "音乐"))
2291
2353
  def answer_chime(seed=0, note=1174.66, grace=880.0, grace_lead=0.07, **kw):
2292
- """"猜中了"的钟琴:一个装饰音 + 一个主音,都在五声音阶上。"""
2354
+ """"猜中了"的钟琴:一个装饰音 + 一个主音,都在五声音阶上。
2355
+
2356
+ v0.7:key 从 **kw 透传(None = 旧行为 D 小调五声;给调名则整条钟琴跟随配乐调性)。
2357
+ """
2358
+ key = kw.get("key")
2293
2359
  x = _bus(1.6, stereo=False)
2294
- _place(x, _tone(in_key(grace), 0.9, 0.5, attack=0.001, index=1.6, ratio=3.5), 0.0, -10.0)
2295
- _place(x, _tone(in_key(note), 1.4, 0.9, attack=0.0015, index=1.2, ratio=2.0),
2360
+ _place(x, _tone(in_key(grace, key), 0.9, 0.5, attack=0.001, index=1.6, ratio=3.5), 0.0, -10.0)
2361
+ _place(x, _tone(in_key(note, key), 1.4, 0.9, attack=0.0015, index=1.2, ratio=2.0),
2296
2362
  float(grace_lead), -1.5)
2297
2363
  return x
2298
2364
 
@@ -2341,7 +2407,10 @@ def typing_at(seed=0, times=(0.0, 0.12, 0.25, 0.4), spaces=(), **kw):
2341
2407
 
2342
2408
  @_recipe(tags=("界面", "数据", "文字"))
2343
2409
  def text_stream(seed=0, times=(0.0, 0.04, 0.08, 0.12), base=1174.7, voice="tone", **kw):
2344
- """AI 逐字输出:比打字更连贯。voice="tone" 是带音高的五声颗粒;"soft" 是不带音高的沙沙。"""
2410
+ """AI 逐字输出:比打字更连贯。voice="tone" 是带音高的五声颗粒;"soft" 是不带音高的沙沙。
2411
+
2412
+ v0.7:voice="tone" 的音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
2413
+ """
2345
2414
  voice = _enum(voice, ("tone", "soft"), "text_stream", "voice")
2346
2415
  ts = _times(times, "text_stream")
2347
2416
  g = rng(seed)
@@ -2354,21 +2423,26 @@ def text_stream(seed=0, times=(0.0, 0.04, 0.08, 0.12), base=1174.7, voice="tone"
2354
2423
  k = bandpass(k, 3200.0 + 900.0 * float(g.random()), q=0.9)
2355
2424
  _place(x, k, t, -6.0 + float(g.uniform(-1.5, 1.5)))
2356
2425
  else:
2357
- f = in_key(float(base) * (2.0 ** (float(g.choice([-2, 0, 2, 4])) / 12.0)))
2426
+ f = in_key(float(base) * (2.0 ** (float(g.choice([-2, 0, 2, 4])) / 12.0)),
2427
+ kw.get("key"))
2358
2428
  _place(x, _tone(f, 0.06, 0.02, attack=0.0006), t, -9.0 + float(g.uniform(-1.0, 1.0)))
2359
2429
  return x
2360
2430
 
2361
2431
 
2362
2432
  @_recipe(tags=("界面", "数据"))
2363
2433
  def data_chirp(seed=0, dur=0.8, rate=28.0, base=1800.0, **kw):
2364
- """数据流:一串很快的、带音高的短脉冲,频率在五声上随机跳动。"""
2434
+ """数据流:一串很快的、带音高的短脉冲,频率在五声上随机跳动。
2435
+
2436
+ v0.7:音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
2437
+ """
2365
2438
  r = float(rate)
2366
2439
  n = max(2, int(float(dur) * r))
2367
2440
  g = rng(seed)
2368
2441
  x = _bus(dur, stereo=False)
2369
2442
  for i in range(n):
2370
2443
  at = i / r
2371
- f = in_key(float(base) * (2.0 ** (float(g.choice([-5, -2, 0, 2, 3, 5, 7])) / 12.0)))
2444
+ f = in_key(float(base) * (2.0 ** (float(g.choice([-5, -2, 0, 2, 3, 5, 7])) / 12.0)),
2445
+ kw.get("key"))
2372
2446
  env_amp = 1.0 - 0.55 * (i / max(1, n - 1))
2373
2447
  _place(x, _tone(f, 0.05, 0.012, attack=0.0004), at, -14.0 + 20 * np.log10(env_amp + 1e-6))
2374
2448
  return x
@@ -2981,7 +3055,33 @@ def selftest() -> int:
2981
3055
  b = render(k, seed=11, dur=3.0) if k == "rain" else render(k, seed=11)
2982
3056
  if not np.array_equal(np.asarray(a), np.asarray(b)):
2983
3057
  raise ValueError("%s 同 seed 两次渲染不一致" % k)
2984
- return "impact/whoosh/ui_blip/typing/rain 逐采样一致"
3058
+ # v0.7 契约 §3.1:key=None 与 key="Dm" 必须逐样本一致(旧路径兼容);
3059
+ # key 给别的调必须真的换音(用 F4:Dm 五声里有 F、C 大调五声里没有,一定吸附到 E4)。
3060
+ old = np.asarray(render("ui_blip", seed=11))
3061
+ dm = np.asarray(render("ui_blip", seed=11, key="Dm"))
3062
+ if not np.array_equal(old, dm):
3063
+ raise ValueError('ui_blip key="Dm" 与 key=None(旧行为)不一致')
3064
+ f4 = np.asarray(render("ui_blip", seed=11, pitch=349.23))
3065
+ c_major = np.asarray(render("ui_blip", seed=11, pitch=349.23, key="C"))
3066
+ if np.array_equal(f4, c_major):
3067
+ raise ValueError('ui_blip key="C" 没有换调(F4 应吸附到 E4)')
3068
+ for rn in ("ui_blip", "answer_chime", "text_stream", "data_chirp", "roulette_tick"):
3069
+ y = render(rn, seed=2, key="Am")
3070
+ if y is None or len(y) == 0:
3071
+ raise ValueError("%s 透传 key='Am' 后无输出" % rn)
3072
+ # content_seed:同参数稳定、对 content_fp/recipe 敏感、落在 1000~9999(契约 §3.1)。
3073
+ s1 = content_seed(1000, "tech-deep", "aaaa1111", "ui_blip", 12.5)
3074
+ s2 = content_seed(1000, "tech-deep", "aaaa1111", "ui_blip", 12.5)
3075
+ s3 = content_seed(1000, "tech-deep", "bbbb2222", "ui_blip", 12.5)
3076
+ s4 = content_seed(1000, "tech-deep", "aaaa1111", "whoosh", 12.5)
3077
+ if s1 != s2:
3078
+ raise ValueError("content_seed 同参数两次不一致")
3079
+ if s3 == s1 or s4 == s1:
3080
+ raise ValueError("content_seed 对 content_fp/recipe 不敏感(跨片会撞种子)")
3081
+ if not (1000 <= s1 <= 9999):
3082
+ raise ValueError("content_seed 超出 1000~9999:%r" % (s1,))
3083
+ return ("impact/whoosh/ui_blip/typing/rain 逐采样一致;key=Dm 同旧行为、key=C 换调;"
3084
+ "content_seed 稳定(s=%d)" % s1)
2985
3085
 
2986
3086
  def t_all_render():
2987
3087
  # 预算分两条路径(见文件头 PERF_BUDGET_* 注释):有 scipy 1.5 s;无 scipy 走
@@ -3054,7 +3154,9 @@ def selftest() -> int:
3054
3154
  ("knock_wood", dict(room="bogus")),
3055
3155
  ("metal_hit", dict(obj="bogus")),
3056
3156
  ("relay_click", dict(gap=-1.0, count=3)),
3057
- ("typing_at", dict(times=[-1.0])))
3157
+ ("typing_at", dict(times=[-1.0])),
3158
+ ("ui_blip", dict(key="bogus")), # v0.7:未知调名必须响亮报错
3159
+ ("answer_chime", dict(key="bogus")))
3058
3160
  for rname, kw in bad:
3059
3161
  try:
3060
3162
  render(rname, seed=3, **kw)