@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
@@ -1,5 +1,5 @@
1
1
  # -*- coding: utf-8 -*-
2
- """scorekit.py — 程序化配乐(v0.5.0)。
2
+ """scorekit.py — 程序化配乐(v0.7.0)。
3
3
 
4
4
  用代码写一张能贯穿整部片子的配乐:调性/和弦表、段落能量、节拍网格、律动层、
5
5
  段落过门、自动电平与"吸气",全部由 numpy + sfxkit 现场合成,不用任何采样。
@@ -33,6 +33,18 @@ v0.5 新增(配乐风格层):
33
33
  style 为 None / "legacy" 时走 v0.4 原路径(逐样本一致);否则走风格化合成。
34
34
  KEYS_MAJOR / KEYS_ALL 6 个大调 + 全部 10 个调
35
35
  spectral_centroid(x, sr=SR) 频谱质心(量"亮度",验证风格差异用)
36
+
37
+ v0.7 新增(内容指纹变奏,references/audio-variation.md §3.3):
38
+ fingerprint 不再只派生音色 seed —— 同一风格换内容指纹时,结构层也稳定地变:
39
+ * 进行档:按指纹从 style["progressions"] 的 calm/body/drive 里另选一档
40
+ (整片一个偏移、climax 仍复用 drive,片内各段的进行关系保持连贯);
41
+ * 和弦:每两小节一次稳定替换(i<->i7 / VI<->VI7 / III<->III7 / VII<->VII7;
42
+ 大调侧 I<->Imaj7 / IV<->IVmaj7),不覆盖乐句末属和弦与片尾主和弦;
43
+ * 动机:每个 (拍, 级数, 时值) 做 ±1 级数偏移(只改级数不改节奏;问句/答句/
44
+ 片尾三种动机独立派生,跨主音的上下行用八度偏移修正);
45
+ * 鼓型:kick 最多加一记 ghost 落点(0.5 拍网格、不与现有落点重复),clap ±0.5 拍。
46
+ report["variation"] = {"chord_swaps", "motif_shifts", "drum_ghosts"} 记实际发生数。
47
+ style=None 的 legacy 路径不经过这些代码,仍然逐字节兼容(selfcheck 20 条)。
36
48
  """
37
49
  from __future__ import annotations
38
50
 
@@ -50,7 +62,7 @@ import numpy as np
50
62
 
51
63
  import sfxkit as S # 唯一音频依赖:numpy + sfxkit
52
64
 
53
- __version__ = "0.5.0"
65
+ __version__ = "0.7.0"
54
66
 
55
67
  SR = 48000 # 采样率(与 filmkit / sfxkit 一致)
56
68
  BEATS_PER_BAR = 4 # 4/4
@@ -128,6 +140,11 @@ _DEGREE_TABLE = (
128
140
  ("V7", 7, (0, 4, 7, 10), "7"),
129
141
  ("vi7", 9, (0, 3, 7, 10), "m7"),
130
142
  ("i7", 0, (0, 3, 7, 10), "m7"),
143
+ # ---- v0.7:和弦替换用的七和弦色彩。同根音、同三音,只把五度后的音换成小七度;
144
+ # 只允许在表尾追加(前 10 项顺序被 _build_key / 老报告依赖),供 _CHORD_SWAPS 使用。 ----
145
+ ("VI7", 8, (0, 4, 7, 10), "7"),
146
+ ("III7", 3, (0, 4, 7, 10), "7"),
147
+ ("VII7", 10, (0, 4, 7, 10), "7"),
131
148
  )
132
149
 
133
150
  # 级数名 -> 定义行(旋律生成/mood 色彩/自测校验用;只读映射,不参与 _build_key)
@@ -276,6 +293,10 @@ def _decimate_mono(x: np.ndarray, k: int = 4) -> np.ndarray:
276
293
  def _moving_avg(v: np.ndarray, w: int) -> np.ndarray:
277
294
  """移动平均(边界复制),O(n),用来做"一阶高通/低通"的近似。"""
278
295
  w = max(2, int(w))
296
+ if len(v) == 0:
297
+ # 空输入:np.pad(mode="edge") 对空数组直接抛 "can't extend empty axis 0",
298
+ # 而空数组只在 duration*sr < 4(不足 4 个采样)时出现(duration=1e-6 实测崩这)。
299
+ return np.zeros(0, dtype=np.float64)
279
300
  pad = w // 2
280
301
  vp = np.pad(v, (pad, w - pad), mode="edge")
281
302
  c = np.cumsum(vp)
@@ -1415,7 +1436,10 @@ class Score:
1415
1436
  peak = float(np.max(np.abs(master))) if n else 0.0
1416
1437
  if peak > 1e-9:
1417
1438
  master *= np.float32(_db_amp(TARGET_PEAK_DB) / peak)
1418
- peak_db = float(20.0 * math.log10(max(float(np.max(np.abs(master))), 1e-12)))
1439
+ # 亚采样时长(duration*sr < 1)时 master 是 (0,2):np.max 对空数组直接抛
1440
+ # "zero-size array to reduction operation maximum"(duration=1e-6 实测崩这)。
1441
+ peak_db = (float(20.0 * math.log10(max(float(np.max(np.abs(master))), 1e-12)))
1442
+ if n else -240.0)
1419
1443
  lufs = _approx_lufs(master, self.sr)
1420
1444
  meas = [{"name": s["name"], "start": round(s["start"], 3), "end": round(s["end"], 3),
1421
1445
  "energy": round(s["energy"], 3), "gain_db": round(lv["gain"], 2),
@@ -1424,7 +1448,7 @@ class Score:
1424
1448
  "rendered": True,
1425
1449
  "peak_dbfs": round(peak_db, 2),
1426
1450
  "lufs_approx": round(lufs, 1),
1427
- "dc_max": round(float(np.max(np.abs(master.mean(axis=0)))), 9),
1451
+ "dc_max": round(float(np.max(np.abs(master.mean(axis=0)))), 9) if n else 0.0,
1428
1452
  "render_s": round(time.perf_counter() - t_start, 3),
1429
1453
  "measured_sections": meas,
1430
1454
  }
@@ -2040,42 +2064,174 @@ def _styled_layers(pack: dict, energy: float, mood: str) -> list:
2040
2064
  return out
2041
2065
 
2042
2066
 
2043
- def _styled_progression(pack: dict, tier: str, mood: str) -> tuple:
2044
- """取该 tier 的级数进行;climax 复用 drive(高潮靠鼓与层数,不靠换和弦)。"""
2067
+ # ---- v0.7 内容指纹变奏(references/audio-variation.md §3.3)----------------
2068
+ # v0.6 及以前指纹只进 seed(只改音色细节):同风格 / 同 bpm / 同调、只换内容指纹,
2069
+ # 两段配乐相关性 0.966 —— 因为和弦进行 / 动机 / 鼓型全写死。
2070
+ # v0.7 把指纹推到"结构层":和弦做每两小节一次的稳定替换、动机级数做 ±1 稳定偏移、
2071
+ # 鼓型加 ghost 落点。三处决策全部走 _hash_int(FNV-1a,跨进程稳定),所以
2072
+ # 同指纹逐采样一致、不同指纹稳定地不同;没有引入额外随机源(变奏全部由指纹决定)。
2073
+ _VARIATION_SALT = "v0.7"
2074
+
2075
+ # 和弦替换对:同根音、同三音,只在"三和弦 <-> 七和弦色彩"之间换(契约 §3.3 的替代级数集)
2076
+ _CHORD_SWAPS = {
2077
+ "minor": {"i": "i7", "i7": "i", "VI": "VI7", "VI7": "VI",
2078
+ "III": "III7", "III7": "III", "VII": "VII7", "VII7": "VII"},
2079
+ "major": {"I": "Imaj7", "Imaj7": "I", "IV": "IVmaj7", "IVmaj7": "IV"},
2080
+ }
2081
+
2082
+ # 动机级数邻接表:只走"调内级数"的上下各一级(只换级数,节奏 / 时值不动)。
2083
+ # 值是 ((下方级数, 八度偏移), (上方级数, 八度偏移));跨主音时八度偏移 ±1,
2084
+ # 否则 i 下行一级会变成高 10 个半音的 VII(旋律轮廓会翻掉)。
2085
+ _MOTIF_NEIGHBORS = {
2086
+ "minor": {
2087
+ "i": (("VII", -1), ("ii°", 0)), "ii°": (("i", 0), ("III", 0)),
2088
+ "III": (("ii°", 0), ("iv", 0)), "iv": (("III", 0), ("v", 0)),
2089
+ "v": (("iv", 0), ("VI", 0)), "V": (("iv", 0), ("VI", 0)),
2090
+ "VI": (("v", 0), ("VII", 0)), "VII": (("VI", 0), ("i", 1)),
2091
+ "I": (("VII", -1), ("ii°", 0)), "i7": (("VII", -1), ("ii°", 0)),
2092
+ },
2093
+ "major": {
2094
+ "I": (("vii", -1), ("ii", 0)), "vii": (("vi", 0), ("I", 1)),
2095
+ "ii": (("I", 0), ("iii", 0)), "iii": (("ii", 0), ("IV", 0)),
2096
+ "IV": (("iii", 0), ("V", 0)), "V": (("IV", 0), ("vi", 0)),
2097
+ "vi": (("V", 0), ("vii", 0)), "VI": (("V", 0), ("VII", 0)),
2098
+ "III": (("ii", 0), ("IV", 0)), "iv": (("iii", 0), ("V", 0)),
2099
+ "Imaj7": (("vii", -1), ("ii", 0)), "IVmaj7": (("iii", 0), ("V", 0)),
2100
+ },
2101
+ }
2102
+
2103
+ # 进行档位(按"安静 -> 推进"排);climax 复用 drive(v0.5 语义:高潮靠鼓与层数)
2104
+ _PROG_TIERS = ("calm", "body", "drive")
2105
+
2106
+
2107
+ def _styled_mode(pack: dict) -> str:
2108
+ """风格包是小调还是大调:看 progressions 的主和弦写法(i/i7 = 小调,I/Imaj7 = 大调)。
2109
+
2110
+ 调式只决定"替换对 / 级数邻接表"用哪一套;真正的调性仍由 key 决定,两者不改动。
2111
+ """
2112
+ degs = {str(d) for seq in pack["progressions"].values() for d in seq}
2113
+ return "minor" if (degs & {"i", "i7"}) else "major"
2114
+
2115
+
2116
+ def _styled_has_fp(fingerprint) -> str:
2117
+ """指纹归一化:None / "" -> ""(= 关掉全部结构变奏,与 v0.5 逐字节一致)。"""
2118
+ if fingerprint is None:
2119
+ return ""
2120
+ s = str(fingerprint)
2121
+ return s if s != "" else ""
2122
+
2123
+
2124
+ def _styled_progression(pack: dict, tier: str, mood: str, fingerprint=None) -> tuple:
2125
+ """取该 tier 的级数进行;climax 复用 drive(高潮靠鼓与层数,不靠换和弦)。
2126
+
2127
+ v0.7:给了指纹时,按指纹从该风格的 progressions 里另选一档(契约 §3.3)——
2128
+ 同一风格的两部片可以拿到不同的和弦功能顺序,但调式 / 音色 / 鼓型 / 段落结构不变。
2129
+ 偏移量对整部片只有一个(哈希里不带段落号),片内各段的进行关系因此仍然连贯;
2130
+ 无指纹 -> 与 v0.5 完全同一结果(只按能量档取)。
2131
+ """
2045
2132
  progs = pack["progressions"]
2046
2133
  key = tier if tier in progs else ("drive" if tier == "climax" else "body")
2134
+ fp = _styled_has_fp(fingerprint)
2135
+ tiers = [t for t in _PROG_TIERS if t in progs]
2136
+ if fp and tiers and tier != "climax": # climax 仍复用 drive(v0.5 语义:高潮靠鼓与层数)
2137
+ base = tiers.index(key) if key in tiers else min(1, len(tiers) - 1)
2138
+ off = _hash_int(_VARIATION_SALT, "tier", fp) % len(tiers)
2139
+ key = tiers[(base + off) % len(tiers)]
2047
2140
  return tuple(progs[key])
2048
2141
 
2049
2142
 
2050
- def _styled_chords(sec: dict, si: int, secs: list, prog: tuple, pack: dict, mood: str) -> list:
2051
- """一小节一个和弦:级数循环 + 乐句末收属和弦 + mood 色彩 + 片尾落 I。"""
2143
+ def _styled_chord_rules(deg: str, j: int, nb: int, ph: int, tier: str,
2144
+ mood: str, is_last_sec: bool) -> str:
2145
+ """单小节的硬规则(与 v0.5 逐字同序):乐句末属和弦 -> mood 色彩 -> 片尾主和弦。
2146
+
2147
+ 单独拆出来是为了让"变奏前 / 变奏后"各跑一遍:替换是否真的落地、有没有被
2148
+ 硬规则吃掉(乐句末收 V、片尾落 I)都能算清楚,report 里的计数才是真数。
2149
+ """
2150
+ # 每两乐句收一次属和弦(制造张力,与 legacy 的写法同源)
2151
+ if j % ph == ph - 1 and (j // ph) % 2 == 1:
2152
+ deg = "V"
2153
+ # mood 色彩:hopeful 加 maj7 / sus2;tense 用 sus4 悬留
2154
+ if mood == "hopeful":
2155
+ if deg == "I":
2156
+ deg = "Imaj7"
2157
+ elif deg == "IV":
2158
+ deg = "IVmaj7"
2159
+ elif deg == "i":
2160
+ deg = "i7"
2161
+ elif j == 0 and tier == "calm":
2162
+ deg = "sus2"
2163
+ elif mood == "tense" and j % ph == ph - 2 and deg in ("i", "I"):
2164
+ deg = "sus4"
2165
+ # 片尾落主和弦(与 legacy 同口径:I = 皮卡迪三度 / 大调主)
2166
+ if is_last_sec and nb >= 2 and j >= nb - 2:
2167
+ deg = "I"
2168
+ return deg
2169
+
2170
+
2171
+ def _styled_chords(sec: dict, si: int, secs: list, prog: tuple, pack: dict, mood: str,
2172
+ fingerprint=None, mode: str = "minor") -> tuple:
2173
+ """一小节一个和弦:级数循环 + 乐句末收属和弦 + mood 色彩 + 片尾落 I。
2174
+
2175
+ v0.7:给了指纹时,**每两小节做一次稳定替换**(契约 §3.3):按指纹决定替换
2176
+ 块内第 1 小节还是第 2 小节,被选中的小节在 _CHORD_SWAPS 里换成同根音的七和弦
2177
+ 色彩(该小节级数不在替代集里就跳过)。替换先于硬规则执行,所以乐句末属和弦与
2178
+ 片尾主和弦永远不被改坏(契约的"只在不冲突时替换")。
2179
+ 返回 (和弦级数列表, 实际替换次数);无指纹时替换次数恒 0、列表与 v0.5 逐项一致。
2180
+ """
2052
2181
  nb = len(sec["bars"])
2053
2182
  ph = int(pack["phrase_bars"])
2054
2183
  tier = sec["tier"]
2055
2184
  is_last_sec = (si == len(secs) - 1)
2185
+ fp = _styled_has_fp(fingerprint)
2186
+ smap = _CHORD_SWAPS.get(mode, {})
2056
2187
  out = []
2188
+ swaps = 0
2057
2189
  for j in range(nb):
2058
- deg = prog[j % len(prog)]
2059
- # 每两乐句收一次属和弦(制造张力,与 legacy 的写法同源)
2060
- if j % ph == ph - 1 and (j // ph) % 2 == 1:
2061
- deg = "V"
2062
- # mood 色彩:hopeful 加 maj7 / sus2;tense 用 sus4 悬留
2063
- if mood == "hopeful":
2064
- if deg == "I":
2065
- deg = "Imaj7"
2066
- elif deg == "IV":
2067
- deg = "IVmaj7"
2068
- elif deg == "i":
2069
- deg = "i7"
2070
- elif j == 0 and tier == "calm":
2071
- deg = "sus2"
2072
- elif mood == "tense" and j % ph == ph - 2 and deg in ("i", "I"):
2073
- deg = "sus4"
2074
- # 片尾落主和弦(与 legacy 同口径:I = 皮卡迪三度 / 大调主)
2075
- if is_last_sec and nb >= 2 and j >= nb - 2:
2076
- deg = "I"
2190
+ base = prog[j % len(prog)]
2191
+ deg = _styled_chord_rules(base, j, nb, ph, tier, mood, is_last_sec)
2192
+ if fp and smap:
2193
+ # 每两小节一次稳定替换:按指纹决定替换块内第 1 还是第 2 小节
2194
+ # (该小节级数不在替代集里就跳过,不硬造不存在的替代级数)
2195
+ pick = _hash_int(_VARIATION_SALT, "chord", fp, si, j // 2) % 2
2196
+ if pick == (j % 2) and base in smap:
2197
+ alt = _styled_chord_rules(smap[base], j, nb, ph, tier, mood, is_last_sec)
2198
+ if alt != deg: # 被硬规则吃掉的替换不算一次替换
2199
+ deg = alt
2200
+ swaps += 1
2077
2201
  out.append(deg)
2078
- return out
2202
+ return out, swaps
2203
+
2204
+
2205
+ def _styled_kick_ghost(cfg: dict, fp: str, si: int, j: int,
2206
+ fill_bar: bool, breath: bool):
2207
+ """按指纹给这一小节加一个 kick ghost 落点;返回落点(拍)或 None。
2208
+
2209
+ ghost 只落在 0.5 拍网格上、不与 cfg["kick"] 现有落点重合;fill / breath 小节
2210
+ 只允许落在 [0, 3) 拍内(那几个位置要留给鼓加花与呼吸,契约 §3.3)。
2211
+ 每小节最多一记,1/3 的小节不加 —— 否则"每小节都多一记"会变成另一种机械。
2212
+ """
2213
+ used = [float(x) for x in cfg.get("kick", ())]
2214
+ cand = []
2215
+ for k in range(int(round(BEATS_PER_BAR / 0.5))):
2216
+ pos = 0.5 * k
2217
+ if any(abs(pos - u) < 1e-9 for u in used):
2218
+ continue
2219
+ if (fill_bar or breath) and pos >= 3.0 - 1e-9:
2220
+ continue
2221
+ cand.append(pos)
2222
+ if not cand:
2223
+ return None
2224
+ h = _hash_int(_VARIATION_SALT, "ghost", fp, si, j)
2225
+ if h % 3 == 0:
2226
+ return None
2227
+ return cand[(h >> 3) % len(cand)]
2228
+
2229
+
2230
+ def _styled_clap_shift(fp: str, si: int, j: int, idx: int) -> float:
2231
+ """拍手位置的指纹位移:±0.5 拍(契约 §3.3);1/3 不动,保留原律动的稳定感。"""
2232
+ h = _hash_int(_VARIATION_SALT, "clap", fp, si, j, idx)
2233
+ m = h % 3
2234
+ return 0.0 if m == 2 else (0.5 if m == 0 else -0.5)
2079
2235
 
2080
2236
 
2081
2237
  def _styled_target_db(energy: float, span: float) -> float:
@@ -2140,15 +2296,55 @@ def _lead_texture(bank: "_Bank", voice: str, note: str, dur: float, bright: floa
2140
2296
  return bank.bell(note, max(2.4, dur))
2141
2297
 
2142
2298
 
2143
- def _emit_styled_motif(bus: "_Bus", bank: "_Bank", motif, kval: dict, voice: str,
2144
- t0: float, beat: float, db: float, bright: float) -> None:
2145
- """把风格动机铺到主奏总线:级数 -> 音名,乐器由 lead_cycle 指定。"""
2299
+ def _styled_motif_shift(motif, kind: str, fingerprint, style: str, mode: str) -> tuple:
2300
+ """按指纹给动机的每个事件派生 ±1 级数偏移;返回 (事件列表, 偏移次数)。
2301
+
2302
+ 只改级数不改节奏:拍位与时值原样保留。偏移方向按 "指纹 + 风格 + 动机类型 + 事件序号"
2303
+ 哈希派生,所以问句(q) / 答句(a) / 片尾(end) **各自独立**(契约 §3.3 的"独立派生"):
2304
+ 同一部片里同种动机稳定,两部片不会同步变形(q 上移的片子 a 未必也上移)。
2305
+ 返回的事件是 (拍, 级数, 时值, 八度偏移):跨主音上行 / 下行时用八度偏移修正,
2306
+ 否则"小调 i 下行一级"会变成高 10 个半音的 VII,旋律轮廓会翻掉。
2307
+ """
2308
+ fp = _styled_has_fp(fingerprint)
2309
+ table = _MOTIF_NEIGHBORS.get(mode, {})
2310
+ if not fp or not table:
2311
+ return [(float(ev[0]), str(ev[1]), float(ev[2]), 0) for ev in motif], 0
2312
+ # 整句的偏移方向只由 "指纹 + 风格 + 动机类型" 决定(同种动机稳定),
2313
+ # 每个事件在±1 里取哪边由事件序号决定 —— q 与 a 的方向各自独立派生。
2314
+ up = _hash_int(_VARIATION_SALT, "motif-dir", fp, style, kind) % 2 == 1
2315
+ out = []
2316
+ shifted = 0
2146
2317
  for idx, ev in enumerate(motif):
2147
- pos_beats, degree, dur_beats = float(ev[0]), str(ev[1]), float(ev[2])
2148
- note = _degree_note(kval, degree, 4)
2318
+ pos, deg, dur = float(ev[0]), str(ev[1]), float(ev[2])
2319
+ pair = table.get(deg)
2320
+ h = _hash_int(_VARIATION_SALT, "motif", fp, style, kind, idx)
2321
+ if pair is None: # 邻接表没收录的级数不动,绝不瞎编级数
2322
+ out.append((pos, deg, dur, 0))
2323
+ continue
2324
+ name, oct_delta = pair[1] if (up != bool(h % 2)) else pair[0]
2325
+ out.append((pos, name, dur, int(oct_delta)))
2326
+ shifted += 1
2327
+ return out, shifted
2328
+
2329
+
2330
+ def _emit_styled_motif(bus: "_Bus", bank: "_Bank", motif, kval: dict, voice: str,
2331
+ t0: float, beat: float, db: float, bright: float,
2332
+ fingerprint=None, style: str = "", kind: str = "",
2333
+ mode: str = "minor") -> int:
2334
+ """把风格动机铺到主奏总线:级数 -> 音名,乐器由 lead_cycle 指定。
2335
+
2336
+ v0.7:先按指纹做级数 ±1 偏移(_styled_motif_shift)再铺;返回被偏移的事件数,
2337
+ 供 report["variation"]["motif_shifts"] 审计。无指纹时偏移数恒 0、逐音与 v0.5 相同。
2338
+ """
2339
+ events, shifted = _styled_motif_shift(motif, kind, fingerprint, style, mode)
2340
+ for idx, ev in enumerate(events):
2341
+ pos_beats = float(ev[0])
2342
+ degree, dur_beats, oct_delta = str(ev[1]), float(ev[2]), int(ev[3])
2343
+ note = _degree_note(kval, degree, 4 + oct_delta)
2149
2344
  tex = _lead_texture(bank, voice, note, dur_beats * beat, bright)
2150
2345
  pan = 0.18 if idx % 2 == 0 else -0.18
2151
2346
  bus.add(tex, t0 + pos_beats * beat, db, pan)
2347
+ return shifted
2152
2348
 
2153
2349
 
2154
2350
  def _styled_gate_env(n: int, secs: list, present: list, bar: float, sr: int = SR) -> np.ndarray:
@@ -2264,7 +2460,10 @@ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=Non
2264
2460
  * 复用老 _Bank 的鼓组与 _Bus 总线,只新增音色方法,不重写任何老乐器;
2265
2461
  * 老路径(render(style=None) / "legacy")完全不受影响,逐样本与 v0.4 相同。
2266
2462
 
2267
- anchors / sections 语义与 render() 一致;mood 见 MOOD_IDS;fingerprint 只影响 seed 派生。
2463
+ anchors / sections 语义与 render() 一致;mood 见 MOOD_IDS。
2464
+ fingerprint(v0.7):参与 seed 派生 + 结构层变奏(契约 §3.3)——和弦每两小节一次
2465
+ 七和弦色彩替换、按指纹选进行档、动机级数 ±1 稳定偏移、鼓型 ghost 落点 / clap 位移。
2466
+ None / "" 时三处变奏全部关闭,逐样本与 v0.5 相同;报告里的 variation 是这三项的计数。
2268
2467
 
2269
2468
  music_plan:stylekit.resolve_script_style(script) 的 "music" 子字典(整份 plan 也接受)。
2270
2469
  非 None 时 bpm / key / mood / seed 以它为准(优先级高于显式参数)——render.py 只需
@@ -2284,6 +2483,9 @@ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=Non
2284
2483
  f"bpm 必须是有限正数,收到 {bpm!r}")
2285
2484
  _require(key in KEYS_ALL, f"未知调性 {key!r};可选:{'/'.join(KEYS_ALL)}")
2286
2485
  pack = MUSIC_STYLES[style]
2486
+ # v0.7:调式决定和弦替换对 / 级数邻接表用哪一套;fp_s 为空 = 全部结构变奏关闭
2487
+ mode = _styled_mode(pack)
2488
+ fp_s = _styled_has_fp(fingerprint)
2287
2489
  dur = float(duration)
2288
2490
  sr = SR
2289
2491
  sc = Score(duration, bpm=float(bpm), key=key, seed=int(seed))
@@ -2309,13 +2511,20 @@ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=Non
2309
2511
  space = pack["space"]
2310
2512
 
2311
2513
  # ---- 1) 段落规划:tier / 层 / 和弦 / 电平目标 ----
2514
+ # v0.7:指纹变奏计数(chord_swaps / motif_shifts / drum_ghosts)进 report["variation"]。
2515
+ # 无指纹时三处变奏全关、计数恒 0,和弦仍走 v0.5 那条"只按能量档取进行"的分支。
2516
+ chord_swaps = 0
2517
+ motif_shifts = 0
2518
+ drum_ghosts = 0
2312
2519
  for si, sec in enumerate(secs):
2313
2520
  e = sec["energy"]
2314
2521
  tier = _styled_tier(e)
2315
2522
  sec["tier"] = tier
2316
2523
  sec["layers"] = _styled_layers(pack, e, mood)
2317
- sec["chords"] = _styled_chords(sec, si, secs, _styled_progression(pack, tier, mood),
2318
- pack, mood)
2524
+ prog = _styled_progression(pack, tier, mood, fingerprint)
2525
+ sec["chords"], n_swap = _styled_chords(sec, si, secs, prog, pack, mood,
2526
+ fingerprint, mode)
2527
+ chord_swaps += n_swap
2319
2528
  sec["target_db"] = round(_styled_target_db(e, span), 2)
2320
2529
  sec["target_lufs"] = round(BASE_LUFS + sec["target_db"], 1)
2321
2530
  sec["breath"] = {"calm": 2, "body": 1, "drive": 0, "climax": 0}[tier]
@@ -2353,6 +2562,14 @@ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=Non
2353
2562
  var = bank.kicks[(bi * 4 + int(round(kb))) % len(bank.kicks)]
2354
2563
  drums.add(var, t0 + kb * beat, kick_db + accent)
2355
2564
  hits.append((t0 + kb * beat, 0.28 + 0.22 * e))
2565
+ # v0.7:指纹再塞一记 ghost kick(0.5 拍网格、不与现有落点重复;见 _styled_kick_ghost)
2566
+ if fp_s:
2567
+ ghost_pos = _styled_kick_ghost(cfg, fp_s, si, j, fill_bar, breath)
2568
+ if ghost_pos is not None:
2569
+ gidx = (bi * 4 + int(round(ghost_pos * 2)) + 2) % len(bank.kicks)
2570
+ drums.add(bank.kicks[gidx], t0 + ghost_pos * beat, kick_db - 5.5)
2571
+ hits.append((t0 + ghost_pos * beat, 0.12 + 0.12 * e))
2572
+ drum_ghosts += 1
2356
2573
  if has_hat and cfg.get("hat"):
2357
2574
  step = float(cfg["hat"])
2358
2575
  total = int(round(BEATS_PER_BAR / step))
@@ -2369,11 +2586,17 @@ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=Non
2369
2586
  var = bank.hats[(bi * 7 + k) % len(bank.hats)]
2370
2587
  drums.add(var, t0 + pos * beat, hat_db + ghost, pan)
2371
2588
  if has_hat and e >= 0.50 and cfg.get("clap"):
2372
- for cb in cfg.get("clap", ()):
2589
+ for cidx, cb in enumerate(cfg.get("clap", ())):
2373
2590
  if phrase_end and cb >= 3.0:
2374
2591
  continue
2375
- drums.add(bank.claps[(bi * 2 + int(round(cb))) % len(bank.claps)],
2376
- t0 + cb * beat, -16.0 + 4.0 * e - 1.5 * float(m["drum"]), 0.08)
2592
+ pos = float(cb)
2593
+ if fp_s:
2594
+ shift = _styled_clap_shift(fp_s, si, j, cidx)
2595
+ if shift and 0.0 <= pos + shift < float(BEATS_PER_BAR):
2596
+ pos += shift
2597
+ drum_ghosts += 1
2598
+ drums.add(bank.claps[(bi * 2 + int(round(pos))) % len(bank.claps)],
2599
+ t0 + pos * beat, -16.0 + 4.0 * e - 1.5 * float(m["drum"]), 0.08)
2377
2600
  if tier == "climax" and has_hat:
2378
2601
  for pos in (0.5, 1.5, 2.5, 3.5):
2379
2602
  if phrase_end and pos >= 3.5:
@@ -2535,20 +2758,24 @@ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=Non
2535
2758
  for pi, first in enumerate(range(0, nb, ph)):
2536
2759
  is_last_phrase = (si == last_si and pi == (nb - 1) // ph)
2537
2760
  if is_last_phrase:
2538
- motif = pack["motif"]["end"]
2761
+ kind, motif = "end", pack["motif"]["end"]
2539
2762
  elif phrase_i % 2 == 0:
2540
- motif = pack["motif"]["q"]
2763
+ kind, motif = "q", pack["motif"]["q"]
2541
2764
  else:
2542
- motif = pack["motif"]["a"]
2765
+ kind, motif = "a", pack["motif"]["a"]
2543
2766
  voice = cycle[phrase_i % len(cycle)]
2544
2767
  t0 = sec["bars"][first] * bar
2545
- _emit_styled_motif(lead, bank, motif, kval, voice, t0, beat, mel_db, bright)
2768
+ motif_shifts += _emit_styled_motif(
2769
+ lead, bank, motif, kval, voice, t0, beat, mel_db, bright,
2770
+ fingerprint=fingerprint, style=style, kind=kind, mode=mode)
2546
2771
  phrase_i += 1
2547
2772
  elif si == last_si and e >= 0.22:
2548
2773
  # 片尾安静段:用第一个主奏音色把结局动机答完
2549
2774
  t_last = sec["bars"][0] * bar + max(0.0, (nb - 2) * bar)
2550
- _emit_styled_motif(lead, bank, pack["motif"]["end"], kval, cycle[0],
2551
- t_last, beat, -19.0 + float(m["lead_db"]), bright)
2775
+ motif_shifts += _emit_styled_motif(
2776
+ lead, bank, pack["motif"]["end"], kval, cycle[0],
2777
+ t_last, beat, -19.0 + float(m["lead_db"]), bright,
2778
+ fingerprint=fingerprint, style=style, kind="end", mode=mode)
2552
2779
  lead.buf *= _styled_gate_env(n, secs, lead_pres, bar, sr)[:, None]
2553
2780
  master += lead.buf
2554
2781
  sc._add_delayed(master, lead.buf, float(space["delay_beats"]) * beat * 0.85,
@@ -2630,6 +2857,10 @@ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=Non
2630
2857
  "seed": int(sc.seed),
2631
2858
  "seed_effective": int(seed_eff),
2632
2859
  "fingerprint": "" if fingerprint is None else str(fingerprint),
2860
+ # v0.7:指纹变奏的实际发生数(审计用;无指纹时三项目全 0)
2861
+ "variation": {"chord_swaps": int(chord_swaps),
2862
+ "motif_shifts": int(motif_shifts),
2863
+ "drum_ghosts": int(drum_ghosts)},
2633
2864
  "bar_s": round(bar, 4),
2634
2865
  "beat_s": round(beat, 4),
2635
2866
  "phrase_bars": ph,
@@ -2901,6 +3132,12 @@ def save(path, x, sr: int = SR) -> Path:
2901
3132
  """把配乐写成 24-bit PCM WAV(通道数自适应;峰值由 render 保证 ≤ -1 dBFS)。"""
2902
3133
  path = Path(path)
2903
3134
  arr = np.asarray(x)
3135
+ if arr.size and not np.all(np.isfinite(arr)):
3136
+ # NaN 经 np.rint(...).astype(np.int32) 会落到 INT_MIN(不是 0),24-bit 写出后是
3137
+ # 满幅负样本 —— 成片里一声爆响,且没有任何报错。与 filmkit.wav_write 同口径:报错。
3138
+ bad = int(arr.size - np.count_nonzero(np.isfinite(arr)))
3139
+ raise ValueError("scorekit.save: 采样里有 %d 个 NaN/Inf(%s)——配乐渲染有数值错误,"
3140
+ "拒绝写出损坏的 WAV" % (bad, path))
2904
3141
  if arr.ndim == 1:
2905
3142
  arr = np.stack([arr, arr], axis=1)
2906
3143
  _require(arr.ndim == 2 and arr.shape[1] in (1, 2), f"只支持单/双声道,收到 shape={arr.shape}")
@@ -3406,6 +3643,11 @@ def _format_report(rep: dict, wav_path: Path | None, report_path: Path | None) -
3406
3643
  f" / 情绪 {rep.get('mood', 'steady')} / 有效 seed {rep.get('seed_effective', rep['seed'])}"
3407
3644
  f" / 频谱质心 {rep.get('spectral_centroid_hz', 0):.0f} Hz")
3408
3645
  lines.append(f" 段落 {len(rep['sections'])} 个,交界 {len(rep['transitions'])} 处,吸气 {len(rep['sucks'])} 处")
3646
+ var = rep.get("variation") or {}
3647
+ if any(int(var.get(k, 0) or 0) for k in ("chord_swaps", "motif_shifts", "drum_ghosts")):
3648
+ lines.append(" 指纹变奏:和弦替换 %d 处 / 动机偏移 %d 音 / 鼓型 ghost %d 处"
3649
+ % (int(var.get("chord_swaps", 0)), int(var.get("motif_shifts", 0)),
3650
+ int(var.get("drum_ghosts", 0))))
3409
3651
  for s in rep["sections"]:
3410
3652
  lines.append(f" {s['name']:<8} {s['start']:7.2f} – {s['end']:7.2f} s"
3411
3653
  f" energy {s['energy']:.2f} 目标 {s['target_lufs']:.1f} LUFS"
@@ -3434,7 +3676,8 @@ def main(argv=None) -> int:
3434
3676
  ap.add_argument("--style", default=None,
3435
3677
  help="配乐风格:" + "/".join(MUSIC_STYLE_IDS) + "(缺省 = legacy,与 v0.4 逐样本一致)")
3436
3678
  ap.add_argument("--mood", default="steady", help="情绪修饰:" + "/".join(MOOD_IDS))
3437
- ap.add_argument("--fingerprint", default=None, help="内容指纹(只影响 v0.5 风格路径的 seed 派生)")
3679
+ ap.add_argument("--fingerprint", default=None,
3680
+ help="内容指纹(v0.7:参与和声/动机/鼓型变奏;只影响 style 路径,legacy 忽略)")
3438
3681
  ap.add_argument("--bpm", type=float, default=None,
3439
3682
  help="BPM(缺省:legacy 120;风格路径取该风格 bpm_hint 的中位)")
3440
3683
  ap.add_argument("--key", default=None,