@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.6.0

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.
@@ -1,5 +1,5 @@
1
1
  # -*- coding: utf-8 -*-
2
- """scorekit.py — 程序化配乐(v0.3.0)。
2
+ """scorekit.py — 程序化配乐(v0.5.0)。
3
3
 
4
4
  用代码写一张能贯穿整部片子的配乐:调性/和弦表、段落能量、节拍网格、律动层、
5
5
  段落过门、自动电平与"吸气",全部由 numpy + sfxkit 现场合成,不用任何采样。
@@ -21,6 +21,18 @@
21
21
  class Score: section / plan_from_anchors / suck / render / report
22
22
  render(duration, bpm=120, key="Dm", anchors=None, sections=None, seed=7) -> (samples, report)
23
23
  save(path, x, sr=SR) / load(path, sr=SR) / selftest()
24
+
25
+ v0.5 新增(配乐风格层):
26
+ MUSIC_STYLES / MUSIC_STYLE_IDS 9 个配乐风格(8 个与 stylekit 同名 + legacy)
27
+ render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=None,
28
+ sections=None, seed=7, fingerprint=None, sucks=None,
29
+ music_plan=None, pace=None) -> (samples, report)
30
+ music_plan = stylekit.resolve_script_style(script)["music"](非 None 时盖过 bpm/key/mood/seed)
31
+ pace = stylekit 的 pacing dict(只用 min_dur+hold 合并过碎段落,不改片长)
32
+ render(..., style=None, mood="steady", fingerprint=None)
33
+ style 为 None / "legacy" 时走 v0.4 原路径(逐样本一致);否则走风格化合成。
34
+ KEYS_MAJOR / KEYS_ALL 6 个大调 + 全部 10 个调
35
+ spectral_centroid(x, sr=SR) 频谱质心(量"亮度",验证风格差异用)
24
36
  """
25
37
  from __future__ import annotations
26
38
 
@@ -38,7 +50,7 @@ import numpy as np
38
50
 
39
51
  import sfxkit as S # 唯一音频依赖:numpy + sfxkit
40
52
 
41
- __version__ = "0.3.0"
53
+ __version__ = "0.5.0"
42
54
 
43
55
  SR = 48000 # 采样率(与 filmkit / sfxkit 一致)
44
56
  BEATS_PER_BAR = 4 # 4/4
@@ -102,8 +114,25 @@ _DEGREE_TABLE = (
102
114
  ("VII", 10, (0, 4, 7, 12), ""),
103
115
  ("I", 0, (0, 4, 7, 12), ""), # 结尾大三和弦(皮卡迪三度)
104
116
  ("sus2", 0, (0, 2, 7, 12), "sus2"), # 悬留色彩,过门/段首可用
117
+ # ---- v0.5:大调级数与七和弦色彩。只允许在表尾追加;前 10 项顺序不许动(_build_key 依赖) ----
118
+ ("ii", 2, (0, 3, 7, 12), "m"),
119
+ ("iii", 4, (0, 3, 7, 12), "m"),
120
+ ("IV", 5, (0, 4, 7, 12), ""),
121
+ ("vi", 9, (0, 3, 7, 12), "m"),
122
+ ("vii", 11, (0, 3, 6, 12), "dim"),
123
+ ("sus4", 0, (0, 5, 7, 12), "sus4"),
124
+ ("Imaj7", 0, (0, 4, 7, 11), "maj7"),
125
+ ("ii7", 2, (0, 3, 7, 10), "m7"),
126
+ ("iii7", 4, (0, 3, 7, 10), "m7"),
127
+ ("IVmaj7", 5, (0, 4, 7, 11), "maj7"),
128
+ ("V7", 7, (0, 4, 7, 10), "7"),
129
+ ("vi7", 9, (0, 3, 7, 10), "m7"),
130
+ ("i7", 0, (0, 3, 7, 10), "m7"),
105
131
  )
106
132
 
133
+ # 级数名 -> 定义行(旋律生成/mood 色彩/自测校验用;只读映射,不参与 _build_key)
134
+ _DEGREE_BY_NAME = {row[0]: row for row in _DEGREE_TABLE}
135
+
107
136
 
108
137
  # ============================================================================
109
138
  # 小工具(全部确定性、无副作用)
@@ -177,7 +206,8 @@ def _build_key(root_name: str, root_pc: int, accidentals: str, cn_name: str) ->
177
206
  for degree, base, intervals, quality in _DEGREE_TABLE:
178
207
  notes = [_semi_note(root_pc, 3, base + iv, names) for iv in intervals]
179
208
  bass_pc = (root_pc + base) % 12
180
- suffix = {"m": "m", "dim": "dim", "sus2": "sus2", "": ""}[quality]
209
+ suffix = {"m": "m", "dim": "dim", "sus2": "sus2", "sus4": "sus4",
210
+ "maj7": "maj7", "m7": "m7", "7": "7", "": ""}[quality]
181
211
  chords[degree] = {
182
212
  "degree": degree,
183
213
  "name": f"{names[bass_pc]}{suffix}",
@@ -204,6 +234,32 @@ KEYS = {
204
234
  "Em": _build_key("E", 4, "sharp", "E 小调"),
205
235
  }
206
236
 
237
+ # 大调三档和声进行(v0.5)。小调那边 PROGRESSIONS 保持原样:legacy 路径只读 PROGRESSIONS。
238
+ PROGRESSIONS_MAJOR = {
239
+ "calm": ("I", "vi", "IV", "V"),
240
+ "body": ("I", "V", "vi", "IV"),
241
+ "drive": ("I", "IV", "V", "I"),
242
+ }
243
+
244
+ # v0.5:6 个大调(与 stylekit.MAJOR_KEYS 同名同义)。用同一个 _build_key 构建,
245
+ # 语义与小调键完全一致(级数表 -> 和弦名/低音/音名);只是 progression 换成大调级数。
246
+ # 写法按惯例挑:C/F/Bb 用降号、G/D/A 用升号。
247
+ KEYS_MAJOR = {
248
+ "C": _build_key("C", 0, "flat", "C 大调"),
249
+ "G": _build_key("G", 7, "sharp", "G 大调"),
250
+ "D": _build_key("D", 2, "sharp", "D 大调"),
251
+ "F": _build_key("F", 5, "flat", "F 大调"),
252
+ "A": _build_key("A", 9, "sharp", "A 大调"),
253
+ "Bb": _build_key("Bb", 10, "flat", "Bb 大调"),
254
+ }
255
+ for _kname in KEYS_MAJOR:
256
+ KEYS_MAJOR[_kname]["progression"] = dict(PROGRESSIONS_MAJOR)
257
+ del _kname
258
+
259
+ # 全部可用调:4 个小调 + 6 个大调。KEYS 本身保持 v0.4 的 4 个小调不变(兼容性锚点)。
260
+ KEYS_ALL = dict(KEYS)
261
+ KEYS_ALL.update(KEYS_MAJOR)
262
+
207
263
 
208
264
  # ============================================================================
209
265
  # 电平测量(简化 K 加权;只用于段落间相对电平匹配与报告,不追求 BS.1770 精度)
@@ -335,6 +391,7 @@ class _Bank:
335
391
  self._cache: dict = {}
336
392
  self._pad_cores: dict = {}
337
393
  self._pad_envs: dict = {}
394
+ self._str_cores: dict = {}
338
395
  self._pad_n = max(16, _ns(self.bar_s * 1.02 + 1.3, self.sr))
339
396
  self._pad_dur = self.bar_s * 1.02
340
397
  # 鼓组:预渲染几套变体,交替使用,避免机械重复
@@ -565,6 +622,211 @@ class _Bank:
565
622
  self._cache[key] = tex
566
623
  return tex
567
624
 
625
+
626
+ # ---------------- v0.5 风格化音色(只用已有原语组合;不碰老乐器的 RNG 序列) ----------------
627
+ #
628
+ # 三个约定,保证 legacy 逐样本不变:
629
+ # 1) 每个新音色都走 self._cached(...) / self._cache,种子用 self._seed("<新名字>", ...),
630
+ # 名字前缀与老乐器不重叠,老乐器一次都不读新缓存;
631
+ # 2) 不在 __init__ 里预渲染任何新素材(既有 rng 调用次序不变);
632
+ # 3) 新方法只在 render_styled 里被调用。
633
+ def strings(self, chord: dict, bright: float, attack: float, release: float = 0.7,
634
+ octave: int = 0) -> np.ndarray:
635
+ """弦乐铺底:5 声部超级锯齿(detune 0.06)过低通,比合成器 pad 更宽、更"有机"。"""
636
+ key = ("strings", chord["name"], int(round(bright * 10)),
637
+ int(round(attack * 100)), int(octave))
638
+ hit = self._cache.get(key)
639
+ if hit is not None:
640
+ return hit
641
+ core = self._strings_core(chord, bright, octave)
642
+ env = self._pad_env(attack, release)
643
+ tex = self._edge_fade(_f32(core * env[:, None]), 0.002, 0.02)
644
+ self._cache[key] = tex
645
+ return tex
646
+
647
+ def _strings_core(self, chord: dict, bright: float, octave: int) -> np.ndarray:
648
+ key = ("strcore", chord["name"], int(round(bright * 10)), int(octave))
649
+ hit = self._str_cores.get(key)
650
+ if hit is not None:
651
+ return hit
652
+ names = [_up(nt, octave) for nt in chord["notes"]] if octave else list(chord["notes"])
653
+ n = self._pad_n
654
+ out = np.zeros((n, 2), np.float32)
655
+ for c in range(2):
656
+ g = S.rng(self._seed("strings", chord["name"], int(round(bright * 10)), octave, c))
657
+ acc = np.zeros(n, np.float64)
658
+ for nt in names:
659
+ acc += S.supersaw(S.hz(nt), n, voices=5, detune=0.06, seed=g, sr=self.sr)
660
+ acc = S.lowpass(acc, 420.0 + 2600.0 * bright, 0.8, sr=self.sr)
661
+ acc = S.highpass(acc, 70.0, sr=self.sr)
662
+ out[:, c] = _f32(acc)
663
+ out *= np.float32(1.9 / max(1, len(names)))
664
+ self._str_cores[key] = out
665
+ return out
666
+
667
+ def cello(self, note: str, dur: float, bright: float) -> np.ndarray:
668
+ """大提琴式低音:锯齿 + 脉冲、带一点揉弦,慢起音。"""
669
+ key = ("cello", note, round(float(dur), 3), int(round(bright * 10)))
670
+ return self._cached(key, lambda: self._make_cello(note, dur, bright))
671
+
672
+ def _make_cello(self, note: str, dur: float, bright: float) -> np.ndarray:
673
+ f = S.hz(note)
674
+ n = _ns(dur + 0.06, self.sr)
675
+ t = _t(n, self.sr)
676
+ vib = 1.0 + 0.0045 * np.sin(2.0 * np.pi * 5.1 * t)
677
+ osc = (0.70 * S.saw(f * vib, n, sr=self.sr)
678
+ + 0.35 * S.pulse(f * vib * 1.001, n, 0.42, sr=self.sr))
679
+ x = S.lowpass(osc, 260.0 + 1500.0 * bright, 1.1, sr=self.sr)
680
+ env = np.minimum(1.0, t / 0.025) * np.clip((dur + 0.05 - t) / 0.06, 0.0, 1.0)
681
+ x = np.tanh(x * 1.6) * 0.5 * env
682
+ return self._edge_fade(_f32(x), 0.002, 0.01)
683
+
684
+ def sub_bass(self, note: str, dur: float) -> np.ndarray:
685
+ """808 式超低音:正弦轻微下滑 + tanh 饱和(lo-fi / 电子底的"地基")。"""
686
+ key = ("subbass", note, round(float(dur), 3))
687
+ return self._cached(key, lambda: self._make_sub_bass(note, dur))
688
+
689
+ def _make_sub_bass(self, note: str, dur: float) -> np.ndarray:
690
+ f = S.hz(note)
691
+ d = float(min(1.2, max(0.08, dur)))
692
+ n = _ns(d + 0.08, self.sr)
693
+ t = _t(n, self.sr)
694
+ fr = f + (f * 1.03 - f) * np.exp(-t / 0.04)
695
+ x = S.sine(fr, n, sr=self.sr) * np.exp(-t / max(0.08, d * 0.55))
696
+ x = np.tanh(x * 1.8) * 0.8 * np.minimum(1.0, t / 0.003)
697
+ return self._edge_fade(_f32(x), 0.001, 0.01)
698
+
699
+ def pulse_bass(self, note: str, dur: float, bright: float) -> np.ndarray:
700
+ """脉冲贝斯:方波为主 + 时变低通,比老 bass 更"电子"。"""
701
+ key = ("pulsebass", note, round(float(dur), 3), int(round(bright * 10)))
702
+ return self._cached(key, lambda: self._make_pulse_bass(note, dur, bright))
703
+
704
+ def _make_pulse_bass(self, note: str, dur: float, bright: float) -> np.ndarray:
705
+ f = S.hz(note)
706
+ n = _ns(dur + 0.07, self.sr)
707
+ t = _t(n, self.sr)
708
+ osc = (0.80 * S.pulse(f, n, 0.34, sr=self.sr)
709
+ + 0.35 * S.saw(f * 1.002, n, sr=self.sr))
710
+ cut = 190.0 + (420.0 + 1700.0 * bright) * np.exp(-t / 0.1)
711
+ x = S.tv_filter(osc, "lowpass", cut, 1.0, block=64, sr=self.sr)
712
+ env = np.minimum(1.0, t / 0.004) * np.clip((dur + 0.05 - t) / 0.05, 0.0, 1.0)
713
+ x = np.tanh(x * 1.7) * 0.55 * env
714
+ return self._edge_fade(_f32(x), 0.001, 0.005)
715
+
716
+ def pluck_note(self, note: str, dur: float, bright: float) -> np.ndarray:
717
+ """Karplus-Strong 拨弦(sfxkit.pluck)—— 民谣 / 手作的木头感。"""
718
+ key = ("plucknote", note, round(float(dur), 3), int(round(bright * 10)))
719
+ return self._cached(key, lambda: self._make_pluck(note, dur, bright))
720
+
721
+ def _make_pluck(self, note: str, dur: float, bright: float) -> np.ndarray:
722
+ f = S.hz(note)
723
+ d = float(min(0.9, max(0.12, dur)))
724
+ x = S.pluck(f, d, brightness=_clip01(0.35 + 0.55 * bright), decay=0.9962,
725
+ seed=self._seed("pluck", note, int(round(bright * 10))), sr=self.sr)
726
+ x = np.asarray(x, dtype=np.float64)
727
+ want = _ns(d, self.sr)
728
+ if len(x) < want:
729
+ x = np.pad(x, (0, want - len(x)))
730
+ return self._edge_fade(_f32(x * 0.9), 0.0005, 0.01)
731
+
732
+ def epiano(self, note: str, dur: float) -> np.ndarray:
733
+ """电钢:FM 钟的变体 + 低八度正弦托底(杂志 / lo-fi 的"暖")。"""
734
+ key = ("epiano", note, round(float(dur), 3))
735
+ return self._cached(key, lambda: self._make_epiano(note, dur))
736
+
737
+ def _make_epiano(self, note: str, dur: float) -> np.ndarray:
738
+ f = S.hz(note)
739
+ d = float(min(3.0, max(0.25, dur)))
740
+ n = _ns(d, self.sr)
741
+ t = _t(n, self.sr)
742
+ idx = 1.5 * np.exp(-t / 0.12)
743
+ x = S.fm(f, 2.0, idx, n, sr=self.sr) * np.exp(-t / (d * 0.45))
744
+ x += 0.42 * S.sine(f * 0.5, n, sr=self.sr) * np.exp(-t / (d * 0.7))
745
+ x += 0.12 * S.fm(f * 2.0, 1.0, 0.35, n, sr=self.sr) * np.exp(-t / 0.25)
746
+ x *= np.minimum(1.0, t / 0.003)
747
+ return self._edge_fade(_f32(x * 0.6), 0.001, 0.02)
748
+
749
+ def marimba(self, note: str, dur: float) -> np.ndarray:
750
+ """马林巴:正弦基音 + 很短的 FM 敲击泛音。"""
751
+ key = ("marimba", note, round(float(dur), 3))
752
+ return self._cached(key, lambda: self._make_marimba(note, dur))
753
+
754
+ def _make_marimba(self, note: str, dur: float) -> np.ndarray:
755
+ f = S.hz(note)
756
+ d = float(min(2.0, max(0.35, dur)))
757
+ n = _ns(d, self.sr)
758
+ t = _t(n, self.sr)
759
+ x = S.sine(f, n, sr=self.sr) * np.exp(-t / 0.30)
760
+ x += 0.45 * S.fm(f, 3.0, 1.1 * np.exp(-t / 0.02), n, sr=self.sr) * np.exp(-t / 0.06)
761
+ x += 0.20 * S.sine(4.0 * f, n, sr=self.sr) * np.exp(-t / 0.05)
762
+ x *= np.minimum(1.0, t / 0.0015)
763
+ return self._edge_fade(_f32(x * 0.7), 0.0008, 0.02)
764
+
765
+ def glock(self, note: str, dur: float = 1.0) -> np.ndarray:
766
+ """钟琴:高比率 FM + 非谐波正弦,短促闪亮(vivid 的"活力")。"""
767
+ key = ("glock", note, round(float(dur), 2))
768
+ return self._cached(key, lambda: self._make_glock(note, dur))
769
+
770
+ def _make_glock(self, note: str, dur: float) -> np.ndarray:
771
+ f = S.hz(note)
772
+ d = float(min(1.6, max(0.25, dur)))
773
+ n = _ns(d, self.sr)
774
+ t = _t(n, self.sr)
775
+ x = S.fm(f, 3.5, 1.8 * np.exp(-t / 0.09), n, sr=self.sr) * np.exp(-t / 0.28)
776
+ x += 0.30 * S.sine(2.76 * f, n, sr=self.sr) * np.exp(-t / 0.16)
777
+ x *= np.minimum(1.0, t / 0.001)
778
+ return self._edge_fade(_f32(x * 0.55), 0.0005, 0.02)
779
+
780
+ def strings_bell(self, note: str, dur: float) -> np.ndarray:
781
+ """弦乐 + 钟的混合主奏:有弓弦的持续感,又有钟的起音(cinema / magazine)。"""
782
+ key = ("strbell", note, round(float(dur), 2))
783
+ return self._cached(key, lambda: self._make_strbell(note, dur))
784
+
785
+ def _make_strbell(self, note: str, dur: float) -> np.ndarray:
786
+ f = S.hz(note)
787
+ d = float(min(3.4, max(0.8, dur)))
788
+ n = _ns(d, self.sr)
789
+ t = _t(n, self.sr)
790
+ g = S.rng(self._seed("strbell", note, round(d, 2)))
791
+ osc = S.supersaw(f, n, voices=3, detune=0.05, seed=g, sr=self.sr)
792
+ osc = S.lowpass(osc, 1500.0, 0.8, sr=self.sr) * np.exp(-t / (d * 0.55))
793
+ bell = S.fm(f, 3.5, 1.6 * np.exp(-t / 0.3), n, sr=self.sr) * np.exp(-t / (d * 0.4))
794
+ x = (0.55 * osc + 0.85 * bell) * np.minimum(1.0, t / 0.004)
795
+ return self._edge_fade(_f32(x * 0.6), 0.002, 0.03)
796
+
797
+ def brass(self, note: str, dur: float, bright: float) -> np.ndarray:
798
+ """铜管:锯齿 + 脉冲过带通,起音短促(epic 的"定格重音")。"""
799
+ key = ("brass", note, round(float(dur), 3), int(round(bright * 10)))
800
+ return self._cached(key, lambda: self._make_brass(note, dur, bright))
801
+
802
+ def _make_brass(self, note: str, dur: float, bright: float) -> np.ndarray:
803
+ f = S.hz(note)
804
+ n = _ns(dur + 0.06, self.sr)
805
+ t = _t(n, self.sr)
806
+ osc = (0.85 * S.saw(f, n, sr=self.sr)
807
+ + 0.40 * S.pulse(f * 1.001, n, 0.42, sr=self.sr))
808
+ x = S.tv_filter(osc, "lowpass", 900.0 + 2600.0 * bright, 1.2, block=64, sr=self.sr)
809
+ x = S.bandpass(x, 900.0 + 1200.0 * bright, 0.8, sr=self.sr)
810
+ env = np.minimum(1.0, t / 0.035) * np.clip((dur + 0.04 - t) / 0.06, 0.0, 1.0)
811
+ x = np.tanh(x * 1.5) * 0.5 * env
812
+ return self._edge_fade(_f32(x), 0.002, 0.01)
813
+
814
+ def stab(self, note: str, dur: float, bright: float) -> np.ndarray:
815
+ """和弦短切音:极短带通脉冲,琶音层用它做"打点"。"""
816
+ key = ("stab", note, round(float(dur), 3), int(round(bright * 10)))
817
+ return self._cached(key, lambda: self._make_stab(note, dur, bright))
818
+
819
+ def _make_stab(self, note: str, dur: float, bright: float) -> np.ndarray:
820
+ f = S.hz(note)
821
+ d = float(min(0.6, max(0.12, dur)))
822
+ n = _ns(d, self.sr)
823
+ t = _t(n, self.sr)
824
+ osc = (0.70 * S.pulse(f, n, 0.30, sr=self.sr)
825
+ + 0.50 * S.saw(f * 1.003, n, sr=self.sr))
826
+ x = S.bandpass(osc, 1000.0 + 2400.0 * bright, 1.1, sr=self.sr) * np.exp(-t / 0.06)
827
+ x *= np.minimum(1.0, t / 0.001)
828
+ return self._edge_fade(_f32(x * 0.8), 0.0005, 0.008)
829
+
568
830
  # ---------------- 效果素材 ----------------
569
831
  def sub_boom(self, dur: float = 2.2, f0: float = 55.0, f1: float = 30.0) -> np.ndarray:
570
832
  """低频轰鸣:音高下滑的正弦(重击的"地基")。"""
@@ -693,7 +955,7 @@ class Score:
693
955
  _require(isinstance(bpm, (int, float)) and math.isfinite(float(bpm))
694
956
  and float(bpm) > 0.0,
695
957
  f"bpm 必须是有限正数,收到 {bpm!r}")
696
- _require(key in KEYS, f"未知调性 {key!r};可选:{'/'.join(KEYS)}")
958
+ _require(key in KEYS_ALL, f"未知调性 {key!r};可选:{'/'.join(KEYS_ALL)}")
697
959
  self.duration = float(duration)
698
960
  self.bpm = float(bpm)
699
961
  self.key = key
@@ -710,7 +972,7 @@ class Score:
710
972
  # ---------------- 公开属性 ----------------
711
973
  @property
712
974
  def k(self) -> dict:
713
- return KEYS[self.key]
975
+ return KEYS_ALL[self.key]
714
976
 
715
977
  def __repr__(self) -> str: # 只用于调试输出
716
978
  return (f"Score(duration={self.duration:g}, bpm={self.bpm:g}, key={self.key!r}, "
@@ -1327,7 +1589,7 @@ class Score:
1327
1589
  plan = self._build_plan()
1328
1590
  rep = {
1329
1591
  "version": 1,
1330
- "generator": f"scorekit {__version__}",
1592
+ "generator": "scorekit",
1331
1593
  "bpm": self.bpm,
1332
1594
  "key": self.key,
1333
1595
  "key_name": self.k["name"],
@@ -1358,6 +1620,1185 @@ class Score:
1358
1620
  return rep
1359
1621
 
1360
1622
 
1623
+
1624
+ # ============================================================================
1625
+ # v0.5 配乐风格层(MUSIC_STYLES):一个 style id = 一种"配乐性格"
1626
+ # ============================================================================
1627
+ #
1628
+ # 为什么要这一层:v0.4 的配乐只有一套和声进行 / 一套动机 / 一张 energy 查表,
1629
+ # 所以「不管什么内容,听众听到的都是同一首曲子的不同时长」。v0.5 把"写什么"拆成
1630
+ # 风格包:和弦进行、动机、鼓型、音色、空间、动态各自成表,同一部片的配乐由 style 决定。
1631
+ #
1632
+ # 与 stylekit.STYLES 的关系:
1633
+ # * 本模块不 import stylekit(scorekit 要能单独拷走运行,且 stylekit 是本模块的上游);
1634
+ # style id 与调池手工对齐,selftest 在 stylekit 存在时会交叉核对(缺失则 skip);
1635
+ # * stylekit 管"策展层"(BPM 梯度 / 调池 / 情绪),这里管"合成层"(和弦 / 鼓型 / 音色 /
1636
+ # 空间 / 动态),两边用同一个 style id 对齐。
1637
+ #
1638
+ # 数据契约(每个风格一个纯数据 dict,无 RNG 副作用):
1639
+ # bpm_hint 仅供文档 / CLI 显示;真正 BPM 由 stylekit 决定后传给 render_styled
1640
+ # keys 调池(与 STYLES[sid]["music"]["keys"] 一致;不在池里的调也能渲,只是不"正宗")
1641
+ # progressions {"calm"/"body"/"drive": 级数元组},级数必须在 _DEGREE_TABLE 里
1642
+ # motif {"q"/"a"/"end": ((拍, 级数, 时值拍), ...)}:级数相对主音,供主奏轮换
1643
+ # layers 层 -> 最低能量(0~1);过线才出现,进出各花 1 小节渐入渐出
1644
+ # voices {"pad"/"bass"/"arp"/"lead": 音色名},可选值见 _STYLE_VOICES
1645
+ # lead_cycle 主奏音色轮换表(每 phrase_bars 轮换一个)
1646
+ # drums {"calm"/"body"/"drive"/"climax": {kick,hat,clap,bass,arp}}
1647
+ # space {"delay_beats","delay_db","taps"} 乒乓延迟(拍为单位)
1648
+ # phrase_bars 4 / 8
1649
+ # duck {"bass","pad"} 侧链深度
1650
+ # level_span_db 段落能量 -> 电平的跨度(dB);8.5 与 legacy 的 energy_target_db 同口径
1651
+ # swing 0.0 / 0.08~0.16:奇数列踩镲与琶音延后(拍),只改位置不改 RNG 调用次数
1652
+
1653
+ MUSIC_STYLES = {
1654
+ # 科技深蓝:电子、切分、拨弦琶音;小调,四四拍推进
1655
+ "tech-deep": {
1656
+ "label": "科技深蓝",
1657
+ "bpm_hint": (104, 112, 120),
1658
+ "keys": ("Dm", "Am", "Em", "Cm"),
1659
+ "progressions": {
1660
+ "calm": ("i", "VI", "iv", "v"),
1661
+ "body": ("i", "VI", "III", "VII"),
1662
+ "drive": ("i", "VII", "VI", "V"),
1663
+ },
1664
+ "motif": {
1665
+ "q": ((0.0, "v", 0.75), (1.0, "i", 0.75), (2.0, "III", 0.75), (3.0, "iv", 1.75),
1666
+ (5.0, "III", 0.75), (6.0, "v", 0.75), (7.0, "i", 1.5)),
1667
+ "a": ((0.0, "v", 0.75), (1.0, "i", 0.75), (2.0, "III", 0.75), (3.0, "v", 0.75),
1668
+ (4.0, "VII", 2.0)),
1669
+ "end": ((0.0, "v", 1.0), (1.5, "i", 1.0), (2.5, "III", 1.5), (4.0, "i", 3.0)),
1670
+ },
1671
+ "layers": {"pad": 0.0, "bass": 0.24, "kick": 0.36, "hat": 0.46,
1672
+ "arp": 0.54, "melody": 0.66, "shine": 0.86},
1673
+ "voices": {"pad": "pad_saw", "bass": "pulse", "arp": "pluck", "lead": "bell"},
1674
+ "lead_cycle": ("bell", "marimba"),
1675
+ "drums": {
1676
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1677
+ "body": {"kick": (0.0, 2.0), "hat": 1.0, "clap": (), "bass": "half", "arp": 0.5},
1678
+ "drive": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.5, "clap": (1.0, 3.0),
1679
+ "bass": "off8", "arp": 0.25},
1680
+ "climax": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1681
+ "bass": "16", "arp": 0.25},
1682
+ },
1683
+ "space": {"delay_beats": 0.75, "delay_db": -15.0, "taps": 3},
1684
+ "phrase_bars": 4,
1685
+ "duck": {"bass": 0.44, "pad": 0.30},
1686
+ "level_span_db": 8.5,
1687
+ "swing": 0.0,
1688
+ },
1689
+ # 杂志暖白:大调七和弦、电钢、大提琴贝斯;慢速、留白多
1690
+ "magazine": {
1691
+ "label": "杂志暖白",
1692
+ "bpm_hint": (88, 92, 96, 100),
1693
+ "keys": ("C", "G", "D", "F"),
1694
+ "progressions": {
1695
+ "calm": ("Imaj7", "vi7", "ii7", "V7"),
1696
+ "body": ("I", "vi", "IV", "V"),
1697
+ "drive": ("I", "IV", "V", "vi"),
1698
+ },
1699
+ "motif": {
1700
+ "q": ((0.0, "V", 0.9), (0.9, "VI", 0.9), (1.8, "V", 0.9), (2.7, "III", 0.9),
1701
+ (3.5, "I", 1.5), (5.0, "ii", 0.9), (5.9, "III", 0.9), (6.8, "V", 2.2)),
1702
+ "a": ((0.0, "V", 0.9), (0.9, "VI", 0.9), (1.8, "iii", 0.9), (2.7, "ii", 0.9),
1703
+ (3.5, "I", 1.5), (5.0, "III", 0.9), (6.0, "V", 2.4)),
1704
+ "end": ((0.0, "V", 1.2), (1.5, "VI", 0.9), (2.5, "V", 1.0), (3.5, "I", 3.0)),
1705
+ },
1706
+ "layers": {"pad": 0.0, "bass": 0.30, "kick": 0.42, "hat": 0.52,
1707
+ "arp": 0.50, "melody": 0.30, "shine": 0.90},
1708
+ "voices": {"pad": "strings", "bass": "cello", "arp": "epiano", "lead": "celesta"},
1709
+ "lead_cycle": ("celesta", "strings_bell"),
1710
+ "drums": {
1711
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1712
+ "body": {"kick": (0.0, 2.0), "hat": 1.0, "clap": (), "bass": "half", "arp": 0.5},
1713
+ "drive": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (1.0, 3.0),
1714
+ "bass": "off8", "arp": 0.5},
1715
+ "climax": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1716
+ "bass": "off8", "arp": 0.25},
1717
+ },
1718
+ "space": {"delay_beats": 1.0, "delay_db": -17.0, "taps": 2},
1719
+ "phrase_bars": 8,
1720
+ "duck": {"bass": 0.34, "pad": 0.24},
1721
+ "level_span_db": 7.0,
1722
+ "swing": 0.0,
1723
+ },
1724
+ # 手绘纸感:木吉他拨弦、马林巴、轻微摇摆;大调,温暖
1725
+ "paper": {
1726
+ "label": "手绘纸感",
1727
+ "bpm_hint": (84, 88, 92),
1728
+ "keys": ("G", "D", "C"),
1729
+ "progressions": {
1730
+ "calm": ("I", "IV", "vi", "V"),
1731
+ "body": ("I", "IV", "V", "vi"),
1732
+ "drive": ("I", "V", "IV", "I"),
1733
+ },
1734
+ "motif": {
1735
+ "q": ((0.0, "I", 1.0), (1.0, "III", 1.0), (2.0, "V", 1.0), (3.0, "VI", 1.5),
1736
+ (4.5, "V", 0.75), (5.25, "III", 0.75), (6.0, "I", 2.0)),
1737
+ "a": ((0.0, "III", 1.0), (1.0, "V", 1.0), (2.0, "VI", 1.0), (3.0, "V", 1.5),
1738
+ (4.5, "III", 0.75), (5.25, "V", 0.75), (6.0, "I", 2.0)),
1739
+ "end": ((0.0, "V", 1.0), (1.5, "VI", 1.0), (2.5, "V", 1.5), (4.0, "I", 3.5)),
1740
+ },
1741
+ "layers": {"pad": 0.0, "bass": 0.28, "kick": 0.50, "hat": 0.62,
1742
+ "arp": 0.45, "melody": 0.30, "shine": 0.88},
1743
+ "voices": {"pad": "strings", "bass": "cello", "arp": "pluck", "lead": "marimba"},
1744
+ "lead_cycle": ("marimba", "celesta"),
1745
+ "drums": {
1746
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1747
+ "body": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (), "bass": "half", "arp": 0.5},
1748
+ "drive": {"kick": (0.0, 2.0), "hat": 0.25, "clap": (1.0, 3.0),
1749
+ "bass": "off8", "arp": 0.25},
1750
+ "climax": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1751
+ "bass": "off8", "arp": 0.25},
1752
+ },
1753
+ "space": {"delay_beats": 0.5, "delay_db": -18.0, "taps": 2},
1754
+ "phrase_bars": 4,
1755
+ "duck": {"bass": 0.28, "pad": 0.22},
1756
+ "level_span_db": 7.5,
1757
+ "swing": 0.10,
1758
+ },
1759
+ # 活力撞色:大调 4/4、短切和弦、钟琴主奏;高能量、明亮
1760
+ "vivid": {
1761
+ "label": "活力撞色",
1762
+ "bpm_hint": (120, 126, 132),
1763
+ "keys": ("D", "G", "A"),
1764
+ "progressions": {
1765
+ "calm": ("I", "vi", "IV", "V"),
1766
+ "body": ("I", "V", "vi", "IV"),
1767
+ "drive": ("I", "IV", "V", "I"),
1768
+ },
1769
+ "motif": {
1770
+ "q": ((0.0, "V", 0.5), (0.5, "V", 0.25), (1.0, "VI", 0.5), (1.5, "V", 0.5),
1771
+ (2.0, "III", 0.5), (2.5, "I", 1.5), (4.0, "V", 0.5), (4.5, "VI", 0.5),
1772
+ (5.0, "V", 1.0), (6.0, "I", 2.0)),
1773
+ "a": ((0.0, "III", 0.5), (0.5, "III", 0.25), (1.0, "V", 0.5), (1.5, "III", 0.5),
1774
+ (2.0, "V", 0.5), (2.5, "VI", 1.5), (4.0, "V", 0.5), (5.0, "III", 1.0),
1775
+ (6.0, "I", 2.0)),
1776
+ "end": ((0.0, "V", 0.5), (1.0, "VI", 0.75), (2.0, "V", 1.5), (4.0, "I", 3.0)),
1777
+ },
1778
+ "layers": {"pad": 0.0, "bass": 0.20, "kick": 0.30, "hat": 0.40,
1779
+ "arp": 0.44, "melody": 0.48, "shine": 0.78},
1780
+ "voices": {"pad": "pad_saw", "bass": "pulse", "arp": "stab", "lead": "glock"},
1781
+ "lead_cycle": ("glock", "bell"),
1782
+ "drums": {
1783
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1784
+ "body": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (1.0, 3.0),
1785
+ "bass": "off8", "arp": 0.5},
1786
+ "drive": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1787
+ "bass": "16", "arp": 0.25},
1788
+ "climax": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1789
+ "bass": "16", "arp": 0.25},
1790
+ },
1791
+ "space": {"delay_beats": 0.5, "delay_db": -16.0, "taps": 3},
1792
+ "phrase_bars": 4,
1793
+ "duck": {"bass": 0.50, "pad": 0.32},
1794
+ "level_span_db": 9.0,
1795
+ "swing": 0.0,
1796
+ },
1797
+ # 极简黑白:只有必要的声音,长音、留白、钢片琴;慢速
1798
+ "mono": {
1799
+ "label": "极简黑白",
1800
+ "bpm_hint": (60, 64, 68),
1801
+ "keys": ("C", "F", "Am"),
1802
+ "progressions": {
1803
+ "calm": ("I", "IV", "I", "V"),
1804
+ "body": ("I", "IV", "V", "IV"),
1805
+ "drive": ("I", "IV", "V", "I"),
1806
+ },
1807
+ "motif": {
1808
+ "q": ((0.0, "I", 1.5), (1.5, "III", 1.5), (3.0, "V", 2.0),
1809
+ (6.0, "III", 1.0), (7.0, "V", 3.0)),
1810
+ "a": ((0.0, "V", 1.5), (1.5, "III", 1.5), (3.0, "I", 2.0),
1811
+ (6.0, "V", 1.0), (7.0, "I", 3.0)),
1812
+ "end": ((0.0, "V", 2.0), (2.0, "III", 1.5), (3.5, "I", 4.0)),
1813
+ },
1814
+ "layers": {"pad": 0.0, "bass": 0.34, "kick": 0.55, "hat": 0.70,
1815
+ "arp": 0.60, "melody": 0.25, "shine": 0.92},
1816
+ "voices": {"pad": "strings", "bass": "cello", "arp": "epiano", "lead": "celesta"},
1817
+ "lead_cycle": ("celesta", "bell"),
1818
+ "drums": {
1819
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1820
+ "body": {"kick": (0.0,), "hat": None, "clap": (), "bass": "whole", "arp": None},
1821
+ "drive": {"kick": (0.0, 2.0), "hat": 1.0, "clap": (), "bass": "half", "arp": 0.5},
1822
+ "climax": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (),
1823
+ "bass": "half", "arp": 0.5},
1824
+ },
1825
+ "space": {"delay_beats": 2.0, "delay_db": -20.0, "taps": 2},
1826
+ "phrase_bars": 8,
1827
+ "duck": {"bass": 0.30, "pad": 0.20},
1828
+ "level_span_db": 6.0,
1829
+ "swing": 0.0,
1830
+ },
1831
+ # 电影青橙:小调、弦乐铺底、大空间;安静到爆发跨度最大
1832
+ "cinema": {
1833
+ "label": "电影青橙",
1834
+ "bpm_hint": (76, 80, 84),
1835
+ "keys": ("Dm", "Am"),
1836
+ "progressions": {
1837
+ "calm": ("i", "VI", "III", "VII"),
1838
+ "body": ("i", "VII", "VI", "V"),
1839
+ "drive": ("i", "VI", "III", "VII"),
1840
+ },
1841
+ "motif": {
1842
+ "q": ((0.0, "i", 1.5), (1.5, "III", 1.0), (2.5, "iv", 2.0),
1843
+ (5.0, "III", 1.0), (6.0, "i", 3.0)),
1844
+ "a": ((0.0, "III", 1.5), (1.5, "VI", 1.0), (2.5, "VII", 2.0),
1845
+ (5.0, "v", 1.0), (6.0, "i", 3.0)),
1846
+ "end": ((0.0, "VI", 1.5), (1.5, "VII", 1.0), (2.5, "v", 1.5), (4.0, "i", 4.0)),
1847
+ },
1848
+ "layers": {"pad": 0.0, "bass": 0.26, "kick": 0.52, "hat": 0.68,
1849
+ "arp": 0.62, "melody": 0.30, "shine": 0.88},
1850
+ "voices": {"pad": "strings", "bass": "cello", "arp": "epiano",
1851
+ "lead": "strings_bell"},
1852
+ "lead_cycle": ("strings_bell", "celesta"),
1853
+ "drums": {
1854
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1855
+ "body": {"kick": (0.0,), "hat": None, "clap": (), "bass": "whole", "arp": None},
1856
+ "drive": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (), "bass": "half", "arp": 0.5},
1857
+ "climax": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1858
+ "bass": "off8", "arp": 0.25},
1859
+ },
1860
+ "space": {"delay_beats": 1.5, "delay_db": -14.0, "taps": 3},
1861
+ "phrase_bars": 8,
1862
+ "duck": {"bass": 0.40, "pad": 0.26},
1863
+ "level_span_db": 10.0,
1864
+ "swing": 0.0,
1865
+ },
1866
+ # 暖灰薄荷:lo-fi 摇摆、电钢、808 贝斯;轻鼓、留白
1867
+ "lo-fi": {
1868
+ "label": "暖灰薄荷",
1869
+ "bpm_hint": (72, 78, 84),
1870
+ "keys": ("C", "F", "G"),
1871
+ "progressions": {
1872
+ "calm": ("Imaj7", "vi7", "ii7", "V7"),
1873
+ "body": ("Imaj7", "IVmaj7", "iii7", "vi7"),
1874
+ "drive": ("ii7", "V7", "Imaj7", "IVmaj7"),
1875
+ },
1876
+ "motif": {
1877
+ "q": ((0.0, "III", 1.0), (1.0, "V", 1.0), (2.0, "VI", 1.0), (3.0, "V", 1.0),
1878
+ (4.0, "I", 1.0), (5.0, "ii", 1.0), (6.0, "III", 1.0), (7.0, "I", 2.0)),
1879
+ "a": ((0.0, "V", 1.0), (1.0, "VI", 1.0), (2.0, "V", 1.0), (3.0, "III", 1.0),
1880
+ (4.0, "ii", 1.0), (5.0, "III", 1.0), (6.0, "V", 1.0), (7.0, "I", 2.0)),
1881
+ "end": ((0.0, "V", 1.0), (1.0, "VI", 1.0), (2.0, "V", 1.0), (3.0, "I", 4.0)),
1882
+ },
1883
+ "layers": {"pad": 0.0, "bass": 0.24, "kick": 0.40, "hat": 0.48,
1884
+ "arp": 0.44, "melody": 0.32, "shine": 0.90},
1885
+ "voices": {"pad": "pad_saw", "bass": "sub", "arp": "epiano", "lead": "marimba"},
1886
+ "lead_cycle": ("marimba", "celesta"),
1887
+ "drums": {
1888
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1889
+ "body": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (), "bass": "half", "arp": 0.5},
1890
+ "drive": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (1.0, 3.0),
1891
+ "bass": "off8", "arp": 0.5},
1892
+ "climax": {"kick": (0.0, 2.0), "hat": 0.25, "clap": (1.0, 3.0),
1893
+ "bass": "off8", "arp": 0.25},
1894
+ },
1895
+ "space": {"delay_beats": 0.75, "delay_db": -16.0, "taps": 2},
1896
+ "phrase_bars": 4,
1897
+ "duck": {"bass": 0.38, "pad": 0.26},
1898
+ "level_span_db": 6.5,
1899
+ "swing": 0.12,
1900
+ },
1901
+ # 深黑鎏金:小调、铜管主奏、四四拍重击;动态跨度最大
1902
+ "epic": {
1903
+ "label": "深黑鎏金",
1904
+ "bpm_hint": (88, 92, 96),
1905
+ "keys": ("Cm", "Dm"),
1906
+ "progressions": {
1907
+ "calm": ("i", "VI", "iv", "V"),
1908
+ "body": ("i", "VI", "III", "VII"),
1909
+ "drive": ("i", "VII", "VI", "V"),
1910
+ },
1911
+ "motif": {
1912
+ "q": ((0.0, "i", 1.0), (1.0, "III", 1.0), (2.0, "v", 1.0), (3.0, "VI", 2.0),
1913
+ (5.0, "v", 1.0), (6.0, "i", 3.0)),
1914
+ "a": ((0.0, "v", 1.0), (1.0, "VI", 1.0), (2.0, "VII", 1.0), (3.0, "V", 2.0),
1915
+ (5.0, "VII", 1.0), (6.0, "i", 3.0)),
1916
+ "end": ((0.0, "VI", 1.5), (1.5, "VII", 1.0), (2.5, "v", 1.0), (3.5, "i", 4.0)),
1917
+ },
1918
+ "layers": {"pad": 0.0, "bass": 0.20, "kick": 0.32, "hat": 0.42,
1919
+ "arp": 0.50, "melody": 0.30, "shine": 0.80},
1920
+ "voices": {"pad": "strings", "bass": "cello", "arp": "stab", "lead": "brass"},
1921
+ "lead_cycle": ("brass", "strings_bell"),
1922
+ "drums": {
1923
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1924
+ "body": {"kick": (0.0, 2.0), "hat": 0.5, "clap": (), "bass": "half", "arp": 0.5},
1925
+ "drive": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1926
+ "bass": "16", "arp": 0.25},
1927
+ "climax": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1928
+ "bass": "16", "arp": 0.25},
1929
+ },
1930
+ "space": {"delay_beats": 0.75, "delay_db": -13.0, "taps": 3},
1931
+ "phrase_bars": 4,
1932
+ "duck": {"bass": 0.46, "pad": 0.34},
1933
+ "level_span_db": 11.0,
1934
+ "swing": 0.0,
1935
+ },
1936
+ # legacy:给 render_styled 用的"v0.4 近似包"(真实兼容路径不经过这里;
1937
+ # render(style=None) / render(style="legacy") 直接走老的 Score.render())
1938
+ "legacy": {
1939
+ "label": "v0.4 兼容",
1940
+ "bpm_hint": (120,),
1941
+ "keys": ("Dm", "Am", "Cm", "Em"),
1942
+ "progressions": {
1943
+ "calm": ("i", "VI", "iv", "v"),
1944
+ "body": ("i", "VI", "III", "VII"),
1945
+ "drive": ("i", "VII", "VI", "V"),
1946
+ },
1947
+ "motif": {
1948
+ "q": ((0.0, "i", 0.9), (1.0, "v", 0.9), (2.0, "I", 0.9), (3.0, "III", 1.8),
1949
+ (5.0, "i", 0.9), (6.0, "VII", 0.9), (7.0, "v", 1.6)),
1950
+ "a": ((0.0, "v", 0.9), (1.0, "i", 0.9), (2.0, "III", 0.9), (3.0, "iv", 0.9),
1951
+ (4.0, "v", 2.4)),
1952
+ "end": ((0.0, "v", 0.9), (1.0, "i", 0.9), (2.0, "III", 0.9), (3.0, "iv", 1.6),
1953
+ (5.0, "i", 3.0)),
1954
+ },
1955
+ "layers": {"pad": 0.0, "bass": 0.22, "kick": 0.34, "hat": 0.44,
1956
+ "arp": 0.55, "melody": 0.66, "shine": 0.82},
1957
+ "voices": {"pad": "pad_saw", "bass": "pulse", "arp": "fm", "lead": "bell"},
1958
+ "lead_cycle": ("bell", "celesta"),
1959
+ "drums": {
1960
+ "calm": {"kick": (), "hat": None, "clap": (), "bass": "whole", "arp": None},
1961
+ "body": {"kick": (0.0, 2.0), "hat": 1.0, "clap": (), "bass": "half", "arp": 0.5},
1962
+ "drive": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1963
+ "bass": "16", "arp": 0.25},
1964
+ "climax": {"kick": (0.0, 1.0, 2.0, 3.0), "hat": 0.25, "clap": (1.0, 3.0),
1965
+ "bass": "16", "arp": 0.25},
1966
+ },
1967
+ "space": {"delay_beats": 0.56, "delay_db": -15.0, "taps": 3},
1968
+ "phrase_bars": 4,
1969
+ "duck": {"bass": 0.42, "pad": 0.30},
1970
+ "level_span_db": 8.5,
1971
+ "swing": 0.0,
1972
+ },
1973
+ }
1974
+
1975
+ MUSIC_STYLE_IDS = tuple(MUSIC_STYLES)
1976
+ LEGACY_MUSIC_STYLE = "legacy"
1977
+
1978
+ # 情绪修饰:不改和弦功能,只改亮度 / 层数 / 鼓密度 / 主奏电平
1979
+ MOOD_IDS = ("steady", "calm", "reflective", "hopeful", "uplifting", "driving", "tense")
1980
+ _MOOD = {
1981
+ "steady": {"bright": 0.00, "layer": 0.00, "drum": 0.00, "lead_db": 0.0},
1982
+ "calm": {"bright": -0.18, "layer": 0.06, "drum": -0.06, "lead_db": -1.5},
1983
+ "reflective": {"bright": -0.12, "layer": 0.04, "drum": -0.04, "lead_db": -1.0},
1984
+ "hopeful": {"bright": 0.04, "layer": -0.02, "drum": 0.00, "lead_db": 0.5},
1985
+ "uplifting": {"bright": 0.14, "layer": -0.05, "drum": 0.05, "lead_db": 1.0},
1986
+ "driving": {"bright": 0.22, "layer": -0.07, "drum": 0.07, "lead_db": 1.2},
1987
+ "tense": {"bright": 0.02, "layer": 0.00, "drum": 0.03, "lead_db": 0.5},
1988
+ }
1989
+
1990
+ # 音色槽 -> 可选音色(实现见 _Bank 的 v0.5 方法;"fm" 复用老 FM 琶音)
1991
+ _STYLE_VOICES = {
1992
+ "pad": {"pad_saw": "pad", "strings": "strings"},
1993
+ "bass": {"pulse": "pulse", "cello": "cello", "sub": "sub"},
1994
+ "arp": {"pluck": "pluck", "epiano": "epiano", "stab": "stab", "fm": "fm"},
1995
+ "lead": {"bell": "bell", "celesta": "celesta", "marimba": "marimba",
1996
+ "glock": "glock", "strings_bell": "strings_bell", "brass": "brass",
1997
+ "epiano": "epiano"},
1998
+ }
1999
+
2000
+
2001
+ def _normalize_style_id(style):
2002
+ """None / "" / "legacy" -> None(走 v0.4 原路径);其余必须是 MUSIC_STYLES 的键。"""
2003
+ if style is None:
2004
+ return None
2005
+ sid = str(style).strip()
2006
+ if sid in ("", LEGACY_MUSIC_STYLE):
2007
+ return None
2008
+ _require(sid in MUSIC_STYLES, f"未知配乐风格 {style!r};可选:{'/'.join(MUSIC_STYLE_IDS)}")
2009
+ return sid
2010
+
2011
+
2012
+ def _styled_tier(energy: float) -> str:
2013
+ """风格化四档:calm / body / drive / climax(比 legacy 多一档,给 epic / vivid 用)。"""
2014
+ if energy < 0.40:
2015
+ return "calm"
2016
+ if energy < 0.70:
2017
+ return "body"
2018
+ if energy < 0.88:
2019
+ return "drive"
2020
+ return "climax"
2021
+
2022
+
2023
+ def _styled_layers(pack: dict, energy: float, mood: str) -> list:
2024
+ """按"层阈值 + mood 偏移"算本段出现的层;pad 恒在(配乐不能没有和声底)。"""
2025
+ m = _MOOD.get(mood, _MOOD["steady"])
2026
+ out = []
2027
+ for name in LAYER_ORDER:
2028
+ thr = float(pack["layers"][name])
2029
+ if name == "pad":
2030
+ out.append(name)
2031
+ continue
2032
+ if name == "bass":
2033
+ adj = 0.5 * m["layer"]
2034
+ elif name in ("kick", "hat"):
2035
+ adj = m["layer"] * 1.5 - m["drum"]
2036
+ else:
2037
+ adj = m["layer"]
2038
+ if energy >= max(0.0, thr + adj):
2039
+ out.append(name)
2040
+ return out
2041
+
2042
+
2043
+ def _styled_progression(pack: dict, tier: str, mood: str) -> tuple:
2044
+ """取该 tier 的级数进行;climax 复用 drive(高潮靠鼓与层数,不靠换和弦)。"""
2045
+ progs = pack["progressions"]
2046
+ key = tier if tier in progs else ("drive" if tier == "climax" else "body")
2047
+ return tuple(progs[key])
2048
+
2049
+
2050
+ def _styled_chords(sec: dict, si: int, secs: list, prog: tuple, pack: dict, mood: str) -> list:
2051
+ """一小节一个和弦:级数循环 + 乐句末收属和弦 + mood 色彩 + 片尾落 I。"""
2052
+ nb = len(sec["bars"])
2053
+ ph = int(pack["phrase_bars"])
2054
+ tier = sec["tier"]
2055
+ is_last_sec = (si == len(secs) - 1)
2056
+ out = []
2057
+ 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"
2077
+ out.append(deg)
2078
+ return out
2079
+
2080
+
2081
+ def _styled_target_db(energy: float, span: float) -> float:
2082
+ """能量 -> 相对电平(dB)。span = 最安静段到最响段的电平跨度;
2083
+
2084
+ span=8.5 时与 legacy 的 energy_target_db() 完全同式:-8.5 * (1 - energy)。
2085
+ """
2086
+ return -float(span) * (1.0 - _clip01(energy))
2087
+
2088
+
2089
+ def _styled_swing_shift(swing: float, step: float, k: int) -> float:
2090
+ """摇摆:只延后"奇数细分"(不影响 RNG 调用次数,只改落点)。"""
2091
+ if swing <= 0.0 or step >= 1.0 or (k % 2) == 0:
2092
+ return 0.0
2093
+ return float(swing) * min(1.0, step / 0.5)
2094
+
2095
+
2096
+ def _degree_note(kval: dict, degree: str, octave: int = 4) -> str:
2097
+ """级数 -> 音名(相对主音,跨八度自动进位)。"""
2098
+ row = _DEGREE_BY_NAME[degree]
2099
+ return _semi_note(kval["root_pc"], int(octave), int(row[1]), kval["names"])
2100
+
2101
+
2102
+ def _check_voice(slot: str, voice: str) -> str:
2103
+ """校验风格音色名并返回内部实现名(未知音色直接报错,不静默回落)。"""
2104
+ table = _STYLE_VOICES[slot]
2105
+ _require(voice in table, f"风格音色 {slot}={voice!r} 未知;可选:{'/'.join(table)}")
2106
+ return table[voice]
2107
+
2108
+
2109
+ def _bass_texture(bank: "_Bank", voice: str, note: str, dur: float, bright: float) -> np.ndarray:
2110
+ if voice == "cello":
2111
+ return bank.cello(note, dur, bright)
2112
+ if voice == "sub":
2113
+ return bank.sub_bass(note, dur)
2114
+ return bank.pulse_bass(note, dur, bright)
2115
+
2116
+
2117
+ def _arp_texture(bank: "_Bank", voice: str, note: str, bright: float, dur: float = 0.3) -> np.ndarray:
2118
+ if voice == "pluck":
2119
+ return bank.pluck_note(note, dur, bright)
2120
+ if voice == "epiano":
2121
+ return bank.epiano(note, max(0.35, dur))
2122
+ if voice == "stab":
2123
+ return bank.stab(note, dur, bright)
2124
+ return bank.arp(note, bright, dur)
2125
+
2126
+
2127
+ def _lead_texture(bank: "_Bank", voice: str, note: str, dur: float, bright: float) -> np.ndarray:
2128
+ if voice == "celesta":
2129
+ return bank.celesta(note, max(2.8, dur))
2130
+ if voice == "marimba":
2131
+ return bank.marimba(note, max(0.6, dur))
2132
+ if voice == "glock":
2133
+ return bank.glock(note, max(0.5, dur))
2134
+ if voice == "strings_bell":
2135
+ return bank.strings_bell(note, max(1.2, dur))
2136
+ if voice == "brass":
2137
+ return bank.brass(note, max(0.5, dur), bright)
2138
+ if voice == "epiano":
2139
+ return bank.epiano(note, max(0.8, dur))
2140
+ return bank.bell(note, max(2.4, dur))
2141
+
2142
+
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 指定。"""
2146
+ 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)
2149
+ tex = _lead_texture(bank, voice, note, dur_beats * beat, bright)
2150
+ pan = 0.18 if idx % 2 == 0 else -0.18
2151
+ bus.add(tex, t0 + pos_beats * beat, db, pan)
2152
+
2153
+
2154
+ def _styled_gate_env(n: int, secs: list, present: list, bar: float, sr: int = SR) -> np.ndarray:
2155
+ """层进出门控:某层在相邻段缺席时,用 1 小节线性坡道渐入 / 渐出。"""
2156
+ env = np.ones(int(n), np.float32)
2157
+ L = max(2, _ns(bar, sr))
2158
+ nsec = len(secs)
2159
+ for si, sec in enumerate(secs):
2160
+ s = max(0, min(int(n), int(round(float(sec["start"]) * sr))))
2161
+ e = max(0, min(int(n), int(round(float(sec["end"]) * sr))))
2162
+ if e - s < 2:
2163
+ continue
2164
+ if not present[si]:
2165
+ env[s:e] = 0.0
2166
+ continue
2167
+ prev = present[si - 1] if si > 0 else present[si]
2168
+ nxt = present[si + 1] if si + 1 < nsec else present[si]
2169
+ if not prev:
2170
+ seg = env[s:min(e, s + L)]
2171
+ seg *= _f32(np.linspace(0.0, 1.0, len(seg)) ** 1.5)
2172
+ if not nxt:
2173
+ seg = env[max(s, e - L):e]
2174
+ seg *= _f32(np.linspace(1.0, 0.0, len(seg)) ** 1.5)
2175
+ return env
2176
+
2177
+
2178
+ def _styled_seed(seed: int, fingerprint, style: str, mood: str) -> int:
2179
+ """内容指纹 -> 音色细节 seed(只影响 v0.5 风格路径;legacy 不参与)。"""
2180
+ base = int(seed)
2181
+ if fingerprint is None or str(fingerprint) == "":
2182
+ return base
2183
+ h = _hash_int("v0.5", style, mood, str(fingerprint))
2184
+ return base * 1000 + (h % 1000)
2185
+
2186
+
2187
+ def _styled_params(style: str, bpm, key, mood, seed, music_plan) -> dict:
2188
+ """合并显式参数与 stylekit 的 music dict:plan 优先,显式参数只在 plan 缺项时兜底。
2189
+
2190
+ music_plan 可以是 resolve_script_style()["music"](推荐),也可以是整份 plan(含 music 子字典)。
2191
+ 缺省回退顺序:plan -> 显式参数 -> 风格包自带值(bpm_hint 中位 / 调池首个 / steady / 7)。
2192
+ """
2193
+ pack = MUSIC_STYLES[style]
2194
+ hint = pack["bpm_hint"]
2195
+ eff = {
2196
+ "bpm": None if bpm is None else float(bpm),
2197
+ "key": None if key is None else str(key),
2198
+ "mood": "steady" if mood is None else str(mood),
2199
+ "seed": 7 if seed is None else int(seed),
2200
+ }
2201
+ plan = music_plan
2202
+ if isinstance(plan, dict):
2203
+ if isinstance(plan.get("music"), dict):
2204
+ plan = plan["music"]
2205
+ if plan.get("bpm") not in (None, ""):
2206
+ eff["bpm"] = float(plan["bpm"])
2207
+ if plan.get("key") not in (None, ""):
2208
+ eff["key"] = str(plan["key"])
2209
+ if plan.get("mood") not in (None, ""):
2210
+ eff["mood"] = str(plan["mood"]).strip().lower() or "steady"
2211
+ # seed_effective 是 stylekit 已经掺过内容指纹的最终种子;没有就退回 seed
2212
+ for fld in ("seed_effective", "seed"):
2213
+ if plan.get(fld) is not None:
2214
+ try:
2215
+ eff["seed"] = abs(int(plan[fld]))
2216
+ break
2217
+ except (TypeError, ValueError):
2218
+ continue
2219
+ if eff["bpm"] is None:
2220
+ eff["bpm"] = float(hint[len(hint) // 2])
2221
+ if eff["key"] is None:
2222
+ eff["key"] = str(pack["keys"][0])
2223
+ eff["mood"] = str(eff["mood"] or "steady").strip().lower() or "steady"
2224
+ return eff
2225
+
2226
+
2227
+ def _apply_pace(blocks: list, pace) -> list:
2228
+ """按 pacing 的 min_dur + hold 把过碎的段落合并到下限(不改片长、不挪时间轴)。
2229
+
2230
+ stylekit 的 min_dur 是"一条字幕至少停留多久",hold 是"句末额外留白";
2231
+ 这里只用两者之和当"段落最短长度":太碎的锚点(例如 1 小节一个)会被并进前一段,
2232
+ 名字保留前一段,energy 取较高者(合并后的段要能代表最响的那一刻)。
2233
+ """
2234
+ if not pace or not isinstance(pace, dict) or not blocks:
2235
+ return blocks
2236
+ try:
2237
+ lo = float(pace.get("min_dur", 0.0)) + float(pace.get("hold", 0.0))
2238
+ except (TypeError, ValueError):
2239
+ return blocks
2240
+ if lo <= 0.0:
2241
+ return blocks
2242
+ out = [dict(blocks[0])]
2243
+ for b in blocks[1:]:
2244
+ prev = out[-1]
2245
+ if float(prev["end"]) - float(prev["start"]) < lo - 1e-9:
2246
+ prev["end"] = float(b["end"])
2247
+ prev["energy"] = max(float(prev["energy"]), float(b["energy"]))
2248
+ else:
2249
+ out.append(dict(b))
2250
+ while len(out) > 1 and (float(out[-1]["end"]) - float(out[-1]["start"])) < lo - 1e-9:
2251
+ last = out.pop()
2252
+ out[-1]["end"] = float(last["end"])
2253
+ out[-1]["energy"] = max(float(out[-1]["energy"]), float(last["energy"]))
2254
+ return out
2255
+
2256
+
2257
+ def render_styled(duration, *, style, bpm=None, key=None, mood=None, anchors=None,
2258
+ sections=None, seed=7, fingerprint=None, sucks=None,
2259
+ music_plan=None, pace=None) -> tuple:
2260
+ """按风格渲一条配乐(v0.5):返回 (samples, report)。
2261
+
2262
+ 与 render() 的区别只在"怎么写",对外保证相同:
2263
+ * 时长精确(ceil(duration*sr) 采样)、峰值 <= -1.5 dBFS、同 seed 逐样本一致;
2264
+ * 复用老 _Bank 的鼓组与 _Bus 总线,只新增音色方法,不重写任何老乐器;
2265
+ * 老路径(render(style=None) / "legacy")完全不受影响,逐样本与 v0.4 相同。
2266
+
2267
+ anchors / sections 语义与 render() 一致;mood 见 MOOD_IDS;fingerprint 只影响 seed 派生。
2268
+
2269
+ music_plan:stylekit.resolve_script_style(script) 的 "music" 子字典(整份 plan 也接受)。
2270
+ 非 None 时 bpm / key / mood / seed 以它为准(优先级高于显式参数)——render.py 只需
2271
+ 传一个 dict,不必和两处默认值打架;显式参数退化成"plan 里没写时的缺省"。
2272
+ pace:stylekit 的 pacing dict(至少含 min_dur / hold)。只按"min_dur + hold"把过碎的段落
2273
+ 合并到下限,不改 anchors 时间轴、不改片长;没有 pacing 需求时传 None 即可。
2274
+
2275
+ 性能:styled 比 legacy 慢约 1.5~2.5x(多声部弦乐 + 新音色是主要成本);
2276
+ 本机实测 60 s 片 3~6 s、388 s 片约 20~40 s(legacy 60 s ≈ 2.4 s),属预期。
2277
+ """
2278
+ t_start = time.perf_counter()
2279
+ _require(style in MUSIC_STYLES, f"未知配乐风格 {style!r};可选:{'/'.join(MUSIC_STYLE_IDS)}")
2280
+ eff = _styled_params(style, bpm, key, mood, seed, music_plan)
2281
+ bpm, key, mood, seed = eff["bpm"], eff["key"], eff["mood"], eff["seed"]
2282
+ _require(mood in MOOD_IDS, f"未知情绪 {mood!r};可选:{'/'.join(MOOD_IDS)}")
2283
+ _require(isinstance(bpm, (int, float)) and math.isfinite(float(bpm)) and float(bpm) > 0.0,
2284
+ f"bpm 必须是有限正数,收到 {bpm!r}")
2285
+ _require(key in KEYS_ALL, f"未知调性 {key!r};可选:{'/'.join(KEYS_ALL)}")
2286
+ pack = MUSIC_STYLES[style]
2287
+ dur = float(duration)
2288
+ sr = SR
2289
+ sc = Score(duration, bpm=float(bpm), key=key, seed=int(seed))
2290
+ # _merge_sections 在没有 anchors/sections 时已经返回"全片",这里不要再补一段:
2291
+ # 补了会在非小节对齐的片长上多出一个 0.x 秒的重复段(legacy 路径保留该历史行为)。
2292
+ for s in _apply_pace(_merge_sections(anchors, sections, dur), pace):
2293
+ sc.section(s["name"], s["start"], s["end"], s["energy"])
2294
+ for t, db, d in (sucks or ()):
2295
+ sc.suck(t, db, d)
2296
+ plan = sc._build_plan()
2297
+ secs = plan["sections"]
2298
+ bar, beat = plan["bar_s"], plan["beat_s"]
2299
+ n = max(int(plan["n"]), int(math.ceil(dur * sr - 1e-9)))
2300
+ kval = sc.k
2301
+ seed_eff = _styled_seed(sc.seed, fingerprint, style, mood)
2302
+ bank = _Bank(seed_eff, beat, bar, sr)
2303
+
2304
+ m = _MOOD[mood]
2305
+ mood_bright = float(m["bright"])
2306
+ span = float(pack["level_span_db"])
2307
+ ph = int(pack["phrase_bars"])
2308
+ swing = float(pack["swing"])
2309
+ space = pack["space"]
2310
+
2311
+ # ---- 1) 段落规划:tier / 层 / 和弦 / 电平目标 ----
2312
+ for si, sec in enumerate(secs):
2313
+ e = sec["energy"]
2314
+ tier = _styled_tier(e)
2315
+ sec["tier"] = tier
2316
+ sec["layers"] = _styled_layers(pack, e, mood)
2317
+ sec["chords"] = _styled_chords(sec, si, secs, _styled_progression(pack, tier, mood),
2318
+ pack, mood)
2319
+ sec["target_db"] = round(_styled_target_db(e, span), 2)
2320
+ sec["target_lufs"] = round(BASE_LUFS + sec["target_db"], 1)
2321
+ sec["breath"] = {"calm": 2, "body": 1, "drive": 0, "climax": 0}[tier]
2322
+ present = {name: [name in s["layers"] for s in secs] for name in LAYER_ORDER}
2323
+ last_si = len(secs) - 1
2324
+
2325
+ master = np.zeros((n, 2), np.float32)
2326
+
2327
+ # ---- 2) 鼓:按 style["drums"][tier] 的 kick 落点 / hat 步长 / clap ----
2328
+ hits: list = []
2329
+ drums = _Bus(n, sr)
2330
+ drum_pres = [bool(s["layers"].count("kick") + s["layers"].count("hat")) for s in secs]
2331
+ for si, sec in enumerate(secs):
2332
+ e = sec["energy"]
2333
+ tier = sec["tier"]
2334
+ cfg = pack["drums"][tier]
2335
+ has_kick = "kick" in sec["layers"]
2336
+ has_hat = "hat" in sec["layers"]
2337
+ if not has_kick and not has_hat:
2338
+ continue
2339
+ nb = len(sec["bars"])
2340
+ bm = sec["breath"]
2341
+ kick_db = -12.0 + 7.5 * e + (1.0 if tier == "climax" else 0.0)
2342
+ hat_db = -20.0 + 8.0 * e - float(m["drum"]) * 4.0
2343
+ for j, bi in enumerate(sec["bars"]):
2344
+ t0 = bi * bar
2345
+ phrase_end = (j % ph == ph - 1) and nb >= ph
2346
+ fill_bar = (j == nb - 1) and (si < len(secs) - 1)
2347
+ breath = phrase_end and not fill_bar and bm > 0
2348
+ if has_kick:
2349
+ for kb in cfg.get("kick", ()):
2350
+ if (fill_bar and kb >= 3.0) or (breath and kb >= 3.0):
2351
+ continue
2352
+ accent = 1.5 if abs(kb) < 1e-9 else 0.0
2353
+ var = bank.kicks[(bi * 4 + int(round(kb))) % len(bank.kicks)]
2354
+ drums.add(var, t0 + kb * beat, kick_db + accent)
2355
+ hits.append((t0 + kb * beat, 0.28 + 0.22 * e))
2356
+ if has_hat and cfg.get("hat"):
2357
+ step = float(cfg["hat"])
2358
+ total = int(round(BEATS_PER_BAR / step))
2359
+ for k in range(total):
2360
+ base_pos = k * step
2361
+ pos = base_pos + _styled_swing_shift(swing, step, k)
2362
+ if fill_bar and pos >= 3.0 - 1e-9:
2363
+ continue
2364
+ if breath and pos >= (3.0 if bm >= 2 else 3.5) - 1e-9:
2365
+ continue
2366
+ on_beat = abs(base_pos - round(base_pos)) < 1e-9
2367
+ ghost = 0.0 if on_beat else -2.5
2368
+ pan = 0.3 if k % 2 == 0 else -0.3
2369
+ var = bank.hats[(bi * 7 + k) % len(bank.hats)]
2370
+ drums.add(var, t0 + pos * beat, hat_db + ghost, pan)
2371
+ if has_hat and e >= 0.50 and cfg.get("clap"):
2372
+ for cb in cfg.get("clap", ()):
2373
+ if phrase_end and cb >= 3.0:
2374
+ 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)
2377
+ if tier == "climax" and has_hat:
2378
+ for pos in (0.5, 1.5, 2.5, 3.5):
2379
+ if phrase_end and pos >= 3.5:
2380
+ continue
2381
+ drums.add(bank.hats_open[(bi + int(pos)) % len(bank.hats_open)],
2382
+ t0 + pos * beat, -21.0 + 3.0 * e, -0.2)
2383
+ if si > 0 and tier in ("drive", "climax") and has_kick:
2384
+ drums.add(bank.crashes[si % len(bank.crashes)], sec["start"],
2385
+ -10.0 + 2.0 * (1.0 - e))
2386
+ drums.add(bank.kick_big, sec["start"], -9.0)
2387
+ hits.append((sec["start"], 0.5))
2388
+ if tier == "climax":
2389
+ for j, bi in enumerate(sec["bars"]):
2390
+ if j > 0 and j % 8 == 0:
2391
+ drums.add(bank.crashes[(si + j) % len(bank.crashes)], bi * bar, -17.0)
2392
+ drums.buf *= _styled_gate_env(n, secs, drum_pres, bar, sr)[:, None]
2393
+ master += drums.buf
2394
+ drums.clear()
2395
+
2396
+ # ---- 3) 贝斯:型(whole/half/off8/16/dub)+ 风格音色 + 侧链避让 ----
2397
+ bass = _Bus(n, sr)
2398
+ bass_voice = _check_voice("bass", pack["voices"]["bass"])
2399
+ for si, sec in enumerate(secs):
2400
+ if not present["bass"][si]:
2401
+ continue
2402
+ e = sec["energy"]
2403
+ cfg = pack["drums"][sec["tier"]]
2404
+ pat = cfg.get("bass", "none")
2405
+ if pat == "none":
2406
+ continue
2407
+ bdb = -14.0 + 5.0 * e
2408
+ bright = _clip01(0.2 + 0.65 * e + 0.5 * mood_bright)
2409
+ nb = len(sec["bars"])
2410
+ bm = sec["breath"]
2411
+ k = 0
2412
+ for j, bi in enumerate(sec["bars"]):
2413
+ t0 = bi * bar
2414
+ chord = kval["chords"][sec["chords"][j]]
2415
+ root = chord["root"]
2416
+ phrase_end = (j % ph == ph - 1) and nb >= ph
2417
+ breath = phrase_end and bm >= 2
2418
+ if pat == "whole":
2419
+ bass.add(_bass_texture(bank, bass_voice, root, bar * 0.92, bright), t0, bdb)
2420
+ elif pat == "half":
2421
+ for pos, dd in ((0.0, 1.8), (2.0, 1.8)):
2422
+ if breath and pos >= 3.0:
2423
+ continue
2424
+ bass.add(_bass_texture(bank, bass_voice, root, dd * beat, bright),
2425
+ t0 + pos * beat, bdb)
2426
+ elif pat == "off8":
2427
+ for pos in (0.5, 1.5, 2.5, 3.5):
2428
+ if breath and pos >= 3.0:
2429
+ continue
2430
+ nt = _up(root, 1) if (k % 7 == 3) else root
2431
+ bass.add(_bass_texture(bank, bass_voice, nt, 0.42 * beat, bright),
2432
+ t0 + pos * beat, bdb)
2433
+ k += 1
2434
+ elif pat == "dub":
2435
+ bass.add(_bass_texture(bank, bass_voice, root, bar * 0.55, bright), t0, bdb - 1.0)
2436
+ if not breath:
2437
+ bass.add(_bass_texture(bank, bass_voice, _up(root, 1), bar * 0.30, bright),
2438
+ t0 + 2.5 * beat, bdb - 3.0)
2439
+ else: # "16":十六分切分
2440
+ for b in range(BEATS_PER_BAR):
2441
+ for off in (0.25, 0.5, 0.75):
2442
+ pos = b + off
2443
+ if breath and pos >= 3.0:
2444
+ continue
2445
+ nt = _up(root, 1) if (k % 11 == 7) else root
2446
+ bass.add(_bass_texture(bank, bass_voice, nt, 0.2 * beat, bright),
2447
+ t0 + pos * beat, bdb)
2448
+ k += 1
2449
+ bass.buf *= sc._duck_env(n, hits, depth=float(pack["duck"]["bass"]), tau=0.15)[:, None]
2450
+ bass.buf *= _styled_gate_env(n, secs, present["bass"], bar, sr)[:, None]
2451
+ master += bass.buf
2452
+ bass.clear()
2453
+
2454
+ # ---- 4) 铺底 pad / strings(受底鼓轻压;段落首小节慢起音;shine 叠高八度) ----
2455
+ pads = _Bus(n, sr)
2456
+ pad_voice = _check_voice("pad", pack["voices"]["pad"])
2457
+ attack_first = {"calm": 1.2, "body": 0.5, "drive": 0.24, "climax": 0.14}
2458
+ attack_next = {"calm": 0.42, "body": 0.14, "drive": 0.07, "climax": 0.05}
2459
+ for si, sec in enumerate(secs):
2460
+ if not present["pad"][si]:
2461
+ continue
2462
+ e = sec["energy"]
2463
+ tier = sec["tier"]
2464
+ pdb = -20.0 + 7.0 * e + 0.8 * mood_bright
2465
+ bright = _clip01(0.15 + 0.75 * e + mood_bright)
2466
+ for j, bi in enumerate(sec["bars"]):
2467
+ chord = kval["chords"][sec["chords"][j]]
2468
+ attack = attack_first[tier] if j == 0 else attack_next[tier]
2469
+ if pad_voice == "strings":
2470
+ tex = bank.strings(chord, bright, attack)
2471
+ else:
2472
+ tex = bank.pad(chord, bright, attack)
2473
+ pads.add(tex, bi * bar, pdb)
2474
+ if present["shine"][si]:
2475
+ pads.add(bank.pad(chord, _clip01(bright - 0.1), attack, octave=1),
2476
+ bi * bar, pdb - 6.0)
2477
+ pads.buf *= sc._duck_env(n, hits, depth=float(pack["duck"]["pad"]), tau=0.18)[:, None]
2478
+ pads.buf *= _styled_gate_env(n, secs, present["pad"], bar, sr)[:, None]
2479
+ master += pads.buf
2480
+ pads.clear()
2481
+
2482
+ # ---- 5) 琶音:步长来自 drums[arp],音色来自 voices[arp],带摇摆 ----
2483
+ keys = _Bus(n, sr)
2484
+ arp_voice = _check_voice("arp", pack["voices"]["arp"])
2485
+ for si, sec in enumerate(secs):
2486
+ if not present["arp"][si]:
2487
+ continue
2488
+ cfg = pack["drums"][sec["tier"]]
2489
+ step = cfg.get("arp")
2490
+ if not step:
2491
+ continue
2492
+ step = float(step)
2493
+ e = sec["energy"]
2494
+ adb = -25.0 + 8.0 * e + 0.5 * mood_bright
2495
+ bright = _clip01(0.25 + 0.6 * e + mood_bright)
2496
+ nb = len(sec["bars"])
2497
+ bm = sec["breath"]
2498
+ for j, bi in enumerate(sec["bars"]):
2499
+ t0 = bi * bar
2500
+ chord = kval["chords"][sec["chords"][j]]
2501
+ seq = [_up(nt, 2) for nt in chord["notes"]]
2502
+ seq = seq + seq[1:3][::-1]
2503
+ total = int(round(BEATS_PER_BAR / step))
2504
+ phrase_end = (j % ph == ph - 1) and nb >= ph
2505
+ breath = phrase_end and bm > 0
2506
+ for k in range(total):
2507
+ pos = k * step + _styled_swing_shift(swing, step, k)
2508
+ if breath and pos >= 3.0 - 1e-9:
2509
+ continue
2510
+ nt = seq[k % len(seq)]
2511
+ pan = 0.35 * math.sin(0.7 * k + 0.3 * j)
2512
+ keys.add(_arp_texture(bank, arp_voice, nt, bright, 0.3),
2513
+ t0 + pos * beat, adb, pan)
2514
+ keys.buf *= _styled_gate_env(n, secs, present["arp"], bar, sr)[:, None]
2515
+ master += keys.buf
2516
+ sc._add_delayed(master, keys.buf, float(space["delay_beats"]) * beat,
2517
+ float(space["delay_db"]), taps=int(space["taps"]), sr=sr)
2518
+ keys.clear()
2519
+
2520
+ # ---- 6) 主奏:motif + lead_cycle 每乐句轮换;片尾用 end 动机 ----
2521
+ lead = _Bus(n, sr)
2522
+ cycle = tuple(pack["lead_cycle"])
2523
+ phrase_i = 0
2524
+ lead_pres = list(present["melody"])
2525
+ if last_si >= 0 and secs[last_si]["energy"] >= 0.22:
2526
+ lead_pres[last_si] = True
2527
+ for si, sec in enumerate(secs):
2528
+ e = sec["energy"]
2529
+ nb = len(sec["bars"])
2530
+ if nb < 2:
2531
+ continue
2532
+ bright = _clip01(0.4 + 0.5 * e + mood_bright)
2533
+ if present["melody"][si]:
2534
+ mel_db = -17.0 + 4.0 * e + float(m["lead_db"])
2535
+ for pi, first in enumerate(range(0, nb, ph)):
2536
+ is_last_phrase = (si == last_si and pi == (nb - 1) // ph)
2537
+ if is_last_phrase:
2538
+ motif = pack["motif"]["end"]
2539
+ elif phrase_i % 2 == 0:
2540
+ motif = pack["motif"]["q"]
2541
+ else:
2542
+ motif = pack["motif"]["a"]
2543
+ voice = cycle[phrase_i % len(cycle)]
2544
+ t0 = sec["bars"][first] * bar
2545
+ _emit_styled_motif(lead, bank, motif, kval, voice, t0, beat, mel_db, bright)
2546
+ phrase_i += 1
2547
+ elif si == last_si and e >= 0.22:
2548
+ # 片尾安静段:用第一个主奏音色把结局动机答完
2549
+ 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)
2552
+ lead.buf *= _styled_gate_env(n, secs, lead_pres, bar, sr)[:, None]
2553
+ master += lead.buf
2554
+ sc._add_delayed(master, lead.buf, float(space["delay_beats"]) * beat * 0.85,
2555
+ float(space["delay_db"]) - 3.0,
2556
+ taps=max(1, int(space["taps"]) - 1), sr=sr)
2557
+ lead.clear()
2558
+
2559
+ # ---- 7) shine:高音闪光(只在最亮的段出现) ----
2560
+ shine = _Bus(n, sr)
2561
+ for si, sec in enumerate(secs):
2562
+ if not present["shine"][si]:
2563
+ continue
2564
+ e = sec["energy"]
2565
+ sdb = -30.0 + 6.0 * e + 0.6 * mood_bright
2566
+ for j, bi in enumerate(sec["bars"]):
2567
+ chord = kval["chords"][sec["chords"][j]]
2568
+ hi = [_up(nt, 2) for nt in chord["notes"][1:3]]
2569
+ for q, pos in enumerate((0.5, 2.75)):
2570
+ shine.add(bank.glock(hi[(bi + q) % len(hi)], 0.9), bi * bar + pos * beat,
2571
+ sdb, 0.4 if q == 0 else -0.4)
2572
+ shine.buf *= _styled_gate_env(n, secs, present["shine"], bar, sr)[:, None]
2573
+ master += shine.buf
2574
+ shine.clear()
2575
+
2576
+ # ---- 8) 过门与效果(每个内部交界一小节) ----
2577
+ fx = _Bus(n, sr)
2578
+ for ti, tr in enumerate(plan["transitions"]):
2579
+ fill_start = tr["t"] - bar
2580
+ nxt_e = secs[ti + 1]["energy"]
2581
+ prev_e = secs[ti]["energy"]
2582
+ sixteenth = 0.25 * beat
2583
+ for k in range(int(round(bar / sixteenth))):
2584
+ p = k / max(1.0, (bar / sixteenth) - 1.0)
2585
+ tom = bank.toms[min(3, int(p * 4))]
2586
+ fx.add(tom, fill_start + k * sixteenth,
2587
+ -18.0 + 6.0 * p + (1.5 if k % 2 == 0 else 0.0), -0.35 + 0.7 * p)
2588
+ if p >= 0.6:
2589
+ fx.add(bank.snares[k % len(bank.snares)], fill_start + k * sixteenth,
2590
+ -20.0 + 5.0 * p, 0.1)
2591
+ fx.add(bank.swell(bar, ti, "styled"), fill_start, -15.0 + 2.0 * nxt_e)
2592
+ if nxt_e > prev_e + 0.10:
2593
+ fx.add(bank.riser(bar * 0.92, ti, "styled"), fill_start + bar * 0.08,
2594
+ -14.0 + 2.0 * nxt_e)
2595
+ if nxt_e >= 0.72:
2596
+ fx.add(bank.sub_boom(2.2, 55.0, 30.0), tr["t"], -9.0 - 2.0 * (1.0 - nxt_e))
2597
+ fx.add(bank.crashes[ti % len(bank.crashes)], tr["t"], -11.0)
2598
+ if nxt_e < prev_e - 0.15:
2599
+ fx.add(bank.crashes[(ti + 1) % len(bank.crashes)], tr["t"], -20.0)
2600
+ master += fx.buf
2601
+ fx.clear()
2602
+
2603
+ # ---- 9) 自动电平(段落间能量 -> dB 曲线)/ 吸气 / 去直流 / 淡入淡出 / 峰值 ----
2604
+ levels = sc._level_match(master, plan)
2605
+ sc._apply_sucks(master, plan["sucks"], sr)
2606
+ sc._de_dc(master)
2607
+ sc._apply_fades(master)
2608
+ peak = float(np.max(np.abs(master))) if n else 0.0
2609
+ if peak > 1e-9:
2610
+ master *= np.float32(_db_amp(TARGET_PEAK_DB) / peak)
2611
+ peak_db = float(20.0 * math.log10(max(float(np.max(np.abs(master))), 1e-12)))
2612
+ lufs = _approx_lufs(master, sr)
2613
+ dc = float(np.max(np.abs(master.mean(axis=0)))) if n else 0.0
2614
+ centroid = spectral_centroid(master, sr)
2615
+
2616
+ # ---- 10) 报告(version 2;字段与 legacy report 兼容,CLI 复用同一个格式化函数) ----
2617
+ rep = {
2618
+ "version": 2,
2619
+ "generator": "scorekit",
2620
+ "style": style,
2621
+ "style_label": str(pack.get("label", style)),
2622
+ "mood": mood,
2623
+ "bpm": round(float(bpm), 4),
2624
+ "bpm_hint": list(pack["bpm_hint"]),
2625
+ "key": key,
2626
+ "key_name": kval["name"],
2627
+ "key_in_style_pool": key in tuple(pack["keys"]),
2628
+ "sr": sr,
2629
+ "duration": round(dur, 3),
2630
+ "seed": int(sc.seed),
2631
+ "seed_effective": int(seed_eff),
2632
+ "fingerprint": "" if fingerprint is None else str(fingerprint),
2633
+ "bar_s": round(bar, 4),
2634
+ "beat_s": round(beat, 4),
2635
+ "phrase_bars": ph,
2636
+ "since": "v0.5",
2637
+ "sections": [
2638
+ {"name": s["name"], "start": round(s["start"], 3), "end": round(s["end"], 3),
2639
+ "energy": round(s["energy"], 3), "tier": s["tier"], "bars": len(s["bars"]),
2640
+ "target_db": round(s["target_db"], 2), "target_lufs": round(s["target_lufs"], 1),
2641
+ "layers": list(s["layers"]), "chords": list(s["chords"])}
2642
+ for s in secs
2643
+ ],
2644
+ "transitions": [dict(t) for t in plan["transitions"]],
2645
+ "sucks": [dict(d) for d in plan["sucks"]],
2646
+ "beats": list(plan["beats"]),
2647
+ "peak_dbfs": round(peak_db, 2),
2648
+ "lufs_approx": round(lufs, 1),
2649
+ "dc_max": round(dc, 9),
2650
+ "spectral_centroid_hz": round(centroid, 1),
2651
+ "render_s": round(time.perf_counter() - t_start, 3),
2652
+ "measured_sections": [
2653
+ {"name": s["name"], "start": round(s["start"], 3), "end": round(s["end"], 3),
2654
+ "energy": round(s["energy"], 3), "gain_db": round(lv["gain"], 2),
2655
+ "measured_db": round(lv["after"], 2)}
2656
+ for s, lv in zip(secs, levels)
2657
+ ],
2658
+ }
2659
+ return master, rep
2660
+
2661
+
2662
+ def spectral_centroid(x, sr: int = SR) -> float:
2663
+ """频谱质心(Hz):粗略量"亮度",用于验证不同风格不是同一首曲子。
2664
+
2665
+ 单声道下混 -> 去均值 -> 分块 rFFT -> 幅度加权平均频率。确定性、无随机、无三方依赖。
2666
+ """
2667
+ a = np.asarray(x)
2668
+ if a.ndim == 2:
2669
+ mono = a.astype(np.float64).mean(axis=1)
2670
+ else:
2671
+ mono = a.astype(np.float64).ravel()
2672
+ if len(mono) < 1024:
2673
+ return 0.0
2674
+ blk = 16384 if len(mono) >= 16384 else 1024
2675
+ hop = max(1, blk // 2)
2676
+ win = np.hanning(blk)
2677
+ freqs = np.fft.rfftfreq(blk, 1.0 / float(sr))
2678
+ acc_m = 0.0
2679
+ acc_p = 0.0
2680
+ for s in range(0, len(mono) - blk + 1, hop):
2681
+ seg = mono[s:s + blk]
2682
+ seg = seg - float(seg.mean())
2683
+ mag = np.abs(np.fft.rfft(seg * win))
2684
+ acc_m += float(np.dot(freqs, mag))
2685
+ acc_p += float(mag.sum())
2686
+ if acc_p <= 1e-12:
2687
+ return 0.0
2688
+ return float(acc_m / acc_p)
2689
+
2690
+
2691
+ def _music_style_lint() -> list:
2692
+ """风格表体检:返回问题列表(空 = 过)。selftest 第 17 条调用。"""
2693
+ bad: list = []
2694
+ need = ("bpm_hint", "keys", "progressions", "motif", "layers", "voices",
2695
+ "lead_cycle", "drums", "space", "phrase_bars", "duck",
2696
+ "level_span_db", "swing")
2697
+ for sid, pack in MUSIC_STYLES.items():
2698
+ missing = [fld for fld in need if fld not in pack]
2699
+ if missing:
2700
+ bad.append(f"{sid}: 缺字段 {'/'.join(missing)}")
2701
+ continue
2702
+ if not pack["bpm_hint"]:
2703
+ bad.append(f"{sid}: bpm_hint 为空")
2704
+ for kk in pack["keys"]:
2705
+ if kk not in KEYS_ALL:
2706
+ bad.append(f"{sid}: 调 {kk} 不在 KEYS_ALL")
2707
+ for tier in ("calm", "body", "drive"):
2708
+ if tier not in pack["progressions"]:
2709
+ bad.append(f"{sid}: progressions 缺 {tier}")
2710
+ continue
2711
+ for deg in pack["progressions"][tier]:
2712
+ if deg not in _DEGREE_BY_NAME:
2713
+ bad.append(f"{sid}.{tier}: 级数 {deg} 不存在")
2714
+ for mt in ("q", "a", "end"):
2715
+ if mt not in pack["motif"]:
2716
+ bad.append(f"{sid}: motif 缺 {mt}")
2717
+ continue
2718
+ for idx, ev in enumerate(pack["motif"][mt]):
2719
+ if len(ev) != 3:
2720
+ bad.append(f"{sid}.{mt}[{idx}]: 必须是 (拍, 级数, 时值拍)")
2721
+ continue
2722
+ if str(ev[1]) not in _DEGREE_BY_NAME:
2723
+ bad.append(f"{sid}.{mt}[{idx}]: 级数 {ev[1]} 不存在")
2724
+ if float(ev[2]) <= 0.0:
2725
+ bad.append(f"{sid}.{mt}[{idx}]: 时值必须 > 0")
2726
+ for slot in ("pad", "bass", "arp", "lead"):
2727
+ if pack["voices"].get(slot) not in _STYLE_VOICES[slot]:
2728
+ bad.append(f"{sid}.voices.{slot}={pack['voices'].get(slot)!r} 未知")
2729
+ for voice in pack["lead_cycle"]:
2730
+ if voice not in _STYLE_VOICES["lead"]:
2731
+ bad.append(f"{sid}.lead_cycle: 音色 {voice!r} 未知")
2732
+ for tier in ("calm", "body", "drive", "climax"):
2733
+ cfg = pack["drums"].get(tier) or {}
2734
+ if not cfg:
2735
+ bad.append(f"{sid}.drums: 缺 {tier}")
2736
+ continue
2737
+ for kb in cfg.get("kick", ()):
2738
+ if not (0.0 <= float(kb) < BEATS_PER_BAR):
2739
+ bad.append(f"{sid}.drums.{tier}: kick {kb} 越界")
2740
+ for cb in cfg.get("clap", ()):
2741
+ if not (0.0 <= float(cb) < BEATS_PER_BAR):
2742
+ bad.append(f"{sid}.drums.{tier}: clap {cb} 越界")
2743
+ hs = cfg.get("hat", None)
2744
+ if hs is not None and not (0.20 <= float(hs) <= 2.0):
2745
+ bad.append(f"{sid}.drums.{tier}: hat {hs} 不合理")
2746
+ if cfg.get("bass", "none") not in ("none", "whole", "half", "off8", "16", "dub"):
2747
+ bad.append(f"{sid}.drums.{tier}: bass 型 {cfg.get('bass')!r} 未知")
2748
+ asp = cfg.get("arp", None)
2749
+ if asp is not None and not (0.2 <= float(asp) <= 2.0):
2750
+ bad.append(f"{sid}.drums.{tier}: arp {asp} 不合理")
2751
+ if int(pack["phrase_bars"]) not in (4, 8):
2752
+ bad.append(f"{sid}: phrase_bars 只能是 4/8")
2753
+ if not (float(pack["level_span_db"]) > 0.0):
2754
+ bad.append(f"{sid}: level_span_db 必须 > 0")
2755
+ if not (0.0 <= float(pack["swing"]) <= 0.2):
2756
+ bad.append(f"{sid}: swing 必须 0~0.2")
2757
+ for fld in ("bass", "pad"):
2758
+ d = float(pack["duck"].get(fld, 0.0))
2759
+ if not (0.0 <= d < 0.9):
2760
+ bad.append(f"{sid}: duck.{fld}={d} 不合理")
2761
+ if float(pack["layers"].get("pad", 1.0)) > 1e-9:
2762
+ bad.append(f"{sid}: layers.pad 必须是 0.0")
2763
+ for nm in LAYER_ORDER:
2764
+ if nm not in pack["layers"]:
2765
+ bad.append(f"{sid}: layers 缺 {nm}")
2766
+ sp = pack["space"]
2767
+ if not (0.0 < float(sp.get("delay_beats", 0.0)) <= 4.0):
2768
+ bad.append(f"{sid}: space.delay_beats 必须 0~4 拍")
2769
+ if int(sp.get("taps", 0)) < 1:
2770
+ bad.append(f"{sid}: space.taps 必须 >= 1")
2771
+ return bad
2772
+
2773
+
2774
+ def _stylekit_crosscheck() -> list:
2775
+ """同目录有 stylekit.py 时交叉核对风格 id 与调池;没有就静默跳过(不算失败)。"""
2776
+ try:
2777
+ import stylekit as ST # noqa: F401
2778
+ except Exception: # noqa: BLE001
2779
+ return []
2780
+ bad: list = []
2781
+ for sid in MUSIC_STYLE_IDS:
2782
+ if sid == LEGACY_MUSIC_STYLE:
2783
+ continue
2784
+ if sid not in ST.STYLES:
2785
+ bad.append(f"stylekit 缺风格 {sid}")
2786
+ continue
2787
+ want = tuple(ST.STYLES[sid]["music"]["keys"])
2788
+ got = tuple(MUSIC_STYLES[sid]["keys"])
2789
+ if want != got:
2790
+ bad.append(f"{sid}: 调池不一致 {got} != {want}")
2791
+ return bad
2792
+
2793
+
2794
+ def _stylekit_available() -> bool:
2795
+ """同目录是否有可导入的 stylekit(没有就跳过交叉核对,不判失败)。"""
2796
+ try:
2797
+ import stylekit # noqa: F401
2798
+ return True
2799
+ except Exception: # noqa: BLE001
2800
+ return False
2801
+
1361
2802
  # ============================================================================
1362
2803
  # 模块级便捷函数
1363
2804
  # ============================================================================
@@ -1431,12 +2872,23 @@ def _merge_sections(anchors, sections, duration: float) -> list:
1431
2872
 
1432
2873
 
1433
2874
  def render(duration, bpm: float = 120.0, key: str = "Dm", anchors=None, sections=None,
1434
- seed: int = 7) -> tuple:
2875
+ seed: int = 7, style=None, mood: str = "steady", fingerprint=None,
2876
+ music_plan=None, pace=None) -> tuple:
1435
2877
  """一次性接口:合成整片配乐,返回 (samples, report)。
1436
2878
 
1437
2879
  anchors:timeline.json 的 [{id,kind,start,end}](自动按 kind 映射能量分段);
1438
2880
  sections:显式段落(绝对时间或 at 引用),在 anchors 之后追加/覆盖空隙。
2881
+
2882
+ v0.5:style=None / "legacy" 走 v0.4 原路径(逐样本与旧版一致;style/mood/fingerprint/
2883
+ music_plan/pace 全部忽略);其余 style(MUSIC_STYLE_IDS 里的 8 个)走 render_styled()。
2884
+ music_plan / pace 的语义见 render_styled:music_plan 非 None 时覆盖 bpm/key/mood/seed。
1439
2885
  """
2886
+ sid = _normalize_style_id(style)
2887
+ if sid is not None:
2888
+ return render_styled(duration, style=sid, bpm=bpm, key=key, mood=mood,
2889
+ anchors=anchors, sections=sections, seed=seed,
2890
+ fingerprint=fingerprint, music_plan=music_plan, pace=pace)
2891
+ # ---- 以下与 v0.4 一字不差:legacy 逐字节兼容是硬契约,别在这段里改任何东西 ----
1440
2892
  sc = Score(duration, bpm=bpm, key=key, seed=seed)
1441
2893
  for s in _merge_sections(anchors, sections, float(duration)):
1442
2894
  sc.section(s["name"], s["start"], s["end"], s["energy"])
@@ -1756,6 +3208,125 @@ def selftest() -> int:
1756
3208
  check("16. 集成形态:anchors+sections 同起点覆盖 / 无 end 自动补到片尾", ok_it,
1757
3209
  " | ".join(f"{b['name']}:{b['start']}-{b['end']}:e{b['energy']}" for b in merged))
1758
3210
 
3211
+ # 17) v0.5 配乐风格表完整性(9 风格;级数存在;调池在 KEYS_ALL;与 stylekit 交叉核对)
3212
+ lint_bad = _music_style_lint()
3213
+ cross_bad = _stylekit_crosscheck()
3214
+ check("17. 配乐风格表完整(9 风格 / 级数存在 / 调池在 KEYS_ALL)",
3215
+ not (lint_bad or cross_bad),
3216
+ "; ".join((lint_bad + cross_bad)[:3]) if (lint_bad or cross_bad)
3217
+ else f"{len(MUSIC_STYLES)} 风格,{len(_DEGREE_BY_NAME)} 个级数"
3218
+ + (";stylekit 调池一致" if _stylekit_available() else ";无 stylekit 可交叉核对"))
3219
+
3220
+ # 18) v0.5 大调 & 七和弦表(KEYS 仍是 v0.4 的 4 个小调)
3221
+ bad_major = []
3222
+ for kname, kv in KEYS_MAJOR.items():
3223
+ for deg in ("I", "ii", "iii", "IV", "V", "vi", "vii", "sus4",
3224
+ "Imaj7", "ii7", "iii7", "IVmaj7", "V7", "vi7", "i7"):
3225
+ if deg not in kv["chords"]:
3226
+ bad_major.append(f"{kname}.{deg} 缺失")
3227
+ for chord in kv["chords"].values():
3228
+ for nt in chord["notes"] + [chord["root"]]:
3229
+ if not _NOTE_RE.match(nt) or S.hz(nt) <= 0:
3230
+ bad_major.append(f"{kname}: 非法音名 {nt!r}")
3231
+ ok_major = ((not bad_major) and set(KEYS) == {"Dm", "Am", "Cm", "Em"}
3232
+ and len(KEYS_ALL) == len(KEYS) + len(KEYS_MAJOR) == 10)
3233
+ check("18. 大调 6 个 & 七和弦级数(KEYS 仍是 4 个小调)", ok_major,
3234
+ "; ".join(bad_major[:3]) if bad_major
3235
+ else f"{len(KEYS_ALL)} 个调,{len(KEYS_ALL['C']['chords'])} 个级数/调")
3236
+
3237
+ # 19) v0.5 逐风格 6 s 渲染:时长精确 / 有限值 / 峰值 / 同 seed 一致 / 风格间 PCM 不全等
3238
+ t_styled = time.perf_counter()
3239
+ style_ids = [sid for sid in MUSIC_STYLE_IDS if sid != LEGACY_MUSIC_STYLE]
3240
+ pcm = {}
3241
+ peaks = {}
3242
+ bad_style = []
3243
+ for sid in style_ids:
3244
+ pk = MUSIC_STYLES[sid]
3245
+ hint = pk["bpm_hint"]
3246
+ bpm_s = float(hint[len(hint) // 2])
3247
+ key_s = str(pk["keys"][0])
3248
+ try:
3249
+ x1, r1 = render_styled(6.0, style=sid, bpm=bpm_s, key=key_s, seed=7)
3250
+ x2, _r2 = render_styled(6.0, style=sid, bpm=bpm_s, key=key_s, seed=7)
3251
+ except Exception as exc: # 自测里也要报原因,不吞异常
3252
+ bad_style.append(f"{sid}: {type(exc).__name__}: {exc}")
3253
+ continue
3254
+ want_n = int(math.ceil(6.0 * SR - 1e-9))
3255
+ if x1.shape != (want_n, 2) or not bool(np.all(np.isfinite(x1))):
3256
+ bad_style.append(f"{sid}: shape={x1.shape} 或含非有限值")
3257
+ if not np.array_equal(x1, x2):
3258
+ bad_style.append(f"{sid}: 同 seed 两次不一致")
3259
+ peaks[sid] = 20.0 * math.log10(max(float(np.max(np.abs(x1))), 1e-12))
3260
+ if not (-3.5 <= peaks[sid] <= -1.0):
3261
+ bad_style.append(f"{sid}: 峰值 {peaks[sid]:.2f} dBFS 越界")
3262
+ if r1.get("version") != 2 or r1.get("style") != sid or "sections" not in r1:
3263
+ bad_style.append(f"{sid}: report 结构不对")
3264
+ pcm[sid] = x1
3265
+ same_pairs = []
3266
+ ids_ok = list(pcm)
3267
+ for i in range(len(ids_ok)):
3268
+ for j in range(i + 1, len(ids_ok)):
3269
+ if np.array_equal(pcm[ids_ok[i]], pcm[ids_ok[j]]):
3270
+ same_pairs.append(f"{ids_ok[i]}=={ids_ok[j]}")
3271
+ cen = {sid: spectral_centroid(x, SR) for sid, x in pcm.items()}
3272
+ spread = (max(cen.values()) - min(cen.values())) if cen else 0.0
3273
+
3274
+ # 19b) stylekit 的 music_plan 优先级:plan 里的 bpm/key/mood/seed 必须盖过显式参数
3275
+ plan_used = {"bpm": 123.0, "key": "A", "mood": "uplifting", "seed": 42,
3276
+ "seed_effective": 424242}
3277
+ try:
3278
+ _xp, rp = render_styled(4.0, style="vivid", bpm=90.0, key="D", mood="calm", seed=1,
3279
+ music_plan={"music": plan_used})
3280
+ ok_plan = (rp.get("bpm") == 123.0 and rp.get("key") == "A"
3281
+ and rp.get("mood") == "uplifting" and rp.get("seed_effective") == 424242)
3282
+ plan_detail = (f"plan -> {rp.get('bpm')}/{rp.get('key')}/{rp.get('mood')}/"
3283
+ f"seed {rp.get('seed_effective')}")
3284
+ except Exception as exc: # 自测里也要报原因
3285
+ ok_plan = False
3286
+ plan_detail = f"{type(exc).__name__}: {exc}"
3287
+
3288
+ # 19c) pace:min_dur+hold 只合并过碎段落,不改片长 / 时间轴
3289
+ stale_blocks = [
3290
+ {"name": "碎1", "start": 0.0, "end": 2.0, "energy": 0.3},
3291
+ {"name": "碎2", "start": 2.0, "end": 4.0, "energy": 0.6},
3292
+ {"name": "长", "start": 4.0, "end": 12.0, "energy": 0.5},
3293
+ ]
3294
+ paced = _apply_pace(stale_blocks, {"min_dur": 3.2, "hold": 0.2})
3295
+ ok_pace = (len(paced) == 2 and abs(paced[0]["end"] - 4.0) < 1e-9
3296
+ and abs(paced[0]["energy"] - 0.6) < 1e-9
3297
+ and abs(paced[-1]["start"] - 4.0) < 1e-9
3298
+ and abs(paced[-1]["end"] - 12.0) < 1e-9)
3299
+ try:
3300
+ xpace, rpace = render_styled(
3301
+ 6.0, style="vivid", bpm=126, key="D", seed=7,
3302
+ anchors=[{"id": "p%d" % i, "kind": "body", "start": i * 0.75,
3303
+ "end": (i + 1) * 0.75} for i in range(8)],
3304
+ pace={"min_dur": 3.0, "hold": 0.30})
3305
+ ok_pace = ok_pace and xpace.shape == (int(math.ceil(6.0 * SR - 1e-9)), 2)
3306
+ pace_detail = f"索引 {len(paced)} 段;6 s 片仍 {xpace.shape[0]} 采样,{len(rpace['sections'])} 段"
3307
+ except Exception as exc:
3308
+ ok_pace = False
3309
+ pace_detail = f"{type(exc).__name__}: {exc}"
3310
+
3311
+ ok_styled = ((not bad_style) and (not same_pairs) and len(pcm) == len(style_ids)
3312
+ and spread >= 100.0 and ok_plan and ok_pace)
3313
+ check("19. 8 风格各渲 6 s:时长/有限值/峰值/确定性/两两 PCM 不全等", ok_styled,
3314
+ ("; ".join((bad_style + same_pairs)[:3]) if (bad_style or same_pairs)
3315
+ else f"{len(pcm)} 风格,{time.perf_counter() - t_styled:.1f} s;"
3316
+ f"频谱质心 {min(cen.values()):.0f}~{max(cen.values()):.0f} Hz(跨度 {spread:.0f});"
3317
+ f"{plan_detail};{pace_detail}"))
3318
+
3319
+ # 20) v0.5 legacy 兼容:style=None / "legacy" / 任意 mood 都走 v0.4 原路径,逐样本一致
3320
+ xn, rn = render(6.0, bpm=120, key="Dm", seed=7)
3321
+ xl, rl = render(6.0, bpm=120, key="Dm", seed=7, style="legacy")
3322
+ xm, _rm = render(6.0, bpm=120, key="Dm", seed=7, style=None, mood="driving",
3323
+ fingerprint="whatever")
3324
+ rn_c = {k: v for k, v in rn.items() if k != "render_s"}
3325
+ rl_c = {k: v for k, v in rl.items() if k != "render_s"}
3326
+ ok_legacy = (np.array_equal(xn, xl) and np.array_equal(xn, xm) and rn_c == rl_c
3327
+ and rn.get("version") == 1)
3328
+ check("20. legacy 兼容:style=None / 'legacy' / 任意 mood+fingerprint 逐样本一致", ok_legacy,
3329
+ f"shape={xn.shape},version={rn.get('version')}")
1759
3330
  total = time.perf_counter() - t_all
1760
3331
  passed = sum(1 for _, ok, _ in rows if ok)
1761
3332
  failed = len(rows) - passed
@@ -1810,11 +3381,30 @@ def _parse_suck_list(items) -> list:
1810
3381
  return out
1811
3382
 
1812
3383
 
3384
+ def _format_style_list() -> str:
3385
+ """CLI --list-styles:列可用配乐风格(bpm_hint / 调池 / 主奏音色 / 乐句长度)。"""
3386
+ lines = ["可用配乐风格(--style;缺省 legacy = v0.4 逐样本兼容):"]
3387
+ for sid, pack in MUSIC_STYLES.items():
3388
+ lines.append(" %-10s %-8s bpm_hint %-16s 调池 %-20s 主奏 %-14s 乐句 %d 小节" % (
3389
+ sid, str(pack.get("label", "")),
3390
+ "/".join(str(b) for b in pack["bpm_hint"]),
3391
+ "/".join(str(k) for k in pack["keys"]),
3392
+ "/".join(str(v) for v in pack["lead_cycle"]),
3393
+ int(pack["phrase_bars"]),
3394
+ ))
3395
+ lines.append(" 情绪 --mood:" + "/".join(MOOD_IDS))
3396
+ return "\n".join(lines)
3397
+
3398
+
1813
3399
  def _format_report(rep: dict, wav_path: Path | None, report_path: Path | None) -> str:
1814
3400
  """CLI 中文摘要。"""
1815
3401
  lines = []
1816
3402
  lines.append(f"[scorekit] {rep['duration']:g} s / {rep['bpm']:g} BPM / {rep['key']}({rep['key_name']})"
1817
3403
  f" / 小节 {rep['bar_s']:g} s / seed {rep['seed']}")
3404
+ if rep.get("style"):
3405
+ lines.append(f" 配乐风格 {rep['style']}({rep.get('style_label', '')})"
3406
+ f" / 情绪 {rep.get('mood', 'steady')} / 有效 seed {rep.get('seed_effective', rep['seed'])}"
3407
+ f" / 频谱质心 {rep.get('spectral_centroid_hz', 0):.0f} Hz")
1818
3408
  lines.append(f" 段落 {len(rep['sections'])} 个,交界 {len(rep['transitions'])} 处,吸气 {len(rep['sucks'])} 处")
1819
3409
  for s in rep["sections"]:
1820
3410
  lines.append(f" {s['name']:<8} {s['start']:7.2f} – {s['end']:7.2f} s"
@@ -1839,9 +3429,16 @@ def main(argv=None) -> int:
1839
3429
  description="scorekit — 程序化配乐(numpy + sfxkit,无采样、确定性)",
1840
3430
  )
1841
3431
  ap.add_argument("--selftest", action="store_true", help="跑自测(不联网,逐条 PASS/FAIL)")
3432
+ ap.add_argument("--list-styles", action="store_true", help="列出可用配乐风格后退出")
1842
3433
  ap.add_argument("--duration", type=float, default=60.0, help="片长(秒),默认 60")
1843
- ap.add_argument("--bpm", type=float, default=120.0, help="BPM,默认 120")
1844
- ap.add_argument("--key", default="Dm", help=f"调性,可选 {'/'.join(KEYS)},默认 Dm")
3434
+ ap.add_argument("--style", default=None,
3435
+ help="配乐风格:" + "/".join(MUSIC_STYLE_IDS) + "(缺省 = legacy,与 v0.4 逐样本一致)")
3436
+ ap.add_argument("--mood", default="steady", help="情绪修饰:" + "/".join(MOOD_IDS))
3437
+ ap.add_argument("--fingerprint", default=None, help="内容指纹(只影响 v0.5 风格路径的 seed 派生)")
3438
+ ap.add_argument("--bpm", type=float, default=None,
3439
+ help="BPM(缺省:legacy 120;风格路径取该风格 bpm_hint 的中位)")
3440
+ ap.add_argument("--key", default=None,
3441
+ help="调性,可选 " + "/".join(KEYS_ALL) + "(缺省:legacy Dm;风格路径取调池首个)")
1845
3442
  ap.add_argument("--seed", type=int, default=7, help="随机种子,默认 7")
1846
3443
  ap.add_argument("--energy", type=float, default=0.55, help="无 anchors/sections 时的单段能量,默认 0.55")
1847
3444
  ap.add_argument("--anchors", default=None, help="timeline.json 或锚点列表文件([{id,kind,start,end}])")
@@ -1853,27 +3450,47 @@ def main(argv=None) -> int:
1853
3450
 
1854
3451
  if args.selftest:
1855
3452
  return selftest()
1856
-
1857
- _require(args.key in KEYS, f"未知调性 {args.key!r};可选:{'/'.join(KEYS)}")
3453
+ if args.list_styles:
3454
+ print(_format_style_list())
3455
+ return 0
3456
+
3457
+ sid = _normalize_style_id(args.style)
3458
+ if sid is None:
3459
+ bpm = float(args.bpm) if args.bpm is not None else 120.0
3460
+ key = str(args.key) if args.key else "Dm"
3461
+ else:
3462
+ hint = MUSIC_STYLES[sid]["bpm_hint"]
3463
+ bpm = float(args.bpm) if args.bpm is not None else float(hint[len(hint) // 2])
3464
+ key = str(args.key) if args.key else str(MUSIC_STYLES[sid]["keys"][0])
3465
+ _require(key in KEYS_ALL, f"未知调性 {key!r};可选:{'/'.join(KEYS_ALL)}")
1858
3466
  anchors = _parse_anchors_file(args.anchors) if args.anchors else None
1859
3467
  sections = _parse_sections_file(args.sections) if args.sections else None
1860
- if anchors or sections:
1861
- merged = _merge_sections(anchors, sections, float(args.duration))
1862
- else:
1863
- merged = [{"name": "全片", "start": 0.0, "end": float(args.duration), "energy": args.energy}]
1864
- sc = Score(args.duration, bpm=args.bpm, key=args.key, seed=args.seed)
1865
- for s in merged:
1866
- sc.section(s["name"], s["start"], s["end"], s["energy"])
1867
- if anchors:
1868
- sc._anchors = [{"id": a.get("id", ""), "kind": a.get("kind", ""),
1869
- "start": float(a["start"]), "end": float(a["end"])} for a in anchors]
1870
- for t, db, dur in _parse_suck_list(args.suck):
1871
- sc.suck(t, db, dur)
3468
+ sucks = _parse_suck_list(args.suck)
1872
3469
 
1873
3470
  t0 = time.perf_counter()
1874
- wav = sc.render()
3471
+ if sid is None:
3472
+ # legacy:与 v0.4 完全同一段代码(逐字节兼容是硬契约,不要并进 styled 路径)
3473
+ if anchors or sections:
3474
+ merged = _merge_sections(anchors, sections, float(args.duration))
3475
+ else:
3476
+ merged = [{"name": "全片", "start": 0.0, "end": float(args.duration),
3477
+ "energy": args.energy}]
3478
+ sc = Score(args.duration, bpm=bpm, key=key, seed=args.seed)
3479
+ for s in merged:
3480
+ sc.section(s["name"], s["start"], s["end"], s["energy"])
3481
+ if anchors:
3482
+ sc._anchors = [{"id": a.get("id", ""), "kind": a.get("kind", ""),
3483
+ "start": float(a["start"]), "end": float(a["end"])} for a in anchors]
3484
+ for t, db, dur in sucks:
3485
+ sc.suck(t, db, dur)
3486
+ wav = sc.render()
3487
+ rep = sc.report()
3488
+ else:
3489
+ wav, rep = render_styled(args.duration, style=sid, bpm=bpm, key=key, mood=args.mood,
3490
+ anchors=anchors, sections=sections, seed=args.seed,
3491
+ fingerprint=args.fingerprint,
3492
+ sucks=[(t, db, d) for t, db, d in sucks])
1875
3493
  dt = time.perf_counter() - t0
1876
- rep = sc.report()
1877
3494
  rep["render_s"] = round(dt, 3)
1878
3495
  wav_path = Path(args.out) if args.out else None
1879
3496
  report_path = Path(args.report) if args.report else None