@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.1
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/CHANGELOG.md +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -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
|
|
@@ -781,6 +782,33 @@ def tv_filter(x, kind: str, freq, q: float = 0.7071, gain_db: float = 0.0,
|
|
|
781
782
|
return y
|
|
782
783
|
|
|
783
784
|
|
|
785
|
+
def _resonator_ir(f: float, t60: float, amp: float, n_max: int,
|
|
786
|
+
sr: int = SR) -> np.ndarray:
|
|
787
|
+
"""模态谐振器的解析冲激响应 h[n] = amp · r^n · sin(ωn),长度 ≤ n_max。
|
|
788
|
+
|
|
789
|
+
为什么不复用通用的 _iir_ir():无 scipy 时 resonator 也要走 FFT 卷积,而金属/
|
|
790
|
+
玻璃模态的 t60 可达 5~7 s(见 MATERIALS 的 eta 量级),-80 dB 衰减长度约
|
|
791
|
+
1.33·t60·sr ≈ 40 万采样。旧的 _iir_ir(cap=20000) 在 0.42 s 处把 IR 砍成 0:
|
|
792
|
+
截断点在 -25 dB 上直接跳变,听感是长尾里多一下“咔”,且与 scipy 路径的
|
|
793
|
+
产物长度/尾音全不同(references/sfx-design.md §12 要求静态滤波与 scipy
|
|
794
|
+
相对误差 ≤ 2e-4,实测旧兜底最差 -8 dB)。这个二阶系统的 IR 有闭式解,
|
|
795
|
+
一次向量化算完,比逐样本 Python 递推快两三个数量级。
|
|
796
|
+
长度取 min(n_max, -80 dB 衰减长度):输出只取前 n_max 个采样,h 超过 n_max
|
|
797
|
+
的部分不可能影响这些采样,所以这个截断是精确的、不是近似。
|
|
798
|
+
"""
|
|
799
|
+
n_max = int(max(0, n_max))
|
|
800
|
+
if n_max == 0:
|
|
801
|
+
return np.zeros(0)
|
|
802
|
+
a1 = math.exp(-LN1000 / (max(float(t60), 1e-5) * float(sr)))
|
|
803
|
+
w = 2.0 * np.pi * float(f) / float(sr)
|
|
804
|
+
# 借 _ir_len() 的极点半径估计(它已处理不衰减/数值退化),保证与旧路径同口径
|
|
805
|
+
a = np.array([1.0, -2.0 * a1 * math.cos(w), a1 * a1])
|
|
806
|
+
b = np.array([0.0, float(amp) * a1 * math.sin(w)])
|
|
807
|
+
L = int(_ir_len(b, a, cap=n_max))
|
|
808
|
+
k = np.arange(L, dtype=float)
|
|
809
|
+
return float(amp) * np.power(a1, k) * np.sin(w * k)
|
|
810
|
+
|
|
811
|
+
|
|
784
812
|
def resonator(excitation, f: float, t60: float, amp: float = 1.0,
|
|
785
813
|
sr: int = SR) -> np.ndarray:
|
|
786
814
|
"""单个模态:二阶谐振器,冲激响应为 amp · r^n · sin(ωn)(指数衰减的正弦)。"""
|
|
@@ -793,7 +821,7 @@ def resonator(excitation, f: float, t60: float, amp: float = 1.0,
|
|
|
793
821
|
a = np.array([1.0, -2.0 * a1 * math.cos(w), a1 * a1])
|
|
794
822
|
if _sig is not None:
|
|
795
823
|
return _sig.lfilter(b, a, x, axis=0)
|
|
796
|
-
return _fftconv(x,
|
|
824
|
+
return _fftconv(x, _resonator_ir(f, t60, amp, len(x), sr))[: len(x)]
|
|
797
825
|
|
|
798
826
|
|
|
799
827
|
def resonator_bank(excitation, freqs, t60s, amps, sr: int = SR) -> np.ndarray:
|
|
@@ -999,13 +1027,31 @@ class Body:
|
|
|
999
1027
|
y = np.zeros(n)
|
|
1000
1028
|
if not events:
|
|
1001
1029
|
return y
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1030
|
+
if _sig is not None:
|
|
1031
|
+
# 有 scipy:逐模态 lfilter(保持 v0.7 及以前的产物逐样本不变——用户成片不出新声音)
|
|
1032
|
+
exc = np.zeros(n)
|
|
1033
|
+
for i, (f, t60) in enumerate(zip(self.freqs, self.t60s)):
|
|
1034
|
+
exc[:] = 0.0
|
|
1035
|
+
for s, pulse, w in events:
|
|
1036
|
+
exc[s:s + len(pulse)] += w[i] * pulse
|
|
1037
|
+
y += resonator(exc, float(f), float(t60), 1.0, sr)
|
|
1038
|
+
return y
|
|
1039
|
+
# 无 scipy:先按事件把「加权模态 IR」合成一条,再与力脉冲卷积一次(N 模态 → 1 次 FFT)。
|
|
1040
|
+
# 等价性:每个事件的模态权重 w_i 是标量,Σ_i w_i·conv(pulse, h_i) = conv(pulse, Σ_i w_i·h_i)
|
|
1041
|
+
# 可换序;所以合并只减少 FFT 次数,不改变结果。只在兜底路径合并:有 scipy 的产物保持原样。
|
|
1042
|
+
# 代价:IR 长度仍按 -80 dB 衰减截断,且只保留到输出末尾(h 超过 n-s 的部分影响不到输出)。
|
|
1043
|
+
for s, pulse, w in events:
|
|
1044
|
+
lcap = n - s
|
|
1045
|
+
if lcap <= 0 or len(pulse) == 0:
|
|
1046
|
+
continue
|
|
1047
|
+
h = np.zeros(lcap)
|
|
1048
|
+
for i, (f, t60) in enumerate(zip(self.freqs, self.t60s)):
|
|
1049
|
+
if w[i] == 0.0:
|
|
1050
|
+
continue
|
|
1051
|
+
hi = _resonator_ir(float(f), float(t60), float(w[i]), lcap, sr)
|
|
1052
|
+
h[: len(hi)] += hi
|
|
1053
|
+
seg = _fftconv(pulse, h)[:lcap]
|
|
1054
|
+
y[s:s + len(seg)] += seg
|
|
1009
1055
|
return y
|
|
1010
1056
|
|
|
1011
1057
|
|
|
@@ -1683,6 +1729,12 @@ def save(path, x, subtype: str = "PCM_24") -> Path:
|
|
|
1683
1729
|
"""写 WAV(stdlib wave,不依赖 soundfile)。subtype: PCM_16 / PCM_24 / PCM_32。"""
|
|
1684
1730
|
p = Path(path)
|
|
1685
1731
|
a = np.asarray(x, dtype=float)
|
|
1732
|
+
if a.size and not np.all(np.isfinite(a)):
|
|
1733
|
+
# NaN/Inf 经 astype(<i2/<i4) 会被静默写成 0 或未定义值(不同平台结果不同),
|
|
1734
|
+
# 文件听起来是"随机哑音"而调用方毫无察觉——与 filmkit.wav_write 同口径:报错。
|
|
1735
|
+
bad = int(a.size - np.count_nonzero(np.isfinite(a)))
|
|
1736
|
+
raise ValueError("sfxkit.save: 采样里有 %d 个 NaN/Inf(%s)——上游合成有数值错误,"
|
|
1737
|
+
"拒绝写出损坏的 WAV" % (bad, p))
|
|
1686
1738
|
if a.ndim == 1:
|
|
1687
1739
|
inter = a
|
|
1688
1740
|
n_ch = 1
|
|
@@ -2011,35 +2063,79 @@ CUE_ALIASES = {
|
|
|
2011
2063
|
"stream": "text_stream", # 兼容写法:文字流
|
|
2012
2064
|
}
|
|
2013
2065
|
|
|
2014
|
-
|
|
2066
|
+
# ============================================================================
|
|
2067
|
+
# 调性跟随 / 内容派生 seed(v0.7,references/audio-variation.md §3.1)
|
|
2068
|
+
# ============================================================================
|
|
2069
|
+
# 提示音以前硬编码 D 小调五声:配乐一旦选到 C / Am 等调,提示音就"跑调"。
|
|
2070
|
+
# 这里给 in_key() 加可选的 key 参数,key=None 时逐音保持 v0.6 的 D 小调五声
|
|
2071
|
+
# (自检/legacy 逐样本兼容),C 路(CueBus.build_audio)再把配乐调性透传下来。
|
|
2072
|
+
#
|
|
2073
|
+
# 调名表与 scorekit.KEYS_ALL 对齐(4 个小调 + 6 个大调),不另发明一套写法:
|
|
2074
|
+
# 小调 -> 自然小调五声(0/3/5/7/10 半音,Dm = D F G A C)
|
|
2075
|
+
# 大调 -> 大调五声(0/2/4/7/9 半音,C = C D E G A)
|
|
2076
|
+
_KEY_SPECS = {
|
|
2077
|
+
"Dm": ("D", 2, "minor"), "Am": ("A", 9, "minor"),
|
|
2078
|
+
"Cm": ("C", 0, "minor"), "Em": ("E", 4, "minor"),
|
|
2079
|
+
"C": ("C", 0, "major"), "G": ("G", 7, "major"), "D": ("D", 2, "major"),
|
|
2080
|
+
"F": ("F", 5, "major"), "A": ("A", 9, "major"), "Bb": ("Bb", 10, "major"),
|
|
2081
|
+
}
|
|
2082
|
+
_PENTA_CACHE = {}
|
|
2015
2083
|
|
|
2016
2084
|
|
|
2017
2085
|
def _penta(key="Dm"):
|
|
2018
|
-
"""
|
|
2086
|
+
"""把某一调的五声音阶算成 [Hz, ...](从小到大,MIDI 24~107)。
|
|
2019
2087
|
|
|
2020
|
-
|
|
2088
|
+
小调用自然小调五声、大调用大调五声;结果按调名缓存(纯 numpy,无随机)。
|
|
2089
|
+
key 缺省 / None 仍是 D 小调五声:与 v0.6 的数组逐音一致,legacy 逐样本不变。
|
|
2021
2090
|
"""
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2091
|
+
name = "Dm" if key is None else str(key)
|
|
2092
|
+
got = _PENTA_CACHE.get(name)
|
|
2093
|
+
if got is not None:
|
|
2094
|
+
return got
|
|
2095
|
+
spec = _KEY_SPECS.get(name)
|
|
2096
|
+
if spec is None:
|
|
2097
|
+
raise ValueError("in_key: 未知调名 %r(可用 %s)"
|
|
2098
|
+
% (key, "/".join(_KEY_SPECS)))
|
|
2099
|
+
pc0, mode = int(spec[1]), spec[2] # spec[0] 是音名(只给读代码的人看),不参与计算
|
|
2100
|
+
steps = (0, 3, 5, 7, 10) if mode == "minor" else (0, 2, 4, 7, 9)
|
|
2101
|
+
pcs = {(int(pc0) + s) % 12 for s in steps}
|
|
2102
|
+
out = [m for m in range(24, 108) if (m % 12) in pcs]
|
|
2103
|
+
_PENTA_CACHE[name] = np.array([440.0 * 2.0 ** ((m - 69) / 12.0) for m in out])
|
|
2104
|
+
return _PENTA_CACHE[name]
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def in_key(freq: float, key: str | None = None) -> float:
|
|
2108
|
+
"""把任意频率吸附到最近的五声音阶音(与配乐同调)。
|
|
2109
|
+
|
|
2110
|
+
key=None -> v0.6 的旧行为:硬编码 D 小调五声(D F G A C),逐样本兼容。
|
|
2111
|
+
key 给 "Dm"/"Am"/"Cm"/"Em"(自然小调五声)或 "C"/"G"/"D"/"F"/"A"/"Bb"
|
|
2112
|
+
(大调五声)-> 吸附到该调;未知调名 raise ValueError,不静默降级(与 _enum 同口径)。
|
|
2113
|
+
"""
|
|
2114
|
+
p = _penta(key)
|
|
2037
2115
|
if freq <= 0:
|
|
2038
2116
|
return float(p[0])
|
|
2039
2117
|
i = int(np.argmin(np.abs(np.log(p) - np.log(max(freq, 20.0)))))
|
|
2040
2118
|
return float(p[i])
|
|
2041
2119
|
|
|
2042
2120
|
|
|
2121
|
+
def content_seed(base: int, style: str, content_fp: str, recipe: str, t: float) -> int:
|
|
2122
|
+
"""内容派生 seed(v0.7 契约 §3.1):同片同 cue 稳定,换片同一位置也换种子。
|
|
2123
|
+
|
|
2124
|
+
公式(与 filmkit.content_seed 逐字一致,两边同参必须同值):
|
|
2125
|
+
sha1("v0.7|<base>|<style>|<content_fp>|<recipe>|<t 三位小数>") -> 前 8 位 hex
|
|
2126
|
+
-> int(h[:8], 16) % 9000 + 1000(32 位非负 int,落在 1000~9999)。
|
|
2127
|
+
|
|
2128
|
+
纯 stdlib(hashlib):同进程/跨进程/跨机器同参数同结果,不受 numpy 随机源影响。
|
|
2129
|
+
为什么不用 seed_base + i:那样只跟 cue 的**出现顺序**有关,两部内容完全不同的片子
|
|
2130
|
+
只要 cue 序列一样,音效轨就逐样本相同(跨片"音效都一样"的直接来源)。
|
|
2131
|
+
"""
|
|
2132
|
+
h = hashlib.sha1(
|
|
2133
|
+
("v0.7|%d|%s|%s|%s|%.3f"
|
|
2134
|
+
% (int(base), str(style), str(content_fp), str(recipe), float(t)))
|
|
2135
|
+
.encode("utf-8")).hexdigest()
|
|
2136
|
+
return int(h[:8], 16) % 9000 + 1000
|
|
2137
|
+
|
|
2138
|
+
|
|
2043
2139
|
def _enum(value, allowed, where: str, name: str = "值") -> str:
|
|
2044
2140
|
"""严格校验语义枚举参数:未知值立刻报错并列出可用值,不静默落到默认分支。
|
|
2045
2141
|
|
|
@@ -2228,10 +2324,14 @@ def recipe(name=None, *, tags=(), master=None):
|
|
|
2228
2324
|
# ---------------------------------------------------------------- 界面音
|
|
2229
2325
|
@_recipe(tags=("界面", "提示音"))
|
|
2230
2326
|
def ui_blip(seed=0, pitch=2200.0, kind="tick", dur=0.22, **kw):
|
|
2231
|
-
"""极短的"嘀":玻璃/金属的敲击,音高吸附五声音阶。
|
|
2327
|
+
"""极短的"嘀":玻璃/金属的敲击,音高吸附五声音阶。
|
|
2328
|
+
|
|
2329
|
+
v0.7:key 从 **kw 透传(C 路把配乐调性放进来)——None/缺省 = 旧行为(D 小调五声),
|
|
2330
|
+
给 "Dm"/"C"/"Am" 等则吸附到该调,未知调名 raise ValueError(见 in_key)。
|
|
2331
|
+
"""
|
|
2232
2332
|
kind = _enum(kind, ("tick", "ping"), "ui_blip", "kind")
|
|
2233
2333
|
g = rng(seed)
|
|
2234
|
-
f = in_key(float(pitch)) * float(g.uniform(0.985, 1.015))
|
|
2334
|
+
f = in_key(float(pitch), kw.get("key")) * float(g.uniform(0.985, 1.015))
|
|
2235
2335
|
if kind == "tick":
|
|
2236
2336
|
body = _tone(f, dur * 0.55, 0.035, attack=0.0004)
|
|
2237
2337
|
click = contact_noise(1.4, center_hz=6200.0, q=0.9, seed=seed) * 0.35
|
|
@@ -2276,9 +2376,16 @@ def relay_click(seed=0, count=1, gap=0.18, **kw):
|
|
|
2276
2376
|
|
|
2277
2377
|
@_recipe(tags=("界面", "抽签"))
|
|
2278
2378
|
def roulette_tick(seed=0, last=False, **kw):
|
|
2279
|
-
"""转盘抽签的木质"咔嗒":硬接触的木块,last=True 时更重、更低。
|
|
2379
|
+
"""转盘抽签的木质"咔嗒":硬接触的木块,last=True 时更重、更低。
|
|
2380
|
+
|
|
2381
|
+
v0.7:给了 key 时把木块基频吸附到该调(提示音跟配乐同调);key=None 走旧公式,
|
|
2382
|
+
随机序列与取样点都不变 -> legacy 逐样本兼容。
|
|
2383
|
+
"""
|
|
2280
2384
|
g = rng(seed)
|
|
2281
2385
|
f1 = (410.0 if not last else 300.0) * float(g.uniform(0.97, 1.03))
|
|
2386
|
+
key = kw.get("key")
|
|
2387
|
+
if key is not None:
|
|
2388
|
+
f1 = in_key(f1, key)
|
|
2282
2389
|
b = bar(f1, material="wood", n_modes=7, tilt=0.25, seed=seed)
|
|
2283
2390
|
x = _bus(0.26, stereo=False)
|
|
2284
2391
|
_place(x, b.render(Hit(0.0, velocity=1.0 if last else 0.75,
|
|
@@ -2289,10 +2396,14 @@ def roulette_tick(seed=0, last=False, **kw):
|
|
|
2289
2396
|
|
|
2290
2397
|
@_recipe(tags=("界面", "提示音", "音乐"))
|
|
2291
2398
|
def answer_chime(seed=0, note=1174.66, grace=880.0, grace_lead=0.07, **kw):
|
|
2292
|
-
""""猜中了"的钟琴:一个装饰音 + 一个主音,都在五声音阶上。
|
|
2399
|
+
""""猜中了"的钟琴:一个装饰音 + 一个主音,都在五声音阶上。
|
|
2400
|
+
|
|
2401
|
+
v0.7:key 从 **kw 透传(None = 旧行为 D 小调五声;给调名则整条钟琴跟随配乐调性)。
|
|
2402
|
+
"""
|
|
2403
|
+
key = kw.get("key")
|
|
2293
2404
|
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),
|
|
2405
|
+
_place(x, _tone(in_key(grace, key), 0.9, 0.5, attack=0.001, index=1.6, ratio=3.5), 0.0, -10.0)
|
|
2406
|
+
_place(x, _tone(in_key(note, key), 1.4, 0.9, attack=0.0015, index=1.2, ratio=2.0),
|
|
2296
2407
|
float(grace_lead), -1.5)
|
|
2297
2408
|
return x
|
|
2298
2409
|
|
|
@@ -2341,7 +2452,10 @@ def typing_at(seed=0, times=(0.0, 0.12, 0.25, 0.4), spaces=(), **kw):
|
|
|
2341
2452
|
|
|
2342
2453
|
@_recipe(tags=("界面", "数据", "文字"))
|
|
2343
2454
|
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" 是不带音高的沙沙。
|
|
2455
|
+
"""AI 逐字输出:比打字更连贯。voice="tone" 是带音高的五声颗粒;"soft" 是不带音高的沙沙。
|
|
2456
|
+
|
|
2457
|
+
v0.7:voice="tone" 的音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
|
|
2458
|
+
"""
|
|
2345
2459
|
voice = _enum(voice, ("tone", "soft"), "text_stream", "voice")
|
|
2346
2460
|
ts = _times(times, "text_stream")
|
|
2347
2461
|
g = rng(seed)
|
|
@@ -2354,21 +2468,31 @@ def text_stream(seed=0, times=(0.0, 0.04, 0.08, 0.12), base=1174.7, voice="tone"
|
|
|
2354
2468
|
k = bandpass(k, 3200.0 + 900.0 * float(g.random()), q=0.9)
|
|
2355
2469
|
_place(x, k, t, -6.0 + float(g.uniform(-1.5, 1.5)))
|
|
2356
2470
|
else:
|
|
2357
|
-
f = in_key(float(base) * (2.0 ** (float(g.choice([-2, 0, 2, 4])) / 12.0))
|
|
2471
|
+
f = in_key(float(base) * (2.0 ** (float(g.choice([-2, 0, 2, 4])) / 12.0)),
|
|
2472
|
+
kw.get("key"))
|
|
2358
2473
|
_place(x, _tone(f, 0.06, 0.02, attack=0.0006), t, -9.0 + float(g.uniform(-1.0, 1.0)))
|
|
2359
2474
|
return x
|
|
2360
2475
|
|
|
2361
2476
|
|
|
2362
2477
|
@_recipe(tags=("界面", "数据"))
|
|
2363
2478
|
def data_chirp(seed=0, dur=0.8, rate=28.0, base=1800.0, **kw):
|
|
2364
|
-
"""数据流:一串很快的、带音高的短脉冲,频率在五声上随机跳动。
|
|
2479
|
+
"""数据流:一串很快的、带音高的短脉冲,频率在五声上随机跳动。
|
|
2480
|
+
|
|
2481
|
+
v0.7:音高跟随 **kw 里的 key(None = 旧行为 D 小调五声)。
|
|
2482
|
+
"""
|
|
2365
2483
|
r = float(rate)
|
|
2484
|
+
# rate 是"每秒脉冲数",必须为正:rate=0 会在下面 i / r 抛 ZeroDivisionError(内部
|
|
2485
|
+
# 堆栈,用户看不出是参数问题);rate<0 时 int(dur*r) 为负、事件时刻为负,_place 会
|
|
2486
|
+
# 静默丢掉大部分脉冲——只剩哑音。与 _enum 同口径:认识但非法的值必须响亮报错。
|
|
2487
|
+
if not math.isfinite(r) or r <= 0.0:
|
|
2488
|
+
raise ValueError("data_chirp: rate 必须是正数(每秒脉冲数),收到 %r" % (rate,))
|
|
2366
2489
|
n = max(2, int(float(dur) * r))
|
|
2367
2490
|
g = rng(seed)
|
|
2368
2491
|
x = _bus(dur, stereo=False)
|
|
2369
2492
|
for i in range(n):
|
|
2370
2493
|
at = i / r
|
|
2371
|
-
f = in_key(float(base) * (2.0 ** (float(g.choice([-5, -2, 0, 2, 3, 5, 7])) / 12.0))
|
|
2494
|
+
f = in_key(float(base) * (2.0 ** (float(g.choice([-5, -2, 0, 2, 3, 5, 7])) / 12.0)),
|
|
2495
|
+
kw.get("key"))
|
|
2372
2496
|
env_amp = 1.0 - 0.55 * (i / max(1, n - 1))
|
|
2373
2497
|
_place(x, _tone(f, 0.05, 0.012, attack=0.0004), at, -14.0 + 20 * np.log10(env_amp + 1e-6))
|
|
2374
2498
|
return x
|
|
@@ -2981,7 +3105,33 @@ def selftest() -> int:
|
|
|
2981
3105
|
b = render(k, seed=11, dur=3.0) if k == "rain" else render(k, seed=11)
|
|
2982
3106
|
if not np.array_equal(np.asarray(a), np.asarray(b)):
|
|
2983
3107
|
raise ValueError("%s 同 seed 两次渲染不一致" % k)
|
|
2984
|
-
|
|
3108
|
+
# v0.7 契约 §3.1:key=None 与 key="Dm" 必须逐样本一致(旧路径兼容);
|
|
3109
|
+
# key 给别的调必须真的换音(用 F4:Dm 五声里有 F、C 大调五声里没有,一定吸附到 E4)。
|
|
3110
|
+
old = np.asarray(render("ui_blip", seed=11))
|
|
3111
|
+
dm = np.asarray(render("ui_blip", seed=11, key="Dm"))
|
|
3112
|
+
if not np.array_equal(old, dm):
|
|
3113
|
+
raise ValueError('ui_blip key="Dm" 与 key=None(旧行为)不一致')
|
|
3114
|
+
f4 = np.asarray(render("ui_blip", seed=11, pitch=349.23))
|
|
3115
|
+
c_major = np.asarray(render("ui_blip", seed=11, pitch=349.23, key="C"))
|
|
3116
|
+
if np.array_equal(f4, c_major):
|
|
3117
|
+
raise ValueError('ui_blip key="C" 没有换调(F4 应吸附到 E4)')
|
|
3118
|
+
for rn in ("ui_blip", "answer_chime", "text_stream", "data_chirp", "roulette_tick"):
|
|
3119
|
+
y = render(rn, seed=2, key="Am")
|
|
3120
|
+
if y is None or len(y) == 0:
|
|
3121
|
+
raise ValueError("%s 透传 key='Am' 后无输出" % rn)
|
|
3122
|
+
# content_seed:同参数稳定、对 content_fp/recipe 敏感、落在 1000~9999(契约 §3.1)。
|
|
3123
|
+
s1 = content_seed(1000, "tech-deep", "aaaa1111", "ui_blip", 12.5)
|
|
3124
|
+
s2 = content_seed(1000, "tech-deep", "aaaa1111", "ui_blip", 12.5)
|
|
3125
|
+
s3 = content_seed(1000, "tech-deep", "bbbb2222", "ui_blip", 12.5)
|
|
3126
|
+
s4 = content_seed(1000, "tech-deep", "aaaa1111", "whoosh", 12.5)
|
|
3127
|
+
if s1 != s2:
|
|
3128
|
+
raise ValueError("content_seed 同参数两次不一致")
|
|
3129
|
+
if s3 == s1 or s4 == s1:
|
|
3130
|
+
raise ValueError("content_seed 对 content_fp/recipe 不敏感(跨片会撞种子)")
|
|
3131
|
+
if not (1000 <= s1 <= 9999):
|
|
3132
|
+
raise ValueError("content_seed 超出 1000~9999:%r" % (s1,))
|
|
3133
|
+
return ("impact/whoosh/ui_blip/typing/rain 逐采样一致;key=Dm 同旧行为、key=C 换调;"
|
|
3134
|
+
"content_seed 稳定(s=%d)" % s1)
|
|
2985
3135
|
|
|
2986
3136
|
def t_all_render():
|
|
2987
3137
|
# 预算分两条路径(见文件头 PERF_BUDGET_* 注释):有 scipy 1.5 s;无 scipy 走
|
|
@@ -3054,7 +3204,9 @@ def selftest() -> int:
|
|
|
3054
3204
|
("knock_wood", dict(room="bogus")),
|
|
3055
3205
|
("metal_hit", dict(obj="bogus")),
|
|
3056
3206
|
("relay_click", dict(gap=-1.0, count=3)),
|
|
3057
|
-
("typing_at", dict(times=[-1.0]))
|
|
3207
|
+
("typing_at", dict(times=[-1.0])),
|
|
3208
|
+
("ui_blip", dict(key="bogus")), # v0.7:未知调名必须响亮报错
|
|
3209
|
+
("answer_chime", dict(key="bogus")))
|
|
3058
3210
|
for rname, kw in bad:
|
|
3059
3211
|
try:
|
|
3060
3212
|
render(rname, seed=3, **kw)
|
|
@@ -3137,19 +3289,85 @@ def selftest() -> int:
|
|
|
3137
3289
|
return "环境声响度贴合 master(±1.5 LU)"
|
|
3138
3290
|
|
|
3139
3291
|
def t_fallback():
|
|
3292
|
+
# v0.8:这条以前只查"输出是有限值"——等于只验了"没崩",兜底路径的幅度
|
|
3293
|
+
# 正确性完全没查(references/sfx-design.md §12 承诺静态滤波与 scipy 相对
|
|
3294
|
+
# 误差 ≤ 2e-4)。现在按当前环境选参照:
|
|
3295
|
+
# 有 scipy:同一函数跑两遍(scipy 路径 vs 兜底路径)逐样本比 —— 直接验"等价";
|
|
3296
|
+
# 无 scipy:没有 scipy 路径可比,改用**解析参照**(双二阶的阶跃稳态/频响、
|
|
3297
|
+
# 谐振器的闭式冲激响应 amp·r^n·sin(ωn)),这样 CI 的 noscipy 任务
|
|
3298
|
+
# 也在查"数值对不对",而不是自己和自己比出一个假 PASS。
|
|
3140
3299
|
old = globals().get("_sig")
|
|
3300
|
+
has_ref = old is not None
|
|
3301
|
+
S = globals()
|
|
3141
3302
|
try:
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3303
|
+
x = np.random.default_rng(0).standard_normal(24000) * 0.1
|
|
3304
|
+
worst = 0.0
|
|
3305
|
+
|
|
3306
|
+
def cmp_paths(fn):
|
|
3307
|
+
"""有 scipy 时:两条路径逐样本相对误差;无 scipy 时返回 None。"""
|
|
3308
|
+
if not has_ref:
|
|
3309
|
+
return None
|
|
3310
|
+
S["_sig"] = old
|
|
3311
|
+
a = fn()
|
|
3312
|
+
S["_sig"] = None
|
|
3313
|
+
b = fn()
|
|
3314
|
+
d = float(np.max(np.abs(a - b))) / max(float(np.max(np.abs(a))), 1e-12)
|
|
3315
|
+
return d
|
|
3316
|
+
|
|
3317
|
+
# 1) 静态滤波:有 scipy 比路径;无 scipy 用"阶跃稳态 = 解析 DC 增益"
|
|
3318
|
+
for kind, f0, dc in (("lowpass", 1000.0, 1.0), ("highpass", 1000.0, 0.0)):
|
|
3319
|
+
S["_sig"] = None
|
|
3320
|
+
step = np.concatenate([np.zeros(4800), np.ones(43200)])
|
|
3321
|
+
y = sosfilt(biquad(kind, f0), step)
|
|
3322
|
+
err = abs(float(np.mean(y[-1000:])) - dc)
|
|
3323
|
+
if err > 2e-3:
|
|
3324
|
+
raise ValueError("无 scipy %s(%g) 阶跃稳态 %.6f 偏离 DC 增益 %.3f"
|
|
3325
|
+
% (kind, f0, float(np.mean(y[-1000:])), dc))
|
|
3326
|
+
worst = max(worst, err)
|
|
3327
|
+
d = cmp_paths(lambda: lowpass(x, 1000.0))
|
|
3328
|
+
if d is not None:
|
|
3329
|
+
if d > 2e-4:
|
|
3330
|
+
raise ValueError("无 scipy 静态滤波与 scipy 相对误差 %.2e > 2e-4" % d)
|
|
3331
|
+
worst = max(worst, d)
|
|
3332
|
+
|
|
3333
|
+
# 2) 谐振器:闭式解。h[n] = amp·r^n·sin(ωn)(本函数构造的 b/a 决定的)。
|
|
3334
|
+
# 注意激励必须**够长**:旧兜底把 IR 截断在 20000 采样,若只喂 4096 点冲激,
|
|
3335
|
+
# 输出前 4096 点与解析解一致,截断根本测不出来(第一版断言就踩过这个坑)。
|
|
3336
|
+
# 这里用 30000 点白噪声:t60=3 s 的 -80 dB 长度约 19.2 万采样,输出第 2 万点之后
|
|
3337
|
+
# 的错误才会暴露,相对误差会到 3e-2 量级(远大于下面 2e-4 的阈值)。
|
|
3338
|
+
n = 30000
|
|
3339
|
+
exc = np.random.default_rng(7).standard_normal(n)
|
|
3340
|
+
for f, t60, amp in ((300.0, 1.0, 0.7), (220.0, 3.0, 1.0)):
|
|
3341
|
+
r0 = math.exp(-LN1000 / (t60 * float(SR)))
|
|
3342
|
+
w0 = 2.0 * math.pi * f / float(SR)
|
|
3343
|
+
k = np.arange(n, dtype=float)
|
|
3344
|
+
h_ref = amp * np.power(r0, k) * np.sin(w0 * k) # 解析 IR(独立于被测代码)
|
|
3345
|
+
# 参照卷积用 FFT(30000×30000 直接 np.convolve 是 9e8 次乘加,实测要 20 s,
|
|
3346
|
+
# 会把这个自检从 60 s 预算里挤爆);零填充到 2 的幂即可,长度不足部分不影响
|
|
3347
|
+
# 前 n 个输出采样。
|
|
3348
|
+
L = n + len(h_ref) - 1
|
|
3349
|
+
N = 1 << int(L - 1).bit_length()
|
|
3350
|
+
ref = np.fft.irfft(np.fft.rfft(exc, N) * np.fft.rfft(h_ref, N), N)[:n]
|
|
3351
|
+
S["_sig"] = None
|
|
3352
|
+
h = resonator(exc, f, t60, amp)
|
|
3353
|
+
err = float(np.max(np.abs(h - ref))) / max(float(np.max(np.abs(ref))), 1e-12)
|
|
3354
|
+
if err > 2e-4:
|
|
3355
|
+
raise ValueError("无 scipy 谐振器 f=%g t60=%g 与闭式解相对误差 %.2e > 2e-4"
|
|
3356
|
+
"(IR 被截断?)" % (f, t60, err))
|
|
3357
|
+
worst = max(worst, err)
|
|
3358
|
+
|
|
3359
|
+
# 3) 时变滤波:文档承认"块中心系数 + 重叠相加"是近似(高频控制信号会平滑),
|
|
3360
|
+
# 只查能跑 + 不出 NaN/Inf,不设等价阈值。
|
|
3361
|
+
S["_sig"] = None
|
|
3147
3362
|
z = tv_filter(x, "bandpass", np.linspace(300.0, 4000.0, len(x)), 0.9)
|
|
3148
3363
|
if not np.all(np.isfinite(z)):
|
|
3149
3364
|
raise ValueError("无 scipy 兜底 tv_filter 输出非有限值")
|
|
3150
3365
|
finally:
|
|
3151
3366
|
globals()["_sig"] = old
|
|
3152
|
-
|
|
3367
|
+
if has_ref:
|
|
3368
|
+
return "兜底 vs scipy:静态滤波/谐振器 ≤2e-4(实测 %.1e)/ tv_filter 可用" % worst
|
|
3369
|
+
return ("无 scipy 环境:静态滤波阶跃稳态 DC 增益 / 谐振器闭式解 均 ≤2e-3(实测 %.1e)+ "
|
|
3370
|
+
"tv_filter 可用(本环境没有 scipy 路径可比,用解析参照)" % worst)
|
|
3153
3371
|
|
|
3154
3372
|
def t_analysis():
|
|
3155
3373
|
m = analyze(render("impact", seed=6))
|