@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.
- package/CHANGELOG.md +424 -0
- package/README.md +287 -327
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +4 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +1 -2
- package/skills/video-studio/assets/golden_frames.json +11 -8
- package/skills/video-studio/assets/scenes.py +67 -22
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/audio-variation.md +15 -11
- package/skills/video-studio/references/audio.md +36 -34
- package/skills/video-studio/references/change-impact.md +3 -3
- package/skills/video-studio/references/frame-qa.md +2 -2
- package/skills/video-studio/references/proposal.md +177 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/scene-grammar.md +28 -23
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +95 -257
- package/skills/video-studio/references/v08-quality.md +8 -8
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/references/word-timeline.md +2 -2
- package/skills/video-studio/scripts/designkit.py +2120 -0
- package/skills/video-studio/scripts/filmcheck.py +7 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +67 -7
- package/skills/video-studio/scripts/musicgen.py +2043 -0
- package/skills/video-studio/scripts/preflight.py +10 -3
- package/skills/video-studio/scripts/previewkit.py +849 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +45 -10
- package/skills/video-studio/scripts/qavmaf.py +77 -11
- package/skills/video-studio/scripts/render.py +472 -154
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +658 -131
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +265 -741
- package/skills/video-studio/scripts/voicekit.py +502 -380
- package/skills/video-studio/scripts/webengine.py +15 -14
- package/tools/audio_similarity.py +57 -30
- package/tools/ci_check.py +7 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +521 -168
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- 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())
|