@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.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 (55) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/README.md +287 -327
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +4 -15
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1316 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +1 -2
  11. package/skills/video-studio/assets/golden_frames.json +11 -8
  12. package/skills/video-studio/assets/scenes.py +67 -22
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/audio-variation.md +15 -11
  15. package/skills/video-studio/references/audio.md +36 -34
  16. package/skills/video-studio/references/change-impact.md +3 -3
  17. package/skills/video-studio/references/frame-qa.md +2 -2
  18. package/skills/video-studio/references/proposal.md +177 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/scene-grammar.md +28 -23
  21. package/skills/video-studio/references/sfx-design.md +1 -1
  22. package/skills/video-studio/references/style-packs.md +95 -257
  23. package/skills/video-studio/references/v08-quality.md +8 -8
  24. package/skills/video-studio/references/web-engine.md +1 -1
  25. package/skills/video-studio/references/word-timeline.md +2 -2
  26. package/skills/video-studio/scripts/designkit.py +2120 -0
  27. package/skills/video-studio/scripts/filmcheck.py +7 -33
  28. package/skills/video-studio/scripts/filmkit.py +153 -78
  29. package/skills/video-studio/scripts/grammar.py +67 -7
  30. package/skills/video-studio/scripts/musicgen.py +2043 -0
  31. package/skills/video-studio/scripts/preflight.py +10 -3
  32. package/skills/video-studio/scripts/previewkit.py +849 -0
  33. package/skills/video-studio/scripts/propose.py +825 -0
  34. package/skills/video-studio/scripts/qa_frames.py +45 -10
  35. package/skills/video-studio/scripts/qavmaf.py +77 -11
  36. package/skills/video-studio/scripts/render.py +472 -154
  37. package/skills/video-studio/scripts/scorekit.py +50 -1748
  38. package/skills/video-studio/scripts/selfcheck.py +658 -131
  39. package/skills/video-studio/scripts/stagewatch.py +21 -5
  40. package/skills/video-studio/scripts/stylekit.py +265 -741
  41. package/skills/video-studio/scripts/voicekit.py +502 -380
  42. package/skills/video-studio/scripts/webengine.py +15 -14
  43. package/tools/audio_similarity.py +57 -30
  44. package/tools/ci_check.py +7 -1
  45. package/tools/estimate.py +14 -8
  46. package/tools/golden_frames.py +56 -11
  47. package/tools/silhouette_qa.py +521 -168
  48. package/docs/demo-pil-sheet.png +0 -0
  49. package/docs/styles/demo-results.json +0 -76
  50. package/docs/styles/sheet-8-styles.png +0 -0
  51. package/docs/styles/sheet-ai.png +0 -0
  52. package/docs/styles/sheet-before-after.png +0 -0
  53. package/docs/styles/sheet-craft.png +0 -0
  54. package/docs/styles/sheet-night.png +0 -0
  55. package/skills/video-studio/assets/score_demo.json +0 -307
@@ -0,0 +1,2043 @@
1
+ # -*- coding: utf-8 -*-
2
+ """musicgen.py — P2 参数化配乐编排器(纯 numpy + stdlib,确定性、可单独 import)。
3
+
4
+ D3 目标:删掉 scorekit.MUSIC_STYLES 的 9 套硬编码风格表后,配乐不再由 style id
5
+ 决定,而是由一组可采样参数现场编排:
6
+
7
+ render(duration, *, bpm=96.0, key='Dm', scale='minor', mood='steady',
8
+ seed=0, sections=None, progressions=None, motif=None,
9
+ drums=None, voices=None, energy_curve=None) -> (samples, report)
10
+
11
+ * progressions {tier: [级数, ...]} 和声进行(calm/body/drive[/climax])
12
+ * motif [[拍, 级数, 时值], ...] 旋律骨架(级数相对主音)
13
+ * drums {tier: {kick/hat/clap}} 鼓型(拍位置列表;hat 也接受步长标量)
14
+ * voices {pad/bass/arp/lead: 音色} 六种合成:saw/sine/square/triangle/bell/pluck
15
+ * sections [{start,name,energy}] 段落(决定层数与电平)
16
+ * energy_curve 数字列表 / [[秒, 0-1], ...] 覆盖能量曲线
17
+
18
+ 确定性:不使用 random / time / os.urandom;所有随机量来自 numpy.random.RandomState,
19
+ 种子由 seed + 用途经 sha1 派生。同一组参数两次运行逐采样一致。
20
+
21
+ 音频契约:48 kHz、立体声 (N,2) float32、时长 duration ±1 采样、无 NaN/Inf、
22
+ 峰值 -6 dBFS。母带响度(loudnorm)留给下游。
23
+
24
+ 本模块不 import scorekit / filmkit / PIL,可单独拷走运行。
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import hashlib
30
+ import json
31
+ import math
32
+ import re
33
+ import sys
34
+ import wave
35
+ from pathlib import Path
36
+
37
+ import numpy as np
38
+
39
+ SR = 48000
40
+ PEAK_DBFS = -6.0
41
+ TARGET_PEAK = 10.0 ** (PEAK_DBFS / 20.0)
42
+
43
+ VOICE_IDS = ("saw", "sine", "square", "triangle", "bell", "pluck")
44
+ VOICE_SLOTS = ("pad", "bass", "arp", "lead")
45
+ MOOD_IDS = ("steady", "calm", "reflective", "hopeful", "uplifting", "driving", "tense")
46
+ MOOD_ALIASES = {
47
+ "drive": "driving", "dark": "tense", "epic": "uplifting", "warm": "hopeful",
48
+ "soft": "calm", "minimal": "calm", "playful": "uplifting", "bright": "uplifting",
49
+ "sad": "reflective", "energetic": "driving", "calm-down": "calm",
50
+ }
51
+ SCALE_IDS = ("minor", "major", "dorian", "phrygian", "lydian", "mixolydian", "harmonic-minor")
52
+ SCALE_ALIASES = {
53
+ "min": "minor", "natural-minor": "minor", "aeolian": "minor",
54
+ "maj": "major", "ionian": "major",
55
+ "harmonic": "harmonic-minor", "harmonic_minor": "harmonic-minor",
56
+ }
57
+ TIER_IDS = ("calm", "body", "drive", "climax")
58
+
59
+ _SCALE_STEPS = {
60
+ "major": (0, 2, 4, 5, 7, 9, 11),
61
+ "minor": (0, 2, 3, 5, 7, 8, 10),
62
+ "dorian": (0, 2, 3, 5, 7, 9, 10),
63
+ "phrygian": (0, 1, 3, 5, 7, 8, 10),
64
+ "lydian": (0, 2, 4, 6, 7, 9, 11),
65
+ "mixolydian": (0, 2, 4, 5, 7, 9, 10),
66
+ "harmonic-minor": (0, 2, 3, 5, 7, 8, 11),
67
+ }
68
+ _SCALE_CN = {
69
+ "major": "大调", "minor": "小调", "dorian": "多利亚", "phrygian": "弗里几亚",
70
+ "lydian": "利底亚", "mixolydian": "混合利底亚", "harmonic-minor": "和声小调",
71
+ }
72
+ _ROOT_SEMI = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
73
+ _PITCH_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
74
+ _ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5, "VI": 6, "VII": 7}
75
+ _ROMAN_TOKENS = ("I", "II", "III", "IV", "V", "VI", "VII",
76
+ "i", "ii", "iii", "iv", "v", "vi", "vii")
77
+
78
+ # 情绪档案:只调"亮度/层门限/电平/和声节奏",不写死具体音符。
79
+ _MOOD = {
80
+ "steady": dict(bright=0.00, gate=0.00, lead_db=0.0, drum_db=0.0,
81
+ pad_gain=1.00, bass_gain=1.00, chord_bars=1,
82
+ energy_bias=0.00, swing=0.00),
83
+ "calm": dict(bright=-0.20, gate=0.08, lead_db=-1.5, drum_db=-3.0,
84
+ pad_gain=1.12, bass_gain=0.90, chord_bars=2,
85
+ energy_bias=-0.08, swing=0.00),
86
+ "reflective": dict(bright=-0.12, gate=0.05, lead_db=-1.0, drum_db=-2.0,
87
+ pad_gain=1.08, bass_gain=0.94, chord_bars=2,
88
+ energy_bias=-0.04, swing=0.00),
89
+ "hopeful": dict(bright=0.05, gate=-0.02, lead_db=0.5, drum_db=0.0,
90
+ pad_gain=0.98, bass_gain=1.00, chord_bars=1,
91
+ energy_bias=0.02, swing=0.02),
92
+ "uplifting": dict(bright=0.14, gate=-0.05, lead_db=1.0, drum_db=1.0,
93
+ pad_gain=0.95, bass_gain=1.06, chord_bars=1,
94
+ energy_bias=0.06, swing=0.04),
95
+ "driving": dict(bright=0.20, gate=-0.07, lead_db=1.2, drum_db=2.0,
96
+ pad_gain=0.92, bass_gain=1.10, chord_bars=1,
97
+ energy_bias=0.08, swing=0.05),
98
+ "tense": dict(bright=0.02, gate=0.00, lead_db=0.5, drum_db=0.5,
99
+ pad_gain=1.00, bass_gain=1.02, chord_bars=1,
100
+ energy_bias=0.03, swing=0.00),
101
+ }
102
+
103
+ _DEFAULT_VOICES = {"pad": "saw", "bass": "sine", "arp": "pluck", "lead": "bell"}
104
+
105
+ # 音色不只换波形:不同"乐器"的自然音区/衰减/揉弦也不同。voices 因此真的改变编排。
106
+ _VOICE_TRAITS = {
107
+ # pad_style 覆盖 seed 织体:不同"乐器"的自然奏法本来就不同(弦乐铺底/方波脉冲/钟琴点描)
108
+ "saw": dict(pad_oct=0, arp_oct=0, lead_oct=0, pad_decay=None, bass_decay=None,
109
+ arp_decay=0.30, lead_decay=None, trem=0.00, vib=0.0000,
110
+ pad_style=None, bass_style="pulse", arp_step=None),
111
+ "sine": dict(pad_oct=0, arp_oct=0, lead_oct=0, pad_decay=None, bass_decay=None,
112
+ arp_decay=None, lead_decay=None, trem=0.00, vib=0.0015,
113
+ pad_style="swell", bass_style=None, arp_step=None),
114
+ "square": dict(pad_oct=0, arp_oct=0, lead_oct=0, pad_decay=None, bass_decay=0.35,
115
+ arp_decay=None, lead_decay=0.45, trem=0.25, vib=0.0000,
116
+ pad_style="pulse", bass_style="pulse", arp_step=0.25),
117
+ "triangle": dict(pad_oct=0, arp_oct=0, lead_oct=0, pad_decay=0.90, bass_decay=None,
118
+ arp_decay=0.55, lead_decay=None, trem=0.05, vib=0.0022,
119
+ pad_style="swell", bass_style="half", arp_step=0.25),
120
+ "bell": dict(pad_oct=12, arp_oct=12, lead_oct=0, pad_decay=1.30, bass_decay=0.50,
121
+ arp_decay=1.10, lead_decay=1.30, trem=0.00, vib=0.0000,
122
+ pad_style="sustain", bass_style="whole", arp_step=0.25),
123
+ "pluck": dict(pad_oct=0, arp_oct=0, lead_oct=0, pad_decay=0.50, bass_decay=0.18,
124
+ arp_decay=0.20, lead_decay=0.22, trem=0.00, vib=0.0000,
125
+ pad_style=None, bass_style=None, arp_step=0.5),
126
+ }
127
+
128
+
129
+ def _voice_trait(voice):
130
+ return _VOICE_TRAITS[str(voice)]
131
+ _MOOD_VOICES = {
132
+ "steady": {},
133
+ "calm": {"pad": "triangle"},
134
+ "reflective": {"pad": "triangle"},
135
+ "hopeful": {},
136
+ "uplifting": {"bass": "square"},
137
+ "driving": {"bass": "square", "lead": "pluck"},
138
+ "tense": {"pad": "square", "bass": "saw"},
139
+ }
140
+
141
+ # 缺省和声进行(按调式家族 + 情绪;用户 progressions 优先)。
142
+ _MINOR_PROGS = {
143
+ "steady": dict(calm=("i", "VI", "iv", "v"), body=("i", "VI", "III", "VII"),
144
+ drive=("i", "VII", "VI", "V"), climax=("i", "VI", "III", "V")),
145
+ "calm": dict(calm=("i", "iv", "VI", "v"), body=("i", "iv", "VII", "i"),
146
+ drive=("i", "VI", "iv", "v"), climax=("i", "iv", "VI", "V")),
147
+ "reflective": dict(calm=("i", "VI", "iv", "i"), body=("i", "iv", "VI", "VII"),
148
+ drive=("i", "VI", "III", "i"), climax=("i", "iv", "VI", "V")),
149
+ "hopeful": dict(calm=("i", "III", "VI", "v"), body=("i", "III", "VI", "VII"),
150
+ drive=("i", "VI", "III", "VII"), climax=("i", "III", "VI", "V")),
151
+ "uplifting": dict(calm=("i", "VI", "iv", "v"), body=("i", "VI", "III", "VII"),
152
+ drive=("i", "V", "VI", "VII"), climax=("i", "VI", "III", "V")),
153
+ "driving": dict(calm=("i", "i", "VII", "VI"), body=("i", "VII", "VI", "V"),
154
+ drive=("i", "VII", "VI", "V"), climax=("i", "VII", "V", "V")),
155
+ "tense": dict(calm=("i", "bII", "iv", "i"), body=("i", "bII", "iv", "V"),
156
+ drive=("i", "bII", "V", "i"), climax=("i", "bII", "iv", "V")),
157
+ }
158
+ _MAJOR_PROGS = {
159
+ "steady": dict(calm=("Imaj7", "vi7", "ii7", "V7"), body=("I", "V", "vi", "IV"),
160
+ drive=("I", "IV", "V", "vi"), climax=("I", "IV", "V", "V")),
161
+ "calm": dict(calm=("Imaj7", "vi7", "ii7", "V7"), body=("I", "vi", "IV", "V"),
162
+ drive=("I", "IV", "V", "vi"), climax=("I", "IV", "V", "V")),
163
+ "reflective": dict(calm=("I", "vi", "ii", "IV"), body=("I", "iii", "vi", "IV"),
164
+ drive=("I", "IV", "vi", "V"), climax=("I", "IV", "V", "V")),
165
+ "hopeful": dict(calm=("I", "IV", "vi", "V"), body=("I", "IV", "V", "vi"),
166
+ drive=("I", "V", "vi", "IV"), climax=("I", "IV", "V", "V")),
167
+ "uplifting": dict(calm=("I", "V", "vi", "IV"), body=("I", "V", "vi", "IV"),
168
+ drive=("I", "IV", "V", "vi"), climax=("I", "IV", "V", "V")),
169
+ "driving": dict(calm=("I", "I", "IV", "V"), body=("I", "IV", "V", "vi"),
170
+ drive=("I", "IV", "V", "I"), climax=("I", "IV", "V", "I")),
171
+ "tense": dict(calm=("I", "bII", "IV", "I"), body=("I", "bII", "IV", "V"),
172
+ drive=("I", "bII", "V", "I"), climax=("I", "bII", "IV", "V")),
173
+ }
174
+
175
+ # 缺省动机(8 拍乐句;级数相对主音)。
176
+ _MINOR_MOTIFS = {
177
+ "steady": ((0.0, "i", 1.0), (1.5, "v", 1.0), (2.5, "III", 1.5),
178
+ (4.0, "iv", 1.0), (5.5, "v", 1.0), (6.5, "III", 1.5)),
179
+ "calm": ((0.0, "i", 2.0), (2.0, "III", 2.0), (4.0, "VI", 2.0), (6.0, "v", 1.5)),
180
+ "reflective": ((0.0, "III", 1.5), (2.0, "v", 1.0), (3.0, "i", 2.0), (5.5, "VI", 1.5)),
181
+ "hopeful": ((0.0, "i", 1.0), (1.0, "III", 1.0), (2.0, "VI", 1.5),
182
+ (3.5, "v", 1.0), (5.0, "III", 1.0), (6.0, "i", 2.0)),
183
+ "uplifting": ((0.0, "i", 0.75), (0.75, "III", 0.75), (1.5, "v", 0.75),
184
+ (2.25, "VI", 0.75), (3.0, "III", 1.0), (4.5, "v", 0.75),
185
+ (5.25, "i", 0.75), (6.0, "III", 0.75), (7.0, "v", 1.0)),
186
+ "driving": ((0.0, "i", 0.5), (0.5, "i", 0.5), (1.0, "III", 0.5), (1.5, "v", 0.5),
187
+ (2.0, "i", 0.5), (2.5, "i", 0.5), (3.0, "iv", 0.5), (3.5, "v", 0.5),
188
+ (4.0, "i", 0.5), (4.5, "III", 0.5), (5.0, "VI", 0.5), (5.5, "v", 0.5),
189
+ (6.0, "i", 0.5), (6.5, "v", 0.5), (7.0, "III", 1.0)),
190
+ "tense": ((0.0, "i", 1.0), (1.0, "bII", 1.0), (2.0, "i", 1.0), (3.0, "V", 1.5),
191
+ (5.0, "bII", 0.5), (5.5, "i", 0.5), (6.5, "V", 1.5)),
192
+ }
193
+ _MAJOR_MOTIFS = {
194
+ "steady": ((0.0, "I", 1.0), (1.5, "V", 1.0), (2.5, "vi", 1.5),
195
+ (4.0, "IV", 1.0), (5.5, "V", 1.0), (6.5, "I", 1.5)),
196
+ "calm": ((0.0, "I", 2.0), (2.0, "vi", 2.0), (4.0, "IV", 2.0), (6.0, "V", 1.5)),
197
+ "reflective": ((0.0, "vi", 1.5), (2.0, "IV", 1.0), (3.0, "I", 2.0), (5.5, "V", 1.5)),
198
+ "hopeful": ((0.0, "I", 1.0), (1.0, "IV", 1.0), (2.0, "V", 1.5),
199
+ (3.5, "vi", 1.0), (5.0, "IV", 1.0), (6.0, "I", 2.0)),
200
+ "uplifting": ((0.0, "I", 0.75), (0.75, "V", 0.75), (1.5, "vi", 0.75),
201
+ (2.25, "IV", 0.75), (3.0, "V", 1.0), (4.5, "vi", 0.75),
202
+ (5.25, "I", 0.75), (6.0, "IV", 0.75), (7.0, "V", 1.0)),
203
+ "driving": ((0.0, "I", 0.5), (0.5, "I", 0.5), (1.0, "IV", 0.5), (1.5, "V", 0.5),
204
+ (2.0, "I", 0.5), (2.5, "I", 0.5), (3.0, "IV", 0.5), (3.5, "V", 0.5),
205
+ (4.0, "I", 0.5), (4.5, "IV", 0.5), (5.0, "vi", 0.5), (5.5, "V", 0.5),
206
+ (6.0, "I", 0.5), (6.5, "V", 0.5), (7.0, "IV", 1.0)),
207
+ "tense": ((0.0, "I", 1.0), (1.0, "bII", 1.0), (2.0, "I", 1.0), (3.0, "V", 1.5),
208
+ (5.0, "bII", 0.5), (5.5, "I", 0.5), (6.5, "V", 1.5)),
209
+ }
210
+
211
+ _DEGREE_RE = re.compile(r"^\s*([b#]?)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i)(.*)$")
212
+ _KEY_RE = re.compile(r"^\s*([A-Ga-g])([#b]?)(.*)$")
213
+
214
+
215
+ def _require(cond, msg):
216
+ if not cond:
217
+ raise ValueError(msg)
218
+
219
+
220
+ def _setup_stdio():
221
+ for stream in (sys.stdout, sys.stderr):
222
+ try:
223
+ stream.reconfigure(encoding="utf-8")
224
+ except Exception:
225
+ pass
226
+
227
+
228
+ def _clip01(x):
229
+ return float(min(1.0, max(0.0, float(x))))
230
+
231
+
232
+ def _smoothstep(x):
233
+ x = np.clip(x, 0.0, 1.0)
234
+ return x * x * (3.0 - 2.0 * x)
235
+
236
+
237
+ def _db(x):
238
+ return 10.0 ** (float(x) / 20.0)
239
+
240
+
241
+ def _sha_hex(*parts):
242
+ raw = "|".join(str(p) for p in parts).encode("utf-8")
243
+ return hashlib.sha1(raw).hexdigest()
244
+
245
+
246
+ def _rng(*parts):
247
+ """独立随机流:同 parts 必得同一 RandomState(sha1 派生,不依赖 PYTHONHASHSEED)。"""
248
+ seed = int(_sha_hex(*parts)[:16], 16) % (2 ** 32 - 1)
249
+ return np.random.RandomState(seed)
250
+
251
+
252
+ def _parse_key(key):
253
+ """'Dm' / 'F#m' / 'Bb' / 'C' -> (chroma, minor_hint, 显示名, 根音拼写)。"""
254
+ s = str(key).strip()
255
+ m = _KEY_RE.match(s)
256
+ _require(m is not None, "key 解析失败:%r(应形如 Dm / F#m / Bb / C)" % (key,))
257
+ root = _ROOT_SEMI[m.group(1).upper()]
258
+ acc = m.group(2)
259
+ if acc == "#":
260
+ root += 1
261
+ elif acc == "b":
262
+ root -= 1
263
+ rest = m.group(3).strip().lower()
264
+ _require(rest in ("", "m", "min", "minor", "maj", "major"),
265
+ "key 后缀只支持 m/min/minor/maj/major,收到 %r" % (key,))
266
+ minor_hint = rest.startswith("m") and not rest.startswith("maj")
267
+ chroma = root % 12
268
+ root_name = m.group(1).upper() + m.group(2)
269
+ disp = root_name + ("m" if minor_hint else "")
270
+ return chroma, bool(minor_hint), disp, root_name
271
+
272
+
273
+ def _normalize_scale(scale):
274
+ s = str(scale).strip().lower().replace("_", "-").replace(" ", "-")
275
+ s = SCALE_ALIASES.get(s, s)
276
+ _require(s in SCALE_IDS, "未知调式 %r;可选:%s" % (scale, "/".join(SCALE_IDS)))
277
+ return s
278
+
279
+
280
+ def _normalize_mood(mood):
281
+ s = str(mood).strip().lower()
282
+ s = MOOD_ALIASES.get(s, s)
283
+ _require(s in MOOD_IDS, "未知情绪 %r;可选:%s" % (mood, "/".join(MOOD_IDS)))
284
+ return s
285
+
286
+
287
+ def _scale_family(scale):
288
+ return "major" if scale in ("major", "lydian", "mixolydian") else "minor"
289
+
290
+
291
+ def _default_progressions(scale, mood):
292
+ table = _MAJOR_PROGS if _scale_family(scale) == "major" else _MINOR_PROGS
293
+ return {t: tuple(table[mood][t]) for t in TIER_IDS}
294
+
295
+
296
+ def _default_motif(scale, mood):
297
+ table = _MAJOR_MOTIFS if _scale_family(scale) == "major" else _MINOR_MOTIFS
298
+ return [list(x) for x in table[mood]]
299
+
300
+
301
+ def _grid(step, limit=4.0):
302
+ step = float(step)
303
+ _require(step > 0.0 and math.isfinite(step), "hat 步长必须是有限正数,收到 %r" % (step,))
304
+ out = []
305
+ x = 0.0
306
+ guard = 0
307
+ while x < limit - 1e-9 and guard < 256:
308
+ out.append(round(x, 4))
309
+ x += step
310
+ guard += 1
311
+ return out
312
+
313
+
314
+ def _default_drums(mood):
315
+ """按"情绪密度带 × tier"派生四档鼓型:同一 tier 在不同情绪下也不同密度。
316
+
317
+ 密度带只决定三档骨架(low/mid/high),tier 决定骨架内的位置组合;
318
+ 这样 12 s 短片的 calm/body/drive 不是同一张四踩底鼓表。
319
+ """
320
+ density = {
321
+ "steady": 0.50, "calm": 0.18, "reflective": 0.26, "hopeful": 0.56,
322
+ "uplifting": 0.68, "driving": 0.84, "tense": 0.60,
323
+ }[mood]
324
+ band = "low" if density < 0.50 else ("mid" if density < 0.68 else "high")
325
+ skeletons = {
326
+ "low": {
327
+ "calm": ([], [], []),
328
+ "body": ([0.0, 2.0], _grid(0.5), []),
329
+ "drive": ([0.0, 2.0], _grid(0.5), []),
330
+ "climax": ([0.0, 1.0, 2.0, 3.0], _grid(0.5), [1.0, 3.0]),
331
+ },
332
+ "mid": {
333
+ "calm": ([0.0], [2.0], []),
334
+ "body": ([0.0, 2.0], _grid(0.5), []),
335
+ "drive": ([0.0, 1.0, 2.0, 3.0], _grid(0.5), [1.0, 3.0]),
336
+ "climax": ([0.0, 0.75, 1.5, 2.0, 2.75, 3.5], _grid(0.25), [1.0, 3.0]),
337
+ },
338
+ "high": {
339
+ "calm": ([0.0], [2.0], []),
340
+ "body": ([0.0, 2.0], _grid(0.5), [1.0, 3.0]),
341
+ "drive": ([0.0, 1.0, 2.0, 3.0], _grid(0.25), [1.0, 3.0]),
342
+ "climax": ([0.0, 0.75, 1.5, 2.0, 2.75, 3.5], _grid(0.25), [1.0, 3.0]),
343
+ },
344
+ }
345
+ out = {}
346
+ for tier in TIER_IDS:
347
+ kick, hat, clap = skeletons[band][tier]
348
+ kick, hat, clap = list(kick), list(hat), list(clap)
349
+ if mood == "tense" and tier in ("body", "drive", "climax"):
350
+ kick = [0.0, 1.5, 2.0, 3.5]
351
+ if tier != "body":
352
+ hat = _grid(0.25)
353
+ if mood in ("calm", "reflective"):
354
+ hat = [x for x in hat if abs(x - round(x)) < 1e-9]
355
+ out[tier] = {"kick": kick, "hat": hat, "clap": clap}
356
+ return out
357
+
358
+
359
+ def _parse_degree(token):
360
+ """解析罗马数字级数,返回 dict(acc, deg, upper, intervals, token)。
361
+
362
+ 大小写决定三和弦性质(i 小 / I 大),后缀可覆盖:
363
+ maj7 / 7 / m7 / dim / dim7 / m7b5 / aug / sus4 / sus2 / add9 / 6。
364
+ """
365
+ s = str(token).strip()
366
+ m = _DEGREE_RE.match(s)
367
+ _require(m is not None, "级数解析失败:%r" % (token,))
368
+ acc_s, roman, suffix_raw = m.group(1), m.group(2), m.group(3).strip()
369
+ _require(roman in _ROMAN_TOKENS, "未知级数 %r(只支持 I-VII / i-vii)" % (token,))
370
+ deg = _ROMAN[roman.upper()]
371
+ upper = roman.isupper()
372
+ acc = -1 if acc_s == "b" else (1 if acc_s == "#" else 0)
373
+ suffix = suffix_raw.lower()
374
+ if suffix_raw in ("M7", "Maj7", "Δ7"):
375
+ suffix = "maj7"
376
+ if suffix == "":
377
+ iv = (0, 4, 7) if upper else (0, 3, 7)
378
+ elif suffix in ("maj", "major"):
379
+ iv = (0, 4, 7)
380
+ elif suffix in ("m", "min", "minor"):
381
+ iv = (0, 3, 7)
382
+ elif suffix in ("dim", "o", "°"):
383
+ iv = (0, 3, 6)
384
+ elif suffix in ("aug", "+"):
385
+ iv = (0, 4, 8)
386
+ elif suffix == "7":
387
+ iv = (0, 4, 7, 10) if upper else (0, 3, 7, 10)
388
+ elif suffix == "maj7":
389
+ iv = (0, 4, 7, 11)
390
+ elif suffix == "m7":
391
+ iv = (0, 3, 7, 10)
392
+ elif suffix == "dim7":
393
+ iv = (0, 3, 6, 9)
394
+ elif suffix == "m7b5":
395
+ iv = (0, 3, 6, 10)
396
+ elif suffix == "sus4":
397
+ iv = (0, 5, 7)
398
+ elif suffix == "sus2":
399
+ iv = (0, 2, 7)
400
+ elif suffix == "add9":
401
+ iv = (0, 4, 7, 14) if upper else (0, 3, 7, 14)
402
+ elif suffix == "6":
403
+ iv = (0, 4, 7, 9) if upper else (0, 3, 7, 9)
404
+ else:
405
+ raise ValueError("未知和弦后缀 %r(级数 %r)" % (suffix_raw, token))
406
+ return {"acc": acc, "deg": deg, "upper": upper, "intervals": iv, "token": s}
407
+
408
+
409
+ def _midi_freq(midi):
410
+ return 440.0 * (2.0 ** ((float(midi) - 69.0) / 12.0))
411
+
412
+
413
+ # ============================================================================
414
+ # 合成原语:波表振荡器(按音高限谐波防混叠)+ 包络 + 鼓
415
+ # ============================================================================
416
+ _TABLE_N = 2048
417
+ _TABLE_MASK = _TABLE_N - 1
418
+ _TABLE_CAPS = (1, 2, 4, 6, 8, 12, 16, 24, 32, 48)
419
+ _TABLES = {}
420
+
421
+
422
+ def _harmonic_table(voice, cap):
423
+ """单周期波表:saw/square/triangle/pluck 都是加性合成,按 cap 限谐波。"""
424
+ key = (voice, int(cap))
425
+ tab = _TABLES.get(key)
426
+ if tab is not None:
427
+ return tab
428
+ cap = max(1, int(cap))
429
+ ph = np.arange(_TABLE_N, dtype=np.float64) / float(_TABLE_N)
430
+ if voice == "sine":
431
+ wave = np.sin(2.0 * math.pi * ph)
432
+ elif voice == "saw":
433
+ wave = np.zeros(_TABLE_N)
434
+ for k in range(1, cap + 1):
435
+ wave += (1.0 / k) * np.sin(2.0 * math.pi * k * ph)
436
+ elif voice == "square":
437
+ wave = np.zeros(_TABLE_N)
438
+ for k in range(1, cap + 1, 2):
439
+ wave += (1.0 / k) * np.sin(2.0 * math.pi * k * ph)
440
+ elif voice == "triangle":
441
+ wave = np.zeros(_TABLE_N)
442
+ for k in range(1, cap + 1, 2):
443
+ sign = 1.0 if ((k // 2) % 2 == 0) else -1.0
444
+ wave += (sign / (k * k)) * np.sin(2.0 * math.pi * k * ph)
445
+ elif voice == "pluck":
446
+ wave = np.zeros(_TABLE_N)
447
+ for k in range(1, cap + 1):
448
+ weight = (1.0 if (k % 2) else 0.62) / (k ** 1.35)
449
+ wave += weight * np.sin(2.0 * math.pi * k * ph)
450
+ else:
451
+ raise ValueError("未知音色 %r;可选:%s" % (voice, "/".join(VOICE_IDS)))
452
+ wave -= float(wave.mean())
453
+ peak = float(np.max(np.abs(wave)))
454
+ if peak < 1e-9:
455
+ peak = 1.0
456
+ tab = (wave / peak).astype(np.float64)
457
+ _TABLES[key] = tab
458
+ return tab
459
+
460
+
461
+ def _cap_for_freq(freq, bright):
462
+ """按 Nyquist 选谐波上限;bright>0 更亮,bright<0 更暗。"""
463
+ limit = 0.45 * SR / max(float(freq), 1e-6)
464
+ want = int(max(1.0, min(48.0, limit)))
465
+ if bright > 0.0:
466
+ want = int(want * (1.0 + 0.35 * bright))
467
+ elif bright < 0.0:
468
+ want = int(want * (1.0 + 0.60 * bright))
469
+ want = max(1, min(48, want))
470
+ chosen = 1
471
+ for c in _TABLE_CAPS:
472
+ if c <= want:
473
+ chosen = c
474
+ return chosen
475
+
476
+
477
+ def _table_osc(voice, freq, n, phase0, bright, vib=0.0, vib_rate=5.2):
478
+ cap = _cap_for_freq(freq, bright)
479
+ tab = _harmonic_table(voice, cap)
480
+ t = np.arange(n, dtype=np.float64) / float(SR)
481
+ cycles = float(phase0) + float(freq) * t
482
+ if vib:
483
+ cycles = cycles + float(vib) * np.sin(2.0 * math.pi * float(vib_rate) * t)
484
+ idx = cycles * float(_TABLE_N)
485
+ base = np.floor(idx)
486
+ frac = idx - base
487
+ i0 = base.astype(np.int64) & _TABLE_MASK
488
+ i1 = (i0 + 1) & _TABLE_MASK
489
+ return tab[i0] + (tab[i1] - tab[i0]) * frac
490
+
491
+
492
+ _BELL_PARTS = ((1.0, 1.0, 1.0), (2.0, 0.42, 1.55), (2.76, 0.28, 2.15),
493
+ (4.07, 0.14, 2.90), (5.43, 0.08, 3.70))
494
+
495
+
496
+ def _bell_signal(freq, n, phase0, decay):
497
+ t = np.arange(n, dtype=np.float64) / float(SR)
498
+ sig = np.zeros(n, dtype=np.float64)
499
+ dcy = max(0.08, float(decay))
500
+ for ratio, amp, mult in _BELL_PARTS:
501
+ sig += amp * np.sin(2.0 * math.pi * float(freq) * ratio * t + float(phase0) * ratio) \
502
+ * np.exp(-t / (dcy * mult))
503
+ return sig / 1.9
504
+
505
+
506
+ def _note_signal(voice, freq, n, phase0, bright, decay, detune_cents, vib=0.0):
507
+ if voice == "bell":
508
+ return _bell_signal(freq, n, phase0, decay)
509
+ if detune_cents and float(detune_cents) > 0.0:
510
+ d = float(detune_cents) / 1200.0
511
+ a = _table_osc(voice, freq * (2.0 ** d), n, phase0, bright, vib)
512
+ b = _table_osc(voice, freq * (2.0 ** (-d)), n, phase0 + 0.37, bright, vib, 5.9)
513
+ return 0.5 * (a + b)
514
+ return _table_osc(voice, freq, n, phase0, bright, vib)
515
+
516
+
517
+ def _env_curve(n, attack, release, decay=None):
518
+ t = np.arange(n, dtype=np.float64) / float(SR)
519
+ env = np.ones(n, dtype=np.float64)
520
+ if decay is not None and float(decay) > 0.0:
521
+ env *= np.exp(-t / float(decay))
522
+ na = int(round(max(0.0, float(attack)) * SR))
523
+ na = min(n, max(1, na))
524
+ if na > 2:
525
+ env[:na] *= 0.5 - 0.5 * np.cos(np.linspace(0.0, math.pi, na))
526
+ nr = int(round(max(0.0, float(release)) * SR))
527
+ nr = min(n, max(1, nr))
528
+ if nr > 2:
529
+ env[-nr:] *= 0.5 + 0.5 * np.cos(np.linspace(0.0, math.pi, nr))
530
+ return env
531
+
532
+
533
+ def _pan_gains(pan):
534
+ p = max(-1.0, min(1.0, float(pan)))
535
+ ang = (p + 1.0) * (math.pi / 4.0)
536
+ return math.cos(ang), math.sin(ang)
537
+
538
+
539
+ def _add_mono(buf, start, sig, pan=0.0):
540
+ s = int(round(float(start) * SR))
541
+ if s >= len(buf):
542
+ return 0
543
+ if s < 0:
544
+ sig = sig[-s:]
545
+ s = 0
546
+ m = min(len(sig), len(buf) - s)
547
+ if m <= 0:
548
+ return 0
549
+ g_l, g_r = _pan_gains(pan)
550
+ buf[s:s + m, 0] += (sig[:m] * g_l).astype(np.float32)
551
+ buf[s:s + m, 1] += (sig[:m] * g_r).astype(np.float32)
552
+ return m
553
+
554
+
555
+ def _render_note(buf, start, dur, midi, voice, amp, pan, attack, release,
556
+ decay=None, bright=0.0, detune=0.0, rng=None, vib=0.0):
557
+ n = int(round(max(0.0, float(dur)) * SR))
558
+ if n < 16:
559
+ return 0
560
+ phase0 = float(rng.rand()) if rng is not None else 0.13
561
+ sig = _note_signal(voice, _midi_freq(midi), n, phase0, bright, decay, detune, vib)
562
+ # bell 的衰减已经写在各分音里,包络不再乘一次 decay,否则尾音被砍半
563
+ sig *= _env_curve(n, attack, release, None if voice == "bell" else decay)
564
+ sig *= float(amp)
565
+ return _add_mono(buf, start, sig, pan)
566
+
567
+
568
+ # ---------------------------------------------------------------- 鼓组
569
+ def _render_kick(buf, t, vel, rng):
570
+ n = int(0.52 * SR)
571
+ tt = np.arange(n, dtype=np.float64) / float(SR)
572
+ freq = 43.0 + 105.0 * np.exp(-tt / 0.035)
573
+ phase = 2.0 * math.pi * np.cumsum(freq) / float(SR)
574
+ body = np.sin(phase) * np.exp(-tt / 0.20)
575
+ click = rng.normal(0.0, 1.0, n) * np.exp(-tt / 0.0035) * 0.30
576
+ sig = np.tanh(1.5 * (body + click)) * 0.92
577
+ _add_mono(buf, t, sig * float(vel), 0.0)
578
+
579
+
580
+ def _render_hat(buf, t, vel, rng, open_hat=False):
581
+ n = int((0.30 if open_hat else 0.10) * SR)
582
+ tt = np.arange(n, dtype=np.float64) / float(SR)
583
+ noise = rng.normal(0.0, 1.0, n)
584
+ hf = np.diff(noise, prepend=0.0)
585
+ hf /= (float(np.std(hf)) + 1e-9)
586
+ env = np.exp(-tt / (0.13 if open_hat else 0.034))
587
+ sig = hf * env * 0.30
588
+ _add_mono(buf, t, sig * float(vel), 0.28 if open_hat else 0.36)
589
+
590
+
591
+ def _render_clap(buf, t, vel, rng, fill=False):
592
+ n = int(0.20 * SR)
593
+ tt = np.arange(n, dtype=np.float64) / float(SR)
594
+ noise = rng.normal(0.0, 1.0, n)
595
+ bp = np.diff(np.diff(noise, prepend=0.0), prepend=0.0)
596
+ bp /= (float(np.std(bp)) + 1e-9)
597
+ env = np.zeros(n, dtype=np.float64)
598
+ for k in (0.0, 0.011, 0.022):
599
+ i = int(k * SR)
600
+ if i < n:
601
+ env[i:] += np.exp(-(tt[i:] - k) / (0.008 if fill else 0.012))
602
+ sig = bp * env * (0.24 if fill else 0.34)
603
+ _add_mono(buf, t, sig * float(vel), -0.12)
604
+
605
+
606
+ # ============================================================================
607
+ # 参数归一化:段落 / 和声 / 动机 / 鼓型 / 音色 / 能量曲线
608
+ # ============================================================================
609
+ def _default_sections(duration, mood, bpm=96.0):
610
+ """默认段落:**按小节切**,不按时长比例切。
611
+
612
+ 为什么必须对齐小节:tier 是按小节取的(一小节一个 tier,见 _build_plan),
613
+ 而段落原本按时长分数切。两套网格对不齐时,峰值段可能整段落在两个小节中心之间,
614
+ **climax 永远触发不了** —— 实测 8 s / 96 BPM 就是这种情况(中心 1.25/3.75/6.25/8.75,
615
+ peak 段 4.16–6.08,三个中心全在窗外),调用方传 {"climax": [...]} 完全无效。
616
+ 改成按小节切之后,每段天然覆盖整数个小节,climax 一定落在至少一个完整小节里。
617
+ """
618
+ bias = _MOOD[mood]["energy_bias"]
619
+ dur = float(duration)
620
+ bar_s = 4.0 * (60.0 / float(bpm))
621
+ nbars = max(1, int(math.ceil(dur / bar_s - 1e-9)))
622
+
623
+ def _bar_fracs(weights):
624
+ """把"每段占几小节"的权重组,按**小节边界**转成时间分数。
625
+
626
+ 分配规则:先按权重比例算累计小节数并四舍五入,再强制"每段至少 1 小节"、
627
+ 总和等于 nbars —— 否则末段会不足一小节,它的 probe 窗口被邻居覆盖(见上)。
628
+ """
629
+ if len(weights) == 1:
630
+ return [0.0]
631
+ total = float(sum(weights))
632
+ raw = [float(w) / total * nbars for w in weights]
633
+ bars = [max(1, int(round(x))) for x in raw]
634
+ # 修正总数:多了从最长的段减、少了给最长的段加(保持相对比例)
635
+ while sum(bars) > nbars:
636
+ i = max(range(len(bars)), key=lambda k: bars[k])
637
+ if bars[i] <= 1:
638
+ break
639
+ bars[i] -= 1
640
+ while sum(bars) < nbars:
641
+ i = max(range(len(bars)), key=lambda k: raw[k])
642
+ bars[i] += 1
643
+ out, acc = [0.0], 0
644
+ for b in bars[:-1]:
645
+ acc += b
646
+ out.append(min(1.0, max(0.0, acc * bar_s / dur)))
647
+ return out
648
+ # 段数按"总小节数"定,权重按"每段占几小节"给 —— 这样切点天然落在小节边界。
649
+ # peak 段必须是一整个小节(0.90 > _tier_for 的 0.88 阈值),且它左右各留至少一小节,
650
+ # 否则高潮会被相邻段吃掉或被收尾段压平。
651
+ if nbars <= 1:
652
+ weights, names, energies = (1.0,), ("全片",), (0.55,)
653
+ elif nbars == 2:
654
+ # 两小节片(约 5–7 s):没有空间再放收尾段。peak 落在最后一小节是**有意的**,
655
+ # 由末尾 fade-out 收住,不硬塞一个只有零点几秒的 outro(那种 outro 的能量
656
+ # 探测窗口还会被前一小节的高潮污染,反而判成 climax)。
657
+ weights, names, energies = (1.0, 1.0), ("build", "peak"), (0.55, 0.90)
658
+ elif nbars == 3:
659
+ weights, names, energies = (1.0, 1.0, 1.0), ("intro", "peak", "outro"), (0.45, 0.90, 0.30)
660
+ elif nbars == 4:
661
+ # 4 小节:outro 只占 1 小节,peak 也 1 小节。
662
+ weights, names, energies = (1.0, 1.0, 1.0, 1.0), ("intro", "build", "peak", "outro"), (0.30, 0.52, 0.90, 0.30)
663
+ elif nbars <= 7:
664
+ weights, names, energies = (1.0, 1.0, 1.0, 1.0, 1.0), ("intro", "build", "body", "peak", "outro"), (0.24, 0.42, 0.58, 0.90, 0.30)
665
+ else:
666
+ # 权重之和必须**等于 nbars**:每段至少占满一个小节。
667
+ # 早先 body 用 nbars-4、末尾再叠 1 小节 outro,权重和会超出 nbars,
668
+ # 于是最后一段被压到不足一小节 —— 它的探测窗口被前一整小节的 peak 覆盖,
669
+ # 收尾小节被判成 climax(实测 8 s / 96 BPM:outro 只有 0.5 s)。
670
+ body_bars = max(1, nbars - 4)
671
+ peak_bars = 1
672
+ weights = (1.0, 1.0, float(body_bars), float(peak_bars), 1.0)
673
+ names = ("intro", "build", "body", "peak", "outro")
674
+ energies = (0.24, 0.42, 0.58, 0.90, 0.30)
675
+ fracs = _bar_fracs(weights)
676
+ out = []
677
+ for i, name in enumerate(names):
678
+ start = 0.0 if i == 0 else min(dur, fracs[i] * dur)
679
+ out.append({"name": name, "start": start, "energy": _clip01(energies[i] + bias)})
680
+ for i, item in enumerate(out):
681
+ item["end"] = out[i + 1]["start"] if i + 1 < len(out) else dur
682
+ return out
683
+
684
+
685
+ def _normalize_sections(duration, sections, mood, bpm=96.0):
686
+ dur = float(duration)
687
+ if not sections:
688
+ return _default_sections(dur, mood, bpm)
689
+ _require(isinstance(sections, (list, tuple)) and len(sections) > 0,
690
+ "sections 必须是非空列表:[{start,name,energy}, ...]")
691
+ parsed = []
692
+ for i, s in enumerate(sections):
693
+ _require(isinstance(s, dict), "sections[%d] 必须是 dict,收到 %r" % (i, type(s).__name__))
694
+ try:
695
+ start = float(s.get("start", 0.0))
696
+ energy = float(s.get("energy", 0.5))
697
+ except (TypeError, ValueError):
698
+ raise ValueError("sections[%d] 的 start/energy 必须是数字:%r" % (i, s))
699
+ _require(math.isfinite(start) and math.isfinite(energy),
700
+ "sections[%d] 的 start/energy 必须是有限数" % i)
701
+ name = str(s.get("name") or ("sec%d" % (i + 1))).strip() or ("sec%d" % (i + 1))
702
+ parsed.append({"name": name, "start": max(0.0, min(dur, start)),
703
+ "energy": _clip01(energy)})
704
+ parsed.sort(key=lambda x: x["start"])
705
+ uniq = []
706
+ for item in parsed:
707
+ if uniq and abs(item["start"] - uniq[-1]["start"]) < 1e-6:
708
+ uniq[-1]["energy"] = max(uniq[-1]["energy"], item["energy"])
709
+ else:
710
+ uniq.append(item)
711
+ uniq = [x for x in uniq if x["start"] < dur - 1e-9]
712
+ _require(bool(uniq), "sections 全部落在片长之外(%.3f s)" % dur)
713
+ if uniq[0]["start"] > 1e-6:
714
+ uniq.insert(0, {"name": "intro", "start": 0.0,
715
+ "energy": _clip01(uniq[0]["energy"] * 0.75)})
716
+ uniq[0]["start"] = 0.0
717
+ for i, item in enumerate(uniq):
718
+ end = uniq[i + 1]["start"] if i + 1 < len(uniq) else dur
719
+ item["end"] = max(item["start"], min(dur, float(end)))
720
+ return uniq
721
+
722
+
723
+ def _normalize_progressions(user, scale, mood):
724
+ defaults = _default_progressions(scale, mood)
725
+ if user is None:
726
+ return {t: list(defaults[t]) for t in TIER_IDS}
727
+ if isinstance(user, (list, tuple)):
728
+ user = {t: list(user) for t in TIER_IDS}
729
+ _require(isinstance(user, dict),
730
+ "progressions 必须是 {tier: [级数,...]} 或一个级数列表,收到 %r"
731
+ % (type(user).__name__,))
732
+ out = {}
733
+ for tier in TIER_IDS:
734
+ val = user.get(tier)
735
+ if val is None:
736
+ out[tier] = list(defaults[tier])
737
+ continue
738
+ _require(not isinstance(val, str),
739
+ "progressions[%r] 必须是级数列表(单个数也请写成 ['i']),收到 %r"
740
+ % (tier, val))
741
+ _require(isinstance(val, (list, tuple)) and len(val) > 0,
742
+ "progressions[%r] 必须是非空级数列表" % (tier,))
743
+ toks = []
744
+ for tok in val:
745
+ d = _parse_degree(tok)
746
+ toks.append(d["token"])
747
+ out[tier] = toks
748
+ return out
749
+
750
+
751
+ def _normalize_motif(user, scale, mood):
752
+ if user is None:
753
+ return [list(x) for x in _default_motif(scale, mood)]
754
+ _require(isinstance(user, (list, tuple)) and len(user) > 0,
755
+ "motif 必须是非空列表:[[拍, 级数, 时值], ...]")
756
+ out = []
757
+ for i, item in enumerate(user):
758
+ _require(isinstance(item, (list, tuple)) and len(item) >= 3,
759
+ "motif[%d] 必须是 [拍, 级数, 时值] 三元组:%r" % (i, item))
760
+ try:
761
+ beat = float(item[0])
762
+ dur = float(item[2])
763
+ except (TypeError, ValueError):
764
+ raise ValueError("motif[%d] 的拍/时值必须是数字:%r" % (i, item))
765
+ _require(math.isfinite(beat) and math.isfinite(dur), "motif[%d] 拍/时值必须有限" % i)
766
+ _require(beat >= 0.0, "motif[%d] 的拍不能为负:%r" % (i, beat))
767
+ _require(dur > 0.0, "motif[%d] 的时值必须 > 0:%r" % (i, dur))
768
+ d = _parse_degree(item[1])
769
+ out.append([round(beat, 6), d["token"], round(dur, 6)])
770
+ out.sort(key=lambda x: x[0])
771
+ return out
772
+
773
+
774
+ def _drum_positions(val, key):
775
+ if val is None or val is False:
776
+ return []
777
+ if isinstance(val, (int, float, np.integer, np.floating)):
778
+ v = float(val)
779
+ _require(math.isfinite(v) and v >= 0.0, "%s 的拍位置必须是有限非负数:%r" % (key, val))
780
+ if key == "hat" and (v in (0.25, 0.5, 1.0, 2.0) or 0.0 < v < 1.0):
781
+ return _grid(v)
782
+ return [round(v % 4.0, 4)]
783
+ _require(isinstance(val, (list, tuple)), "%s 必须是拍位置列表,收到 %r" % (key, val))
784
+ out = []
785
+ for x in val:
786
+ try:
787
+ v = float(x)
788
+ except (TypeError, ValueError):
789
+ raise ValueError("%s 的拍位置必须是数字,收到 %r" % (key, x))
790
+ _require(math.isfinite(v) and v >= 0.0, "%s 的拍位置必须是有限非负数:%r" % (key, x))
791
+ out.append(round(v % 4.0, 4))
792
+ uniq = []
793
+ for x in sorted(out):
794
+ if not uniq or abs(x - uniq[-1]) > 1e-6:
795
+ uniq.append(x)
796
+ return uniq[:64]
797
+
798
+
799
+ def _normalize_drums(user, mood):
800
+ defaults = _default_drums(mood)
801
+ if user is None:
802
+ return {t: {k: list(defaults[t][k]) for k in ("kick", "hat", "clap")} for t in TIER_IDS}
803
+ _require(isinstance(user, dict), "drums 必须是 {tier: {kick/hat/clap}} 字典")
804
+ out = {}
805
+ for tier in TIER_IDS:
806
+ spec = user.get(tier, {})
807
+ if spec is None:
808
+ spec = {}
809
+ _require(isinstance(spec, dict), "drums[%r] 必须是 dict" % (tier,))
810
+ extra = [k for k in spec if k not in ("kick", "hat", "clap")]
811
+ _require(not extra, "drums[%r] 出现未知键 %s(只支持 kick/hat/clap)"
812
+ % (tier, "/".join(str(k) for k in extra)))
813
+ out[tier] = {}
814
+ for k in ("kick", "hat", "clap"):
815
+ if k in spec:
816
+ out[tier][k] = _drum_positions(spec[k], k)
817
+ else:
818
+ out[tier][k] = list(defaults[tier][k])
819
+ return out
820
+
821
+
822
+ def _normalize_voices(user, mood):
823
+ merged = dict(_DEFAULT_VOICES)
824
+ merged.update(_MOOD_VOICES[mood])
825
+ if user is None:
826
+ return merged
827
+ _require(isinstance(user, dict), "voices 必须是 {pad/bass/arp/lead: 音色} 字典")
828
+ for slot, val in user.items():
829
+ slot_l = str(slot).strip().lower()
830
+ _require(slot_l in VOICE_SLOTS, "未知音色槽 %r;可选:%s" % (slot, "/".join(VOICE_SLOTS)))
831
+ v = str(val).strip().lower()
832
+ _require(v in VOICE_IDS, "未知音色 %r;可选:%s" % (val, "/".join(VOICE_IDS)))
833
+ merged[slot_l] = v
834
+ return merged
835
+
836
+
837
+ def _energy_from_user(energy_curve, duration):
838
+ """把 energy_curve 归一化成 (xs, ys);None 时返回 None。"""
839
+ if energy_curve is None:
840
+ return None
841
+ dur = float(duration)
842
+ points = energy_curve
843
+ if isinstance(points, dict):
844
+ points = points.get("points", points.get("curve"))
845
+ _require(points is not None, "energy_curve 缺少 points/curve")
846
+ if isinstance(points, np.ndarray):
847
+ points = points.tolist()
848
+ _require(isinstance(points, (list, tuple)) and len(points) > 0,
849
+ "energy_curve 必须是非空列表")
850
+ if all(isinstance(x, (int, float, np.integer, np.floating)) for x in points):
851
+ for x in points:
852
+ _require(math.isfinite(float(x)), "energy_curve 的值必须是有限数:%r" % (x,))
853
+ ys = np.array([_clip01(x) for x in points], dtype=np.float64)
854
+ if len(ys) == 1:
855
+ return np.array([0.0, dur]), np.array([ys[0], ys[0]])
856
+ xs = np.linspace(0.0, dur, len(ys))
857
+ return xs, ys
858
+ pairs = []
859
+ for p in points:
860
+ _require(isinstance(p, (list, tuple)) and len(p) >= 2,
861
+ "energy_curve 的每个点必须是 [秒, 0-1]:%r" % (p,))
862
+ try:
863
+ t = float(p[0])
864
+ e = float(p[1])
865
+ except (TypeError, ValueError):
866
+ raise ValueError("energy_curve 点必须是数字:%r" % (p,))
867
+ _require(math.isfinite(t) and math.isfinite(e), "energy_curve 点必须是有限数:%r" % (p,))
868
+ pairs.append((min(dur, max(0.0, t)), _clip01(e)))
869
+ pairs.sort()
870
+ uniq = []
871
+ for t, e in pairs:
872
+ if uniq and abs(t - uniq[-1][0]) < 1e-9:
873
+ uniq[-1] = (t, e)
874
+ else:
875
+ uniq.append((t, e))
876
+ if uniq[0][0] > 1e-9:
877
+ uniq.insert(0, (0.0, uniq[0][1]))
878
+ else:
879
+ uniq[0] = (0.0, uniq[0][1])
880
+ if uniq[-1][0] < dur - 1e-9:
881
+ uniq.append((dur, uniq[-1][1]))
882
+ else:
883
+ uniq[-1] = (dur, uniq[-1][1])
884
+ xs = np.array([x[0] for x in uniq], dtype=np.float64)
885
+ ys = np.array([x[1] for x in uniq], dtype=np.float64)
886
+ return xs, ys
887
+
888
+
889
+ def _seed_variants(seed, mood):
890
+ """seed 不是"加一点噪声",而是选择编排型:音区/织体/琶音/动机变奏/贝斯型。"""
891
+ s = int(seed)
892
+ pad_styles = ("sustain", "pulse", "swell")
893
+ pad_style = pad_styles[(s // 2) % 3]
894
+ pad_register = 12 if (s % 2 == 0) else 0
895
+ arp_patterns = ((0, 1, 2, 3), (0, 2, 1, 3), (0, 1, 2, 3, 2, 1),
896
+ (0, 2, 3, 1), (3, 2, 1, 0), (0, 1, 2, 1))
897
+ arp_pattern = arp_patterns[(s // 3) % len(arp_patterns)]
898
+ lead_variant = s % 4
899
+ bass_variant = (s // 4) % 2
900
+ if mood in ("calm", "reflective") and pad_style == "pulse":
901
+ pad_style = "swell"
902
+ return {
903
+ "pad_style": pad_style,
904
+ "pad_register": pad_register,
905
+ "arp_pattern": list(arp_pattern),
906
+ "lead_variant": lead_variant,
907
+ "bass_variant": bass_variant,
908
+ }
909
+
910
+
911
+ def _tier_for(energy):
912
+ e = float(energy)
913
+ if e < 0.40:
914
+ return "calm"
915
+ if e < 0.70:
916
+ return "body"
917
+ if e < 0.88:
918
+ return "drive"
919
+ return "climax"
920
+
921
+
922
+ def _layer_thresholds(mood):
923
+ g = float(_MOOD[mood]["gate"])
924
+ return {
925
+ "bass": 0.06 + g,
926
+ "kick": 0.28 + g,
927
+ "arp": 0.34 + g,
928
+ "hat": 0.40 + g,
929
+ "lead": 0.44 + g,
930
+ "clap": 0.54 + g,
931
+ }
932
+
933
+
934
+ def _layers_for_energy(energy, mood):
935
+ e = float(energy)
936
+ thr = _layer_thresholds(mood)
937
+ out = ["pad"]
938
+ for name in ("bass", "kick", "arp", "hat", "lead", "clap"):
939
+ if e >= thr[name]:
940
+ out.append(name)
941
+ return out
942
+
943
+
944
+ def _build_plan(duration, bpm, key, scale, mood, seed, sections, progressions,
945
+ motif, drums, voices, energy_curve):
946
+ try:
947
+ dur = float(duration)
948
+ except (TypeError, ValueError):
949
+ raise ValueError("duration 必须是数字,收到 %r" % (duration,))
950
+ _require(math.isfinite(dur) and dur > 0.0, "duration 必须是有限正数,收到 %r" % (duration,))
951
+ if bpm is None:
952
+ bpm = 96.0
953
+ if key is None:
954
+ key = "Dm"
955
+ if scale is None:
956
+ scale = "minor"
957
+ if mood is None:
958
+ mood = "steady"
959
+ try:
960
+ bpm_f = float(bpm)
961
+ except (TypeError, ValueError):
962
+ raise ValueError("bpm 必须是数字,收到 %r" % (bpm,))
963
+ _require(math.isfinite(bpm_f) and bpm_f > 0.0, "bpm 必须是有限正数,收到 %r" % (bpm,))
964
+ try:
965
+ seed_i = 0 if seed is None else abs(int(seed))
966
+ except (TypeError, ValueError):
967
+ raise ValueError("seed 必须是整数,收到 %r" % (seed,))
968
+ key_chroma, _minor_hint, key_disp, key_root = _parse_key(key)
969
+ scale_s = _normalize_scale(scale)
970
+ mood_s = _normalize_mood(mood)
971
+ scale_iv = _SCALE_STEPS[scale_s]
972
+
973
+ secs = _normalize_sections(dur, sections, mood_s, bpm_f)
974
+ progs = _normalize_progressions(progressions, scale_s, mood_s)
975
+ motif_n = _normalize_motif(motif, scale_s, mood_s)
976
+ drums_n = _normalize_drums(drums, mood_s)
977
+ voices_n = _normalize_voices(voices, mood_s)
978
+
979
+ # 段落能量是"阶梯保持 + 边界前短斜坡":段落中段必须维持本段能量,
980
+ # 否则长段落会在后半段被下一段的低能量拖下去(lift 反而不如 body 响)。
981
+ pts = [(0.0, secs[0]["energy"])]
982
+ for i, s in enumerate(secs):
983
+ s_start = float(s["start"])
984
+ s_end = float(s["end"])
985
+ pts.append((s_start, s["energy"]))
986
+ if i + 1 < len(secs):
987
+ ramp = max(0.05, min(0.50, (s_end - s_start) * 0.15))
988
+ hold = max(s_start, s_end - ramp)
989
+ pts.append((hold, s["energy"]))
990
+ else:
991
+ pts.append((dur, s["energy"]))
992
+ xs_list, ys_list = [], []
993
+ for x, y in pts:
994
+ x = min(dur, max(0.0, float(x)))
995
+ if xs_list and x - xs_list[-1] <= 1e-9:
996
+ ys_list[-1] = float(y)
997
+ else:
998
+ xs_list.append(x)
999
+ ys_list.append(float(y))
1000
+ if xs_list[0] > 1e-9:
1001
+ xs_list.insert(0, 0.0)
1002
+ ys_list.insert(0, ys_list[0])
1003
+ if xs_list[-1] < dur - 1e-9:
1004
+ xs_list.append(dur)
1005
+ ys_list.append(ys_list[-1])
1006
+ xs = np.array(xs_list, dtype=np.float64)
1007
+ ys = np.array(ys_list, dtype=np.float64)
1008
+ user_curve = _energy_from_user(energy_curve, dur)
1009
+ if user_curve is not None:
1010
+ xs, ys = user_curve
1011
+
1012
+ n = max(1, int(round(dur * SR)))
1013
+ beat_s = 60.0 / bpm_f
1014
+ bar_s = 4.0 * beat_s
1015
+ nbars = max(1, int(math.ceil(dur / bar_s - 1e-9)))
1016
+ centers = np.array([max(0.0, min(dur - 1e-9, (b + 0.5) * bar_s)) for b in range(nbars)],
1017
+ dtype=np.float64)
1018
+ # 一小节的能量取"小节内最响的时刻",窗口 **±0.20 小节**(约半小节)。
1019
+ #
1020
+ # 历史:① 只取中心点 -> 段落与小节网格错位时 climax 永远触发不了;
1021
+ # ② 窗口放宽到 ±0.42 几乎盖满整小节 -> 收尾小节被前一节的高潮污染
1022
+ # (实测 8 s / 96 BPM 的 outro 被判成 climax)。
1023
+ # ±0.20 是两头都照顾的取值:段切在中心附近偏差 ≤0.2 小节能被捕获,
1024
+ # 又不会让相邻段把尾巴拖进来。
1025
+ #
1026
+ # 末尾小节会被片长截断,此时实际跨度可能远小于整小节(8 s / 96 BPM 的最后一小节
1027
+ # 只剩 0.5 s),探测点会全挤在片尾同一个值上。跨度过小时退化为"中心点单点",
1028
+ # 语义更诚实:这一小节本来就没多少内容。
1029
+ _half = 0.20 * bar_s
1030
+ _deltas = np.linspace(-_half, _half, 5)
1031
+ _point = np.zeros((len(centers), len(_deltas)), dtype=np.float64)
1032
+ for _b in range(len(centers)):
1033
+ for _k, _d in enumerate(_deltas):
1034
+ _point[_b, _k] = min(dur - 1e-9, max(0.0, centers[_b] + _d))
1035
+ bar_energy = np.max(np.interp(_point.ravel(), xs, ys).reshape(_point.shape), axis=1)
1036
+ bar_tier = [_tier_for(e) for e in bar_energy]
1037
+
1038
+ chord_bars = int(_MOOD[mood_s]["chord_bars"])
1039
+ # 全局小节位置推进和声(而不是每个 tier 重置):12 s 里 body 的第二小节
1040
+ # 能接上 III,drive 小节能落在 V,短片的和声进行才不是 i–VI 两个和弦打转。
1041
+ bar_chord = []
1042
+ for b in range(nbars):
1043
+ p = progs[bar_tier[b]]
1044
+ bar_chord.append(p[(b // chord_bars) % len(p)])
1045
+
1046
+ variation = _seed_variants(seed_i, mood_s)
1047
+ variation.update({"fills": 0, "ghosts": 0, "skipped_lead": 0,
1048
+ "chord_changes": 0, "grace_notes": 0})
1049
+ for b in range(1, nbars):
1050
+ if bar_chord[b] != bar_chord[b - 1]:
1051
+ variation["chord_changes"] += 1
1052
+
1053
+ max_beat = max(float(m[0]) + float(m[2]) for m in motif_n)
1054
+ phrase_beats = max(8, int(math.ceil((max_beat - 1e-9) / 4.0)) * 4)
1055
+ key_name = "%s %s" % (key_root, _SCALE_CN[scale_s])
1056
+
1057
+ payload = {
1058
+ "version": 1,
1059
+ "duration": round(dur, 6),
1060
+ "bpm": round(bpm_f, 6),
1061
+ "key": key_disp,
1062
+ "scale": scale_s,
1063
+ "mood": mood_s,
1064
+ "seed": seed_i,
1065
+ "sections": [[s["name"], round(s["start"], 6), round(s["end"], 6),
1066
+ round(s["energy"], 6)] for s in secs],
1067
+ "progressions": progs,
1068
+ "motif": motif_n,
1069
+ "drums": drums_n,
1070
+ "voices": voices_n,
1071
+ "energy": [[round(float(x), 6), round(float(y), 6)] for x, y in zip(xs, ys)],
1072
+ "variation": variation,
1073
+ }
1074
+ fp = _sha_hex(json.dumps(payload, sort_keys=True, ensure_ascii=False,
1075
+ separators=(",", ":")))[:12]
1076
+
1077
+ return {
1078
+ "duration": dur,
1079
+ "n": n,
1080
+ "sr": SR,
1081
+ "bpm": bpm_f,
1082
+ "beat_s": beat_s,
1083
+ "bar_s": bar_s,
1084
+ "nbars": nbars,
1085
+ "key": key_disp,
1086
+ "key_name": key_name,
1087
+ "key_chroma": key_chroma,
1088
+ "scale": scale_s,
1089
+ "scale_iv": scale_iv,
1090
+ "mood": mood_s,
1091
+ "seed": seed_i,
1092
+ "sections": secs,
1093
+ "energy_x": xs,
1094
+ "energy_y": ys,
1095
+ "bar_centers": centers,
1096
+ "bar_energy": bar_energy,
1097
+ "bar_tier": bar_tier,
1098
+ "bar_chord": bar_chord,
1099
+ "progressions": progs,
1100
+ "motif": motif_n,
1101
+ "phrase_beats": phrase_beats,
1102
+ "drums": drums_n,
1103
+ "voices": voices_n,
1104
+ "variation": variation,
1105
+ "fingerprint": fp,
1106
+ }
1107
+
1108
+
1109
+ def _chord_midis(token, key_chroma, scale_iv, base=60, inversion=0,
1110
+ register=0, want7=False):
1111
+ d = _parse_degree(token)
1112
+ root = base + key_chroma + scale_iv[d["deg"] - 1] + d["acc"] + int(register)
1113
+ notes = [root + iv for iv in d["intervals"]]
1114
+ if want7 and len(notes) == 3:
1115
+ notes.append(root + 10)
1116
+ inv = int(inversion) % max(1, len(notes))
1117
+ for k in range(inv):
1118
+ notes[k] += 12
1119
+ notes.sort()
1120
+ while notes[0] < 40:
1121
+ notes = [x + 12 for x in notes]
1122
+ while notes[-1] > 88:
1123
+ notes = [x - 12 for x in notes]
1124
+ return notes
1125
+
1126
+
1127
+ def _bass_pattern(tier, variant):
1128
+ if tier == "calm":
1129
+ return [(0.0, 0, 3.6, 0.85)]
1130
+ if tier == "body":
1131
+ if variant:
1132
+ return [(0.0, 0, 1.2, 1.0), (1.75, 0, 0.8, 0.70), (2.5, 0, 1.3, 0.85)]
1133
+ return [(0.0, 0, 1.7, 1.0), (2.0, 0, 1.7, 0.78)]
1134
+ if tier == "drive":
1135
+ if variant:
1136
+ return [(0.0, 0, 0.42, 1.0), (0.75, 0, 0.42, 0.70),
1137
+ (1.5, 1, 0.42, 0.82), (2.0, 0, 0.42, 0.95),
1138
+ (2.75, 2, 0.42, 0.68), (3.5, 1, 0.42, 0.82)]
1139
+ return [(k * 0.5, (1 if k in (3, 7) else 0), 0.42,
1140
+ 0.72 + (0.24 if k % 2 == 0 else 0.0)) for k in range(8)]
1141
+ return [(k * 0.5, (2 if k in (3, 7) else 0), 0.40,
1142
+ 0.76 + (0.20 if k % 2 == 0 else 0.0)) for k in range(8)]
1143
+
1144
+
1145
+ _ARP_OCT = (0, 0, 12, 0, 0, 12, 0, 0)
1146
+ _LEAD_OCTS = (0, 0, 12, -12)
1147
+ _LEAD_SHIFTS = (
1148
+ (0, -1, 1, 0, -1),
1149
+ (0, 1, -1, 2, 0),
1150
+ (0, -2, 1, -1, 2),
1151
+ (0, 0, 1, -1, 0),
1152
+ )
1153
+
1154
+
1155
+ def _snap_midi(midi, chord_midis, max_move=2):
1156
+ pcs = {int(x) % 12 for x in chord_midis}
1157
+ if int(midi) % 12 in pcs:
1158
+ return int(midi)
1159
+ for delta in (1, -1, 2, -2):
1160
+ if abs(delta) <= max_move and (int(midi) + delta) % 12 in pcs:
1161
+ return int(midi) + delta
1162
+ return int(midi)
1163
+
1164
+
1165
+ def _arrange(plan):
1166
+ """把 plan 展开成逐事件的编排(不合成音频,describe 也复用)。"""
1167
+ arr = {"pad": [], "pad_hi": [], "bass": [], "arp": [], "lead": [],
1168
+ "kick": [], "hat": [], "clap": []}
1169
+ dur = plan["duration"]
1170
+ beat_s = plan["beat_s"]
1171
+ bar_s = plan["bar_s"]
1172
+ nbars = plan["nbars"]
1173
+ key_chroma = plan["key_chroma"]
1174
+ scale_iv = plan["scale_iv"]
1175
+ mood = plan["mood"]
1176
+ voices = plan["voices"]
1177
+ chord_bars = int(_MOOD[mood]["chord_bars"])
1178
+ pad_tr = _voice_trait(voices["pad"])
1179
+ bass_tr = _voice_trait(voices["bass"])
1180
+ arp_tr = _voice_trait(voices["arp"])
1181
+ lead_tr = _voice_trait(voices["lead"])
1182
+ var = plan["variation"]
1183
+ rng_h = _rng("human", plan["seed"])
1184
+ bright = float(_MOOD[mood]["bright"])
1185
+
1186
+ # ---- pad:sustain / pulse / swell 三种织体;seed 选缺省,voices 可按乐器覆盖
1187
+ pad_style = pad_tr["pad_style"] or var["pad_style"]
1188
+ var["pad_style_effective"] = pad_style
1189
+ b = 0
1190
+ while b < nbars:
1191
+ tier = plan["bar_tier"][b]
1192
+ token = plan["bar_chord"][b]
1193
+ if pad_style == "pulse":
1194
+ positions = (0.0, 1.5, 2.5) if tier in ("drive", "climax") else (0.0, 2.0)
1195
+ notes = _chord_midis(token, key_chroma, scale_iv, base=48 + pad_tr["pad_oct"],
1196
+ inversion=(plan["seed"] + b) % 3,
1197
+ register=var["pad_register"],
1198
+ want7=(mood in ("hopeful", "uplifting") and b % 4 == 3))
1199
+ if not notes:
1200
+ b += 1
1201
+ continue
1202
+ for pos in positions:
1203
+ t = b * bar_s + pos * beat_s
1204
+ if t >= dur:
1205
+ continue
1206
+ pans = _pan_spread(len(notes), 0.55)
1207
+ arr["pad"].append({
1208
+ "t": t, "dur": 0.95 * beat_s, "notes": list(notes), "pans": pans,
1209
+ "amp": 0.34 / math.sqrt(max(1, len(notes))),
1210
+ "attack": 0.02, "release": 0.20,
1211
+ "decay": pad_tr["pad_decay"] if pad_tr["pad_decay"] is not None else 0.55,
1212
+ "bright": bright, "detune": 3.0, "vib": pad_tr["vib"],
1213
+ })
1214
+ b += 1
1215
+ continue
1216
+ # sustain / swell:按 chord_bars 合并,但遇到 tier 或和弦变化就切开
1217
+ b2 = b + 1
1218
+ while (b2 < nbars and b2 < b + chord_bars
1219
+ and plan["bar_tier"][b2] == tier and plan["bar_chord"][b2] == token):
1220
+ b2 += 1
1221
+ start = b * bar_s
1222
+ end = min(dur, b2 * bar_s)
1223
+ if end - start < 0.05:
1224
+ b = b2
1225
+ continue
1226
+ # base=48 而不是 60:频谱实测发现 120–500 Hz 只剩 3.16%(20–120 Hz 占 50%、
1227
+ # 500–2000 Hz 占 37%),中间整段是空的 —— 听感就是"低频轰、中高频薄、没有厚度"。
1228
+ # 根因:pad 用 C4(261 Hz) 起,而 bass 被夹在 31–52(49–164 Hz),两者之间没有东西。
1229
+ # pad 降到 C3(131 Hz) 起正好填这一段,和弦性质不变(只是移位)。
1230
+ notes = _chord_midis(token, key_chroma, scale_iv, base=48 + pad_tr["pad_oct"],
1231
+ inversion=(plan["seed"] + b // 2) % 3,
1232
+ register=var["pad_register"],
1233
+ want7=(mood in ("hopeful", "uplifting") and b % 4 == 3))
1234
+ pans = _pan_spread(len(notes), 0.62)
1235
+ arr["pad"].append({
1236
+ "t": start, "dur": max(0.2, end - start + 0.30), "notes": list(notes),
1237
+ "pans": pans, "amp": 0.30 / math.sqrt(max(1, len(notes))),
1238
+ "attack": 0.50 if pad_style == "swell" else 0.16,
1239
+ "release": 0.65, "decay": pad_tr["pad_decay"], "bright": bright,
1240
+ "detune": 4.0, "vib": pad_tr["vib"],
1241
+ })
1242
+ b = b2
1243
+
1244
+ # ---- bass:每小节按 tier + seed 变体取型;根音来自当前和弦
1245
+ for bi in range(nbars):
1246
+ token = plan["bar_chord"][bi]
1247
+ d = _parse_degree(token)
1248
+ root = 36 + key_chroma + scale_iv[d["deg"] - 1] + d["acc"]
1249
+ while root < 31:
1250
+ root += 12
1251
+ while root > 52:
1252
+ root -= 12
1253
+ bass_style = bass_tr["bass_style"]
1254
+ if bass_style == "whole":
1255
+ pat = [(0.0, 0, 3.6, 0.85)]
1256
+ elif bass_style == "half":
1257
+ pat = [(0.0, 0, 1.7, 1.0), (2.0, 0, 1.7, 0.78)]
1258
+ elif bass_style == "pulse":
1259
+ pat = [(k * 0.5, (1 if k in (3, 7) else 0), 0.40,
1260
+ 0.70 + (0.22 if k % 2 == 0 else 0.0)) for k in range(8)]
1261
+ else:
1262
+ pat = _bass_pattern(plan["bar_tier"][bi], var["bass_variant"])
1263
+ for (beat, kind, dur_beats, vel) in pat:
1264
+ t = bi * bar_s + beat * beat_s
1265
+ if t >= dur:
1266
+ continue
1267
+ midi = root + (7 if kind == 1 else (12 if kind == 2 else 0))
1268
+ note_dur = min(dur - t, dur_beats * beat_s * 0.96)
1269
+ if note_dur < 0.04:
1270
+ continue
1271
+ arr["bass"].append({
1272
+ "t": t, "dur": note_dur, "midi": midi,
1273
+ "amp": 0.46 * vel, "pan": 0.0,
1274
+ "attack": 0.006, "release": 0.06,
1275
+ "decay": bass_tr["bass_decay"],
1276
+ "bright": bright * 0.5, "detune": 0.0, "vib": bass_tr["vib"],
1277
+ })
1278
+
1279
+ # ---- body 层:260–520 Hz 的"和弦骨架",填 120–250 Hz 与 500 Hz 之间那段空档 ----
1280
+ #
1281
+ # 频谱实测(12 s):20–120 Hz 49.6% / 120–250 Hz **2.5%** / 250–500 Hz 16.5% /
1282
+ # 500–2000 Hz 18.2% —— 中间这一段几乎为空,听感是"低频轰、中频没有身体"。
1283
+ # 根因:pad 根音在 C3(131 Hz) 但它的和弦音往上走(131/155/196 Hz),而 arp/lead
1284
+ # 都在 C5 以上;250–520 Hz 这个"有厚度的区间"没有持续音。
1285
+ # 解法不是再开一个合成器,而是让 pad 多叠一层中音区和弦(+12 半音后限幅),
1286
+ # 用同一套包络与 pan,成本近零、也不会引入新的调性信息。
1287
+ # 实测各层频段分布(12 s,改造前):pad 全在 250–2000 Hz(120–250 Hz 只有 0.7%)、
1288
+ # bass 96% 在 20–120 Hz、arp/lead 90%+ 在 500 Hz 以上 —— 所以 120–250 Hz 是挖空的。
1289
+ # 补法:把和弦**根音再降一个八度**叠一层(MIDI 40–55,即 82–196 Hz 的上半段),
1290
+ # 它的基频正好落在 120–250 Hz;用 pad 音色与包络,音量压到 pad 的 0.42 倍,
1291
+ # 只当作"厚度"而不是独立声部。
1292
+ for ev in arr["pad"]:
1293
+ low = [m - 12 for m in ev["notes"] if 40 <= m - 12 <= 57][:2]
1294
+ if low:
1295
+ # 音量:pad 的 1.15 倍。为什么这么大——实测 bass RMS 0.112 / kick 0.102,
1296
+ # 而 pad 只有 0.047、pad_hi 0.016,120–250 Hz 被 20–120 Hz 压到只剩 3%。
1297
+ # 低频本来就在能量上占优(等响曲线),要有"厚度"必须把中低频推上来。
1298
+ arr["pad_hi"].append(dict(ev, notes=low,
1299
+ amp=float(ev["amp"]) * 1.15,
1300
+ pans=_pan_spread(len(low), 0.30)))
1301
+
1302
+ # ---- arp:琶音型由 seed 选,16 分/8 分随 tier
1303
+ pattern = var["arp_pattern"]
1304
+ for bi in range(nbars):
1305
+ tier = plan["bar_tier"][bi]
1306
+ e = float(plan["bar_energy"][bi])
1307
+ if e < 0.31:
1308
+ continue
1309
+ step = arp_tr["arp_step"] or (0.25 if tier in ("drive", "climax") else 0.5)
1310
+ notes = _chord_midis(plan["bar_chord"][bi], key_chroma, scale_iv,
1311
+ base=72, inversion=0, register=0)
1312
+ if not notes:
1313
+ continue
1314
+ nsteps = int(round(4.0 / step))
1315
+ for k in range(nsteps):
1316
+ pos = k * step
1317
+ t = bi * bar_s + pos * beat_s
1318
+ if t >= dur:
1319
+ continue
1320
+ idx = pattern[k % len(pattern)] % len(notes)
1321
+ midi = notes[idx] + arp_tr["arp_oct"] + _ARP_OCT[(k + plan["seed"]) % len(_ARP_OCT)]
1322
+ while midi > 88:
1323
+ midi -= 12
1324
+ pan = 0.48 if (k % 2 == 0) else -0.48
1325
+ if bi % 2:
1326
+ pan = -pan
1327
+ arr["arp"].append({
1328
+ "t": max(0.0, t + (rng_h.rand() - 0.5) * 0.006), "dur": step * beat_s * 0.82,
1329
+ "midi": midi, "amp": (0.23 if k % 4 == 0 else 0.16),
1330
+ "pan": pan, "attack": 0.003, "release": 0.06,
1331
+ "decay": arp_tr["arp_decay"] if arp_tr["arp_decay"] is not None else 0.20,
1332
+ "bright": bright + 0.05, "detune": 0.0, "vib": arp_tr["vib"],
1333
+ })
1334
+
1335
+ # ---- lead:动机骨架 + seed 变奏 + 强拍吸附和弦音
1336
+ motif = plan["motif"]
1337
+ phrase_beats = plan["phrase_beats"]
1338
+ phrase_s = phrase_beats * beat_s
1339
+ nph = max(1, int(math.ceil(dur / phrase_s - 1e-9)))
1340
+ shifts = _LEAD_SHIFTS[var["lead_variant"] % len(_LEAD_SHIFTS)]
1341
+ for ph in range(nph):
1342
+ base_t = ph * phrase_s
1343
+ if base_t >= dur:
1344
+ break
1345
+ shift = shifts[(ph + plan["seed"]) % len(shifts)]
1346
+ octv = _LEAD_OCTS[(plan["seed"] // 5 + ph) % len(_LEAD_OCTS)]
1347
+ for mi, (beat, deg, ndur) in enumerate(motif):
1348
+ t = base_t + float(beat) * beat_s
1349
+ if t >= dur:
1350
+ continue
1351
+ d = _parse_degree(deg)
1352
+ step = d["deg"] - 1 + shift
1353
+ oct_extra = (step // 7) * 12
1354
+ deg_idx = step % 7
1355
+ midi = 60 + lead_tr["lead_oct"] + key_chroma + scale_iv[deg_idx] + d["acc"] + octv + oct_extra
1356
+ while midi < 52:
1357
+ midi += 12
1358
+ while midi > 88:
1359
+ midi -= 12
1360
+ bi = min(nbars - 1, int(t / bar_s))
1361
+ chord_notes = _chord_midis(plan["bar_chord"][bi], key_chroma, scale_iv,
1362
+ base=60, inversion=0, register=0)
1363
+ if abs(float(beat) - round(float(beat))) < 1e-6:
1364
+ midi = _snap_midi(midi, chord_notes)
1365
+ if (rng_h.rand() < 0.06 and abs(float(beat) - round(float(beat))) > 1e-6):
1366
+ var["skipped_lead"] += 1
1367
+ continue
1368
+ note_dur = min(dur - t, max(0.06, float(ndur) * beat_s * 0.92))
1369
+ vel = 0.74 + (0.12 if abs(float(beat) % 4.0) < 1e-6 else 0.0)
1370
+ vel += (rng_h.rand() - 0.5) * 0.10
1371
+ arr["lead"].append({
1372
+ "t": max(0.0, t + (rng_h.rand() - 0.5) * 0.008), "dur": note_dur, "midi": midi,
1373
+ "amp": 0.44 * vel, "pan": 0.16,
1374
+ "attack": 0.015, "release": 0.10,
1375
+ "decay": lead_tr["lead_decay"],
1376
+ "bright": bright + 0.10, "detune": 0.0, "vib": lead_tr["vib"],
1377
+ })
1378
+ # 乐句末加一个短倚音(seed 变体为奇数时更容易出现)
1379
+ if motif and (var["lead_variant"] % 2 == 1) and rng_h.rand() < 0.55:
1380
+ beat, deg, _ndur = motif[-1]
1381
+ t = base_t + float(beat) * beat_s - 0.075
1382
+ if t >= 0.0 and t + 0.07 < dur:
1383
+ d = _parse_degree(deg)
1384
+ step = d["deg"] - 1 + shift - 1
1385
+ oct_extra = (step // 7) * 12
1386
+ deg_idx = step % 7
1387
+ midi = 60 + lead_tr["lead_oct"] + key_chroma + scale_iv[deg_idx] + d["acc"] + octv + oct_extra
1388
+ while midi < 52:
1389
+ midi += 12
1390
+ while midi > 88:
1391
+ midi -= 12
1392
+ arr["lead"].append({
1393
+ "t": max(0.0, t), "dur": 0.07, "midi": midi,
1394
+ "amp": 0.22, "pan": 0.10, "attack": 0.004, "release": 0.02,
1395
+ "decay": 0.10, "bright": bright, "detune": 0.0, "vib": 0.0,
1396
+ })
1397
+ var["grace_notes"] += 1
1398
+
1399
+ # ---- drums:tier 型 + seed/小节位置的 ghost 与过门
1400
+ for bi in range(nbars):
1401
+ tier = plan["bar_tier"][bi]
1402
+ spec = plan["drums"][tier]
1403
+ start = bi * bar_s
1404
+ kick = list(spec["kick"])
1405
+ hat = list(spec["hat"])
1406
+ clap = list(spec["clap"])
1407
+ if tier != "calm" and kick and rng_h.rand() < 0.22:
1408
+ ghost = 3.75 if bi % 2 else 1.75
1409
+ if all(abs(ghost - x) > 1e-6 for x in kick):
1410
+ kick.append(ghost)
1411
+ var["ghosts"] += 1
1412
+ if tier != "calm" and hat and rng_h.rand() < 0.25:
1413
+ ghost = round(0.25 + 0.5 * int(rng_h.randint(0, 8)), 4)
1414
+ if all(abs(ghost - x) > 1e-6 for x in hat):
1415
+ hat.append(ghost)
1416
+ var["ghosts"] += 1
1417
+ fill = bool((bi + 1) % 4 == 0 and tier in ("drive", "climax")
1418
+ and plan["bar_energy"][bi] >= 0.55)
1419
+ for p in sorted(set(round(float(x), 4) for x in kick)):
1420
+ t = start + p * beat_s
1421
+ if t >= dur:
1422
+ continue
1423
+ vel = 1.0 if abs(p) < 1e-6 else (0.88 if abs(p - 2.0) < 1e-6
1424
+ else (0.92 if abs(p - round(p)) < 1e-6 else 0.70))
1425
+ vel *= 0.94 + 0.12 * rng_h.rand()
1426
+ arr["kick"].append({"t": max(0.0, t + (rng_h.rand() - 0.5) * 0.003), "vel": vel})
1427
+ for hi, p in enumerate(sorted(set(round(float(x), 4) for x in hat))):
1428
+ t = start + p * beat_s
1429
+ if t >= dur:
1430
+ continue
1431
+ vel = 0.82 if abs(p - round(p)) < 1e-6 else 0.58
1432
+ vel *= 0.92 + 0.16 * rng_h.rand()
1433
+ arr["hat"].append({"t": max(0.0, t + (rng_h.rand() - 0.5) * 0.005), "vel": vel,
1434
+ "open": bool(rng_h.rand() < 0.10)})
1435
+ for p in sorted(set(round(float(x), 4) for x in clap)):
1436
+ t = start + p * beat_s
1437
+ if t >= dur:
1438
+ continue
1439
+ arr["clap"].append({"t": t, "vel": 0.92 * (0.94 + 0.12 * rng_h.rand()),
1440
+ "fill": False})
1441
+ if fill:
1442
+ var["fills"] += 1
1443
+ for k, p in enumerate((3.0, 3.25, 3.5, 3.75)):
1444
+ t = start + p * beat_s
1445
+ if t >= dur:
1446
+ continue
1447
+ arr["clap"].append({"t": t, "vel": 0.48 + 0.12 * k, "fill": True})
1448
+ for key in ("pad", "bass", "arp", "lead", "kick", "hat", "clap"):
1449
+ arr[key].sort(key=lambda ev: ev["t"])
1450
+ return arr
1451
+
1452
+
1453
+ def _pan_spread(count, width):
1454
+ if count <= 1:
1455
+ return [0.0]
1456
+ return [-width + 2.0 * width * (i / (count - 1.0)) for i in range(count)]
1457
+
1458
+
1459
+ # ============================================================================
1460
+ # 渲染:逐事件合成 -> 层门限/侧链/乒乓延迟 -> 母带峰值归一化
1461
+ # ============================================================================
1462
+ def _pingpong(src, delay_s, feedback=0.33, taps=3):
1463
+ n = len(src)
1464
+ wet = np.zeros_like(src)
1465
+ d = max(1, int(round(float(delay_s) * SR)))
1466
+ for k in range(1, int(taps) + 1):
1467
+ sh = d * k
1468
+ if sh >= n:
1469
+ break
1470
+ g = float(feedback) ** k
1471
+ if k % 2:
1472
+ wet[sh:, 0] += src[:-sh, 1] * g
1473
+ wet[sh:, 1] += src[:-sh, 0] * g
1474
+ else:
1475
+ wet[sh:, 0] += src[:-sh, 0] * g
1476
+ wet[sh:, 1] += src[:-sh, 1] * g
1477
+ return wet
1478
+
1479
+
1480
+ def _sidechain(n, times, depth=0.40, tail=0.17):
1481
+ env = np.ones(n, dtype=np.float64)
1482
+ m = int(tail * SR)
1483
+ if m <= 2:
1484
+ return env
1485
+ k = np.arange(m, dtype=np.float64)
1486
+ shape = 1.0 - float(depth) * np.clip(k / (0.004 * SR), 0.0, 1.0) \
1487
+ * np.exp(-k / (0.055 * SR))
1488
+ for t in times:
1489
+ i = int(round(float(t) * SR))
1490
+ i = max(0, min(n - 1, i))
1491
+ j = min(n, i + m)
1492
+ env[i:j] = np.minimum(env[i:j], shape[:j - i])
1493
+ return env
1494
+
1495
+
1496
+ def _layer_gain(plan, layer, t_all):
1497
+ e = plan["bar_energy"]
1498
+ mood = plan["mood"]
1499
+ if layer == "pad":
1500
+ gate = np.ones_like(e)
1501
+ else:
1502
+ thr = _layer_thresholds(mood)[layer]
1503
+ gate = _smoothstep((e - thr) / 0.10)
1504
+ mult = 1.0
1505
+ if layer == "pad":
1506
+ mult *= float(_MOOD[mood]["pad_gain"])
1507
+ elif layer == "bass":
1508
+ mult *= float(_MOOD[mood]["bass_gain"])
1509
+ elif layer == "lead":
1510
+ mult *= _db(_MOOD[mood]["lead_db"])
1511
+ elif layer in ("kick", "hat", "clap"):
1512
+ mult *= _db(_MOOD[mood]["drum_db"])
1513
+ curve = mult * (0.30 + 0.70 * e) * gate
1514
+ return np.interp(t_all, plan["bar_centers"], curve,
1515
+ left=float(curve[0]), right=float(curve[-1]))
1516
+
1517
+
1518
+ def _render_stems(plan, arr):
1519
+ """逐层合成(未做母线混音),QA 可直接量各层 RMS;render 只负责总线。"""
1520
+ n = int(plan["n"])
1521
+ voices = plan["voices"]
1522
+ bright = float(_MOOD[plan["mood"]]["bright"])
1523
+ rng_phase = _rng("phase", plan["seed"])
1524
+ rng_noise = _rng("noise", plan["seed"])
1525
+
1526
+ pad = np.zeros((n, 2), dtype=np.float32)
1527
+ pad_hi = np.zeros((n, 2), dtype=np.float32)
1528
+ bass = np.zeros((n, 2), dtype=np.float32)
1529
+ arp = np.zeros((n, 2), dtype=np.float32)
1530
+ lead = np.zeros((n, 2), dtype=np.float32)
1531
+ kick = np.zeros((n, 2), dtype=np.float32)
1532
+ hat = np.zeros((n, 2), dtype=np.float32)
1533
+ clap = np.zeros((n, 2), dtype=np.float32)
1534
+
1535
+ for ev in arr["pad"]:
1536
+ for midi, pan in zip(ev["notes"], ev["pans"]):
1537
+ _render_note(pad, ev["t"], ev["dur"], midi, voices["pad"], ev["amp"], pan,
1538
+ ev["attack"], ev["release"], ev["decay"], ev["bright"],
1539
+ ev["detune"], rng_phase, ev.get("vib", 0.0))
1540
+ for ev in arr.get("pad_hi") or []:
1541
+ for midi, pan in zip(ev["notes"], ev["pans"]):
1542
+ _render_note(pad_hi, ev["t"], ev["dur"], midi, voices["pad"], ev["amp"], pan,
1543
+ ev["attack"], ev["release"], ev["decay"], ev["bright"],
1544
+ ev["detune"], rng_phase, ev.get("vib", 0.0))
1545
+ for ev in arr["bass"]:
1546
+ _render_note(bass, ev["t"], ev["dur"], ev["midi"], voices["bass"], ev["amp"],
1547
+ ev["pan"], ev["attack"], ev["release"], ev["decay"],
1548
+ ev["bright"], ev["detune"], rng_phase, ev.get("vib", 0.0))
1549
+ for ev in arr["arp"]:
1550
+ _render_note(arp, ev["t"], ev["dur"], ev["midi"], voices["arp"], ev["amp"],
1551
+ ev["pan"], ev["attack"], ev["release"], ev["decay"],
1552
+ ev["bright"], ev["detune"], rng_phase, ev.get("vib", 0.0))
1553
+ for ev in arr["lead"]:
1554
+ _render_note(lead, ev["t"], ev["dur"], ev["midi"], voices["lead"], ev["amp"],
1555
+ ev["pan"], ev["attack"], ev["release"], ev["decay"],
1556
+ ev["bright"], ev["detune"], rng_phase, ev.get("vib", 0.0))
1557
+ for ev in arr["kick"]:
1558
+ _render_kick(kick, ev["t"], ev["vel"], rng_noise)
1559
+ for ev in arr["hat"]:
1560
+ _render_hat(hat, ev["t"], ev["vel"], rng_noise, bool(ev["open"]))
1561
+ for ev in arr["clap"]:
1562
+ _render_clap(clap, ev["t"], ev["vel"], rng_noise, bool(ev["fill"]))
1563
+
1564
+ t_all = np.arange(n, dtype=np.float64) / float(SR)
1565
+ # pad_hi 与 pad 同层同门限(它就是 pad 的中音区叠层),只是一起吃 pad 的 layer gain
1566
+ for name, buf in (("pad", pad), ("pad", pad_hi), ("bass", bass), ("arp", arp),
1567
+ ("lead", lead), ("kick", kick), ("hat", hat), ("clap", clap)):
1568
+ if not buf.any():
1569
+ continue
1570
+ buf *= _layer_gain(plan, name, t_all)[:, None].astype(np.float32)
1571
+
1572
+ # 乐器性格:square 的 pad 带一点颤音(同一 voices 槽位在不同音色下织体不同)
1573
+ trem = float(_voice_trait(voices["pad"])["trem"])
1574
+ if trem > 1e-6 and pad.any():
1575
+ pad *= (1.0 - 0.5 * trem + 0.5 * trem
1576
+ * np.sin(2.0 * math.pi * 0.33 * t_all)[:, None]).astype(np.float32)
1577
+
1578
+ # 侧链:kick 位置压 pad/bass(鼓型一变,铺底也跟着有呼吸)
1579
+ sc = _sidechain(n, [ev["t"] for ev in arr["kick"]], depth=0.52)
1580
+ pad *= sc[:, None].astype(np.float32)
1581
+ pad_hi *= sc[:, None].astype(np.float32)
1582
+ bass *= (0.72 + 0.28 * sc)[:, None].astype(np.float32)
1583
+
1584
+ # 乒乓延迟:给 arp / lead 一点空间,长度不变(尾部截断)
1585
+ delay_s = min(0.45, 0.75 * plan["beat_s"])
1586
+ arp = arp + 0.20 * _pingpong(arp, delay_s, 0.30, 3)
1587
+ lead = lead + 0.30 * _pingpong(lead, delay_s * 1.5, 0.34, 3)
1588
+ return {"pad": pad, "pad_hi": pad_hi, "bass": bass, "arp": arp, "lead": lead,
1589
+ "kick": kick, "hat": hat, "clap": clap}
1590
+
1591
+
1592
+ def _render_audio(plan, arr):
1593
+ stems = _render_stems(plan, arr)
1594
+ pad, pad_hi, bass, arp = stems["pad"], stems["pad_hi"], stems["bass"], stems["arp"]
1595
+ lead, kick, hat, clap = stems["lead"], stems["kick"], stems["hat"], stems["clap"]
1596
+ n = int(plan["n"])
1597
+
1598
+ # 总线配比:pad/arp/lead 抬起来,bass/kick 收一点 —— 低频别把 500 Hz 以上全盖住
1599
+ # 总线配比:pad_hi(中低频厚度)给到 1.45,bass 收到 0.50、kick 0.62 ——
1600
+ # 不是为了"好听"随便调,是因为实测 20–120 Hz 占了 50%,把中低频整个盖住。
1601
+ mix = (pad.astype(np.float64) * 1.55 + pad_hi.astype(np.float64) * 1.45
1602
+ + bass.astype(np.float64) * 0.50
1603
+ + arp.astype(np.float64) * 1.05 + lead.astype(np.float64) * 1.20
1604
+ + kick.astype(np.float64) * 0.62 + hat.astype(np.float64) * 0.72
1605
+ + clap.astype(np.float64) * 0.62)
1606
+ mix -= mix.mean(axis=0, keepdims=True)
1607
+
1608
+ ni = min(n, int(0.020 * SR))
1609
+ if ni > 1:
1610
+ mix[:ni] *= (0.5 - 0.5 * np.cos(np.linspace(0.0, math.pi, ni)))[:, None]
1611
+ no = min(n, max(1, int(min(1.8, max(0.25, plan["duration"] * 0.22)) * SR)))
1612
+ if no > 1:
1613
+ mix[-no:] *= (0.5 + 0.5 * np.cos(np.linspace(0.0, math.pi, no)))[:, None]
1614
+
1615
+ peak = float(np.max(np.abs(mix))) if mix.size else 0.0
1616
+ if peak > 1e-12:
1617
+ mix *= TARGET_PEAK / peak
1618
+ mix = np.clip(mix, -1.0, 1.0)
1619
+ samples = np.ascontiguousarray(mix.astype(np.float32))
1620
+ if not np.all(np.isfinite(samples)):
1621
+ raise RuntimeError("musicgen: 渲染出了 NaN/Inf(内部数值错误,拒绝输出)")
1622
+ return samples
1623
+
1624
+ # ============================================================================
1625
+ # 报告 / 存盘
1626
+ # ============================================================================
1627
+ def _make_report(plan, arr, samples):
1628
+ n = int(plan["n"])
1629
+ peak = float(np.max(np.abs(samples))) if samples.size else 0.0
1630
+ peak_db = 20.0 * math.log10(peak) if peak > 0.0 else -120.0
1631
+ dc_max = float(np.max(np.abs(samples.mean(axis=0)))) if samples.size else 0.0
1632
+ e_bar = plan["bar_energy"]
1633
+ grid = np.linspace(0.0, plan["duration"], max(2, int(math.ceil(plan["duration"] * 8.0)) + 1))
1634
+ e_stat = np.interp(grid, plan["energy_x"], plan["energy_y"])
1635
+ secs = [{
1636
+ "name": s["name"],
1637
+ "start": round(float(s["start"]), 3),
1638
+ "end": round(float(s["end"]), 3),
1639
+ "energy": round(float(s["energy"]), 3),
1640
+ "tier": _tier_for(s["energy"]),
1641
+ "layers": _layers_for_energy(s["energy"], plan["mood"]),
1642
+ } for s in plan["sections"]]
1643
+ times = set()
1644
+ for s in plan["sections"]:
1645
+ times.add(round(float(s["start"]), 3))
1646
+ times.add(round(float(s["end"]), 3))
1647
+ for c in plan["bar_centers"]:
1648
+ times.add(round(float(c), 3))
1649
+ times.add(round(plan["duration"], 3))
1650
+ curve = [[t, round(float(np.interp(t, plan["energy_x"], plan["energy_y"])), 3)]
1651
+ for t in sorted(times)]
1652
+ variation = dict(plan["variation"])
1653
+ variation["arp_pattern"] = list(variation.get("arp_pattern", []))
1654
+ notes = {k: (sum(len(ev["notes"]) for ev in arr["pad"]) if k == "pad" else len(arr[k]))
1655
+ for k in ("pad", "bass", "arp", "lead", "kick", "hat", "clap")}
1656
+ rep = {
1657
+ "version": 1,
1658
+ "generator": "musicgen",
1659
+ "mode": "parametric",
1660
+ "duration": round(plan["duration"], 3),
1661
+ "actual_duration": round(n / float(SR), 6),
1662
+ "samples": n,
1663
+ "sr": SR,
1664
+ "bpm": plan["bpm"],
1665
+ "key": plan["key"],
1666
+ "key_name": plan["key_name"],
1667
+ "scale": plan["scale"],
1668
+ "mood": plan["mood"],
1669
+ "seed": plan["seed"],
1670
+ "bar_s": round(plan["bar_s"], 4),
1671
+ "beat_s": round(plan["beat_s"], 4),
1672
+ "bars": int(plan["nbars"]),
1673
+ "sections": secs,
1674
+ "energy": {
1675
+ "mean": round(float(np.mean(e_stat)), 3),
1676
+ "min": round(float(np.min(e_stat)), 3),
1677
+ "max": round(float(np.max(e_stat)), 3),
1678
+ "curve": curve,
1679
+ },
1680
+ "variation": variation,
1681
+ "fingerprint": plan["fingerprint"],
1682
+ "progressions": {k: list(v) for k, v in plan["progressions"].items()},
1683
+ "motif": [list(x) for x in plan["motif"]],
1684
+ "drums": {t: {k: list(v) for k, v in d.items()} for t, d in plan["drums"].items()},
1685
+ "voices": dict(plan["voices"]),
1686
+ "notes": notes,
1687
+ "peak": round(peak, 6),
1688
+ "peak_dbfs": round(peak_db, 2),
1689
+ "dc_max": round(dc_max, 8),
1690
+ "clipped": bool(np.any(np.abs(samples) > 1.0)) if samples.size else False,
1691
+ "finite": bool(np.all(np.isfinite(samples))) if samples.size else True,
1692
+ }
1693
+ return rep
1694
+
1695
+
1696
+ def save(path, samples) -> str:
1697
+ """写 24-bit PCM WAV;返回写出的路径字符串(stdlib wave 可直接读回)。"""
1698
+ p = Path(path)
1699
+ arr = np.asarray(samples)
1700
+ _require(arr.ndim in (1, 2), "save: 只支持 1D/2D 采样,收到 shape=%r" % (arr.shape,))
1701
+ if arr.ndim == 1:
1702
+ arr = np.stack([arr, arr], axis=1)
1703
+ _require(arr.shape[1] in (1, 2), "save: 只支持单/双声道,收到 shape=%r" % (arr.shape,))
1704
+ _require(arr.shape[0] > 0, "save: 采样为空,拒绝写 WAV")
1705
+ arr = np.asarray(arr, dtype=np.float64)
1706
+ if not np.all(np.isfinite(arr)):
1707
+ bad = int(arr.size - np.count_nonzero(np.isfinite(arr)))
1708
+ raise ValueError("save: 采样里有 %d 个 NaN/Inf(%s),拒绝写出损坏的 WAV" % (bad, p))
1709
+ pcm = np.rint(np.clip(arr, -1.0, 1.0) * 8388607.0).astype(np.int32)
1710
+ raw = pcm.astype("<i4").view(np.uint8).reshape(-1, 4)[:, :3]
1711
+ p.parent.mkdir(parents=True, exist_ok=True)
1712
+ with wave.open(str(p), "wb") as w:
1713
+ w.setnchannels(int(arr.shape[1]))
1714
+ w.setsampwidth(3)
1715
+ w.setframerate(int(SR))
1716
+ w.writeframes(raw.tobytes())
1717
+ return str(p)
1718
+
1719
+
1720
+ def render(duration, *, bpm=96.0, key="Dm", scale="minor", mood="steady",
1721
+ seed=0, sections=None, progressions=None, motif=None,
1722
+ drums=None, voices=None, energy_curve=None):
1723
+ """一次性接口:按参数现场编排并合成整片配乐,返回 (samples, report)。
1724
+
1725
+ samples 为 (N,2) float32,N = round(duration*48000),峰值 -6 dBFS;
1726
+ report 至少包含 bpm/key/scale/mood/seed/sections/energy/variation/fingerprint。
1727
+ 同参数两次运行逐采样一致(确定性来自 sha1 派生的 RandomState)。
1728
+ """
1729
+ plan = _build_plan(duration, bpm, key, scale, mood, seed, sections,
1730
+ progressions, motif, drums, voices, energy_curve)
1731
+ arr = _arrange(plan)
1732
+ samples = _render_audio(plan, arr)
1733
+ return samples, _make_report(plan, arr, samples)
1734
+
1735
+
1736
+ # ============================================================================
1737
+ # 预览 / CLI
1738
+ # ============================================================================
1739
+ def _fmt_positions(vals):
1740
+ if not vals:
1741
+ return "-"
1742
+ return ",".join("%g" % float(x) for x in vals)
1743
+
1744
+
1745
+ def describe(params) -> str:
1746
+ """不渲染音频,只把参数展开成可读的编排预览(CLI --describe 也用)。"""
1747
+ _require(isinstance(params, dict), "describe(params) 需要 dict,收到 %r" % (type(params).__name__,))
1748
+ duration = float(params.get("duration", 12.0))
1749
+ plan = _build_plan(
1750
+ duration,
1751
+ params.get("bpm", 96.0),
1752
+ params.get("key", "Dm"),
1753
+ params.get("scale", "minor"),
1754
+ params.get("mood", "steady"),
1755
+ params.get("seed", 0),
1756
+ params.get("sections"),
1757
+ params.get("progressions"),
1758
+ params.get("motif"),
1759
+ params.get("drums"),
1760
+ params.get("voices"),
1761
+ params.get("energy_curve"),
1762
+ )
1763
+ arr = _arrange(plan)
1764
+ lines = []
1765
+ lines.append("musicgen 参数编排预览")
1766
+ lines.append(" 时长 %.3f s / %g BPM / %s(%s)/ mood %s / seed %d / 指纹 %s"
1767
+ % (plan["duration"], plan["bpm"], plan["key"], plan["key_name"],
1768
+ plan["mood"], plan["seed"], plan["fingerprint"]))
1769
+ lines.append(" 小节 %.3f s(4 拍)/ 共 %d 小节 / 乐句 %d 拍 / 指纹变体 pad=%s 音区+%d 琶音=%s"
1770
+ % (plan["bar_s"], plan["nbars"], plan["phrase_beats"],
1771
+ plan["variation"].get("pad_style_effective", plan["variation"]["pad_style"]),
1772
+ plan["variation"]["pad_register"],
1773
+ ",".join(str(x) for x in plan["variation"]["arp_pattern"])))
1774
+ lines.append(" 段落 %d 个:" % len(plan["sections"]))
1775
+ for s in plan["sections"]:
1776
+ lines.append(" %-8s %7.2f–%7.2f s energy %.2f tier %-6s 层 %s"
1777
+ % (s["name"], s["start"], s["end"], s["energy"],
1778
+ _tier_for(s["energy"]), "/".join(_layers_for_energy(s["energy"], plan["mood"]))))
1779
+ lines.append(" 能量曲线:" + " ".join("%.2f→%.2f" % (x, y) for x, y in zip(plan["energy_x"], plan["energy_y"])))
1780
+ lines.append(" 和弦进行:")
1781
+ for tier in TIER_IDS:
1782
+ lines.append(" %-7s %s" % (tier, "–".join(plan["progressions"][tier])))
1783
+ lines.append(" 动机(%d 音,乐句 %d 拍):%s"
1784
+ % (len(plan["motif"]), plan["phrase_beats"],
1785
+ ";".join("%g %s %g" % (x[0], x[1], x[2]) for x in plan["motif"])))
1786
+ lines.append(" 鼓型:")
1787
+ for tier in TIER_IDS:
1788
+ d = plan["drums"][tier]
1789
+ lines.append(" %-7s kick[%s] hat[%s] clap[%s]"
1790
+ % (tier, _fmt_positions(d["kick"]), _fmt_positions(d["hat"]),
1791
+ _fmt_positions(d["clap"])))
1792
+ lines.append(" 音色:pad=%s / bass=%s / arp=%s / lead=%s"
1793
+ % (plan["voices"]["pad"], plan["voices"]["bass"],
1794
+ plan["voices"]["arp"], plan["voices"]["lead"]))
1795
+ lines.append(" 预计事件:" + " / ".join(
1796
+ "%s %d" % (k, (sum(len(ev["notes"]) for ev in arr["pad"]) if k == "pad" else len(arr[k])))
1797
+ for k in ("pad", "bass", "arp", "lead", "kick", "hat", "clap")))
1798
+ return "\n".join(lines)
1799
+
1800
+
1801
+ def _format_report(rep):
1802
+ lines = []
1803
+ lines.append("[musicgen] %.3f s / %g BPM / %s(%s)/ mood %s / seed %d"
1804
+ % (rep["duration"], rep["bpm"], rep["key"], rep["key_name"],
1805
+ rep["mood"], rep["seed"]))
1806
+ lines.append(" 小节 %.3f s × %d;峰值 %.2f dBFS;指纹 %s"
1807
+ % (rep["bar_s"], rep["bars"], rep["peak_dbfs"], rep["fingerprint"]))
1808
+ lines.append(" 段落:")
1809
+ for s in rep["sections"]:
1810
+ lines.append(" %-8s %7.2f–%7.2f s energy %.2f tier %-6s 层 %s"
1811
+ % (s["name"], s["start"], s["end"], s["energy"], s["tier"],
1812
+ "/".join(s["layers"])))
1813
+ lines.append(" 和声:" + " | ".join("%s %s" % (t, "–".join(rep["progressions"][t]))
1814
+ for t in TIER_IDS))
1815
+ lines.append(" 音色:pad=%s / bass=%s / arp=%s / lead=%s"
1816
+ % (rep["voices"]["pad"], rep["voices"]["bass"],
1817
+ rep["voices"]["arp"], rep["voices"]["lead"]))
1818
+ lines.append(" 事件:" + " / ".join("%s %d" % (k, rep["notes"][k]) for k in rep["notes"]))
1819
+ v = rep["variation"]
1820
+ lines.append(" 变奏:织体 %s / 音区 +%d / 过门 %d / ghost %d / 和弦变化 %d / 倚音 %d"
1821
+ % (v.get("pad_style_effective", v.get("pad_style")),
1822
+ int(v.get("pad_register", 0)),
1823
+ int(v.get("fills", 0)), int(v.get("ghosts", 0)),
1824
+ int(v.get("chord_changes", 0)), int(v.get("grace_notes", 0))))
1825
+ return "\n".join(lines)
1826
+
1827
+
1828
+ def _load_json_arg(value, name):
1829
+ s = str(value).strip()
1830
+ if s.startswith("{") or s.startswith("["):
1831
+ try:
1832
+ return json.loads(s)
1833
+ except json.JSONDecodeError as exc:
1834
+ raise ValueError("%s 不是合法 JSON:%s" % (name, exc))
1835
+ p = Path(s)
1836
+ if p.exists():
1837
+ try:
1838
+ return json.loads(p.read_text(encoding="utf-8"))
1839
+ except (OSError, json.JSONDecodeError) as exc:
1840
+ raise ValueError("%s 读取失败:%s" % (name, exc))
1841
+ raise ValueError("%s 既不是 JSON 也不是存在的文件:%r" % (name, value))
1842
+
1843
+
1844
+ def main(argv=None) -> int:
1845
+ _setup_stdio()
1846
+ ap = argparse.ArgumentParser(
1847
+ prog="musicgen.py",
1848
+ description="musicgen — 参数化配乐编排器(numpy,确定性,无采样)",
1849
+ )
1850
+ ap.add_argument("--selftest", action="store_true", help="跑自测(无网络,逐条 PASS/FAIL)")
1851
+ ap.add_argument("--describe", action="store_true", help="只打印编排预览,不渲染")
1852
+ ap.add_argument("--duration", type=float, default=12.0, help="片长(秒),默认 12")
1853
+ ap.add_argument("--bpm", type=float, default=96.0, help="BPM,默认 96")
1854
+ ap.add_argument("--key", default="Dm", help="调性,如 Dm / Am / C / F#m,默认 Dm")
1855
+ ap.add_argument("--scale", default="minor", help="调式:" + "/".join(SCALE_IDS))
1856
+ ap.add_argument("--mood", default="steady", help="情绪:" + "/".join(MOOD_IDS))
1857
+ ap.add_argument("--seed", type=int, default=0, help="种子,默认 0")
1858
+ ap.add_argument("--sections", default=None, help="段落 JSON / JSON 文件")
1859
+ ap.add_argument("--progressions", default=None, help="和声进行 JSON / JSON 文件")
1860
+ ap.add_argument("--motif", default=None, help="动机 JSON / JSON 文件")
1861
+ ap.add_argument("--drums", default=None, help="鼓型 JSON / JSON 文件")
1862
+ ap.add_argument("--voices", default=None, help="音色 JSON / JSON 文件")
1863
+ ap.add_argument("--energy-curve", default=None, help="能量曲线 JSON / JSON 文件")
1864
+ ap.add_argument("--out", default=None, help="输出 WAV(24-bit PCM)")
1865
+ ap.add_argument("--report", default=None, help="输出 report JSON")
1866
+ ap.add_argument("--json", action="store_true", help="stdout 打印完整 report JSON")
1867
+ args = ap.parse_args(argv)
1868
+
1869
+ if args.selftest:
1870
+ return selftest()
1871
+
1872
+ params = {
1873
+ "bpm": args.bpm, "key": args.key, "scale": args.scale, "mood": args.mood,
1874
+ "seed": args.seed,
1875
+ }
1876
+ for name in ("sections", "progressions", "motif", "drums", "voices"):
1877
+ val = getattr(args, name)
1878
+ if val is not None:
1879
+ params[name] = _load_json_arg(val, "--" + name)
1880
+ if args.energy_curve is not None:
1881
+ params["energy_curve"] = _load_json_arg(args.energy_curve, "--energy-curve")
1882
+
1883
+ if args.describe:
1884
+ print(describe(dict(params, duration=args.duration)))
1885
+ return 0
1886
+
1887
+ samples, rep = render(args.duration, **params)
1888
+ if args.out:
1889
+ print(" 已写 WAV:%s" % save(args.out, samples))
1890
+ if args.report:
1891
+ p = Path(args.report)
1892
+ p.parent.mkdir(parents=True, exist_ok=True)
1893
+ p.write_text(json.dumps(rep, ensure_ascii=False, indent=2), encoding="utf-8")
1894
+ print(" 已写报告:%s" % p)
1895
+ if args.json:
1896
+ print(json.dumps(rep, ensure_ascii=False, indent=2))
1897
+ else:
1898
+ print(_format_report(rep))
1899
+ return 0
1900
+
1901
+
1902
+ # ============================================================================
1903
+ # 自测
1904
+ # ============================================================================
1905
+ def _corr(a, b):
1906
+ x = np.asarray(a, dtype=np.float64).ravel()
1907
+ y = np.asarray(b, dtype=np.float64).ravel()
1908
+ if x.size != y.size or x.size < 2:
1909
+ return 0.0
1910
+ if float(np.std(x)) < 1e-12 or float(np.std(y)) < 1e-12:
1911
+ return 0.0
1912
+ return float(np.corrcoef(x, y)[0, 1])
1913
+
1914
+
1915
+ def _raises(fn):
1916
+ try:
1917
+ fn()
1918
+ except (ValueError, TypeError):
1919
+ return True
1920
+ return False
1921
+
1922
+
1923
+ def selftest() -> int:
1924
+ import tempfile
1925
+
1926
+ failures = []
1927
+ checks = 0
1928
+
1929
+ def ck(name, cond, detail=""):
1930
+ nonlocal checks
1931
+ checks += 1
1932
+ print("%s %-58s %s" % ("PASS" if cond else "FAIL", name, detail))
1933
+ if not cond:
1934
+ failures.append(name)
1935
+
1936
+ s, rep = render(12.0, bpm=96, key="Dm", scale="minor", mood="steady", seed=7)
1937
+ n_expected = int(round(12.0 * SR))
1938
+ ck("12 s 形状 (N,2) float32", s.shape == (n_expected, 2) and s.dtype == np.float32,
1939
+ "shape=%r dtype=%s" % (s.shape, s.dtype))
1940
+ ck("12 s 时长 ±1 采样", abs(len(s) - n_expected) <= 1, "N=%d 期望=%d" % (len(s), n_expected))
1941
+ ck("无 NaN/Inf", bool(np.all(np.isfinite(s))), "finite=%s" % bool(np.all(np.isfinite(s))))
1942
+ peak = float(np.max(np.abs(s)))
1943
+ ck("无削波 |x|<=1", peak <= 1.0, "peak=%.6f" % peak)
1944
+ ck("峰值 -6 dBFS 左右", abs(20.0 * math.log10(max(peak, 1e-12)) + 6.0) < 0.5,
1945
+ "peak=%.6f (%.2f dBFS)" % (peak, 20.0 * math.log10(max(peak, 1e-12))))
1946
+ ck("report 必需字段", all(k in rep for k in
1947
+ ("bpm", "key", "scale", "mood", "seed", "sections",
1948
+ "energy", "variation", "fingerprint")),
1949
+ "fingerprint=%s" % rep.get("fingerprint"))
1950
+
1951
+ s2, rep2 = render(12.0, bpm=96, key="Dm", scale="minor", mood="steady", seed=7)
1952
+ ck("同参数逐采样一致", bool(np.array_equal(s, s2)),
1953
+ "array_equal=%s fingerprint_same=%s" % (bool(np.array_equal(s, s2)),
1954
+ rep["fingerprint"] == rep2["fingerprint"]))
1955
+
1956
+ for dur in (1.0, 5.5, 30.0):
1957
+ x, _r = render(dur, bpm=96, key="Dm", scale="minor", mood="steady", seed=7)
1958
+ ck("时长边界 %g s" % dur, abs(len(x) - int(round(dur * SR))) <= 1 and bool(np.all(np.isfinite(x))),
1959
+ "N=%d" % len(x))
1960
+
1961
+ with tempfile.TemporaryDirectory(prefix="musicgen_") as td:
1962
+ p = Path(td) / "musicgen_selftest.wav"
1963
+ out = save(p, s)
1964
+ with wave.open(str(p), "rb") as w:
1965
+ ch, sw, fr, nf = w.getnchannels(), w.getsampwidth(), w.getframerate(), w.getnframes()
1966
+ ck("save 返回 str 且可被 wave 读回",
1967
+ isinstance(out, str) and ch == 2 and sw == 3 and fr == SR and nf == len(s),
1968
+ "ch=%d sw=%d sr=%d frames=%d" % (ch, sw, fr, nf))
1969
+ ck("save 拒绝 NaN", _raises(lambda: save(Path(td) / "bad.wav",
1970
+ np.array([[0.0, float("nan")]]))),
1971
+ "NaN 被拒")
1972
+
1973
+ ck("未知音色报错", _raises(lambda: render(2.0, voices={"pad": "noise"})), "ValueError")
1974
+ ck("未知情绪报错", _raises(lambda: render(2.0, mood="zzz")), "ValueError")
1975
+ ck("未知调式报错", _raises(lambda: render(2.0, scale="zzz")), "ValueError")
1976
+ ck("未知调性报错", _raises(lambda: render(2.0, key="H")), "ValueError")
1977
+ ck("坏级数报错", _raises(lambda: render(2.0, progressions={"body": ["i", "ZZ"]})),
1978
+ "ValueError")
1979
+ ck("空动机报错", _raises(lambda: render(2.0, motif=[])), "ValueError")
1980
+
1981
+ # 参数敏感性:换任一参数,波形相关性必须显著低于 0.95
1982
+ base, _b = render(12.0, bpm=96, key="Dm", scale="minor", mood="steady", seed=7)
1983
+ variants = [
1984
+ ("seed 7→8", render(12.0, bpm=96, key="Dm", scale="minor", mood="steady", seed=8)[0]),
1985
+ ("mood steady→driving", render(12.0, bpm=96, key="Dm", scale="minor",
1986
+ mood="driving", seed=7)[0]),
1987
+ ("voices 全换", render(12.0, bpm=96, key="Dm", scale="minor", mood="steady",
1988
+ seed=7, voices={"pad": "square", "bass": "square",
1989
+ "arp": "saw", "lead": "triangle"})[0]),
1990
+ ("progressions 换", render(12.0, bpm=96, key="Dm", scale="minor", mood="steady",
1991
+ seed=7, progressions={
1992
+ "calm": ["i", "III", "VII", "iv"],
1993
+ "body": ["i", "V", "VI", "iv"],
1994
+ "drive": ["i", "iv", "VII", "III"],
1995
+ "climax": ["i", "V", "VI", "III"]})[0]),
1996
+ ("drums 换", render(12.0, bpm=96, key="Dm", scale="minor", mood="steady", seed=7,
1997
+ drums={
1998
+ "calm": {"kick": [0.75], "hat": [1.25, 2.75], "clap": []},
1999
+ "body": {"kick": [0.5, 2.5], "hat": [0, 0.75, 1.5, 2.25, 3.0, 3.75],
2000
+ "clap": [2.0]},
2001
+ "drive": {"kick": [0, 0.5, 1.25, 2, 2.5, 3.25],
2002
+ "hat": [0, 0.375, 0.75, 1.125, 1.5, 1.875, 2.25,
2003
+ 2.625, 3.0, 3.375, 3.75],
2004
+ "clap": [0.5, 2.5]},
2005
+ "climax": {"kick": [0, 1, 2, 3],
2006
+ "hat": [0.125, 0.375, 0.625, 0.875, 1.125, 1.375,
2007
+ 1.625, 1.875, 2.125, 2.375, 2.625, 2.875,
2008
+ 3.125, 3.375, 3.625, 3.875],
2009
+ "clap": [1, 3]},
2010
+ })[0]),
2011
+ ]
2012
+ print(" 参数相关性(12 s,base=seed 7 steady;|r| < 0.95 为通过):")
2013
+ for label, other in variants:
2014
+ r = _corr(base, other)
2015
+ ck("参数生效:" + label, abs(r) < 0.95,
2016
+ "r=%+0.4f |r|=%.4f" % (r, abs(r)))
2017
+
2018
+ sec_s, sec_r = render(6.0, sections=[{"start": 0.0, "name": "A", "energy": 0.2},
2019
+ {"start": 3.0, "name": "B", "energy": 0.9}])
2020
+ ck("sections 生效", sec_r["sections"][0]["name"] == "A"
2021
+ and sec_r["sections"][-1]["name"] == "B"
2022
+ and abs(sec_r["sections"][-1]["energy"] - 0.9) < 1e-6,
2023
+ "names=%s" % [x["name"] for x in sec_r["sections"]])
2024
+ ec_s, ec_r = render(6.0, energy_curve=[0.1, 0.95])
2025
+ ck("energy_curve 生效", ec_r["energy"]["max"] > 0.8 and ec_r["energy"]["min"] < 0.2,
2026
+ "min=%.2f max=%.2f" % (ec_r["energy"]["min"], ec_r["energy"]["max"]))
2027
+
2028
+ txt = describe({"duration": 8.0, "bpm": 100.0, "key": "Am", "mood": "driving",
2029
+ "seed": 3})
2030
+ ck("describe 返回可读预览", isinstance(txt, str) and "100" in txt and "Am" in txt
2031
+ and "和弦进行" in txt, "%d 字符" % len(txt))
2032
+
2033
+ print("")
2034
+ if failures:
2035
+ print("SUMMARY: %d checks, %d FAILED -> %s" % (checks, len(failures),
2036
+ ", ".join(failures)))
2037
+ return 1
2038
+ print("SUMMARY: %d checks, all PASS" % checks)
2039
+ return 0
2040
+
2041
+
2042
+ if __name__ == "__main__":
2043
+ raise SystemExit(main())