hyper-animator-codex 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/bin/hyper-animator-codex.mjs +16 -28
- package/lib/install-options.mjs +44 -0
- package/lib/install-skill.mjs +16 -0
- package/lib/minimax-config.mjs +162 -0
- package/package.json +1 -1
- package/skills/hyper-animator-codex/SKILL.md +16 -8
- package/skills/hyper-animator-codex/references/beat-sync-workflow.md +51 -0
- package/skills/hyper-animator-codex/references/minimax-music-workflow.md +77 -0
- package/skills/hyper-animator-codex/scripts/analyze_music_beats.py +78 -0
- package/skills/hyper-animator-codex/scripts/generate_minimax_music.mjs +346 -0
- package/skills/hyper-animator-codex/scripts/minimax_runtime_config.mjs +113 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/README.md +13 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/beat_detector/__init__.py +33 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/beat_detector/analyzer.py +129 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/beat_detector/beat.py +133 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/beat_detector/cli.py +74 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/beat_detector/errors.py +49 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/beat_detector/structure.py +171 -0
- package/skills/hyper-animator-codex/vendor/music-beat-detector/beat_detector/utils.py +73 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MINIMAX_MODEL,
|
|
8
|
+
readMinimaxRuntimeConfig,
|
|
9
|
+
redactMinimaxConfig,
|
|
10
|
+
validateMinimaxConfig,
|
|
11
|
+
} from "./minimax_runtime_config.mjs";
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = "https://api.minimaxi.com/v1/music_generation";
|
|
14
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const skillRoot = dirname(scriptDir);
|
|
16
|
+
|
|
17
|
+
function requireValue(args, index, flag) {
|
|
18
|
+
const value = args[index + 1];
|
|
19
|
+
|
|
20
|
+
if (!value || value.startsWith("--")) {
|
|
21
|
+
throw new Error(`${flag} requires a value`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(args) {
|
|
28
|
+
const parsed = {
|
|
29
|
+
prompt: undefined,
|
|
30
|
+
lyrics: undefined,
|
|
31
|
+
lyrics_optimizer: false,
|
|
32
|
+
is_instrumental: true,
|
|
33
|
+
output_dir: join(process.cwd(), "hyper-animator-output", "music"),
|
|
34
|
+
output_format: "hex",
|
|
35
|
+
audio_format: "mp3",
|
|
36
|
+
sample_rate: 44100,
|
|
37
|
+
bitrate: 256000,
|
|
38
|
+
model: undefined,
|
|
39
|
+
config_path: undefined,
|
|
40
|
+
dry_run: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
44
|
+
const arg = args[index];
|
|
45
|
+
|
|
46
|
+
if (arg === "--prompt") {
|
|
47
|
+
parsed.prompt = requireValue(args, index, arg);
|
|
48
|
+
index += 1;
|
|
49
|
+
} else if (arg === "--lyrics") {
|
|
50
|
+
parsed.lyrics = requireValue(args, index, arg);
|
|
51
|
+
index += 1;
|
|
52
|
+
} else if (arg === "--lyrics-optimizer") {
|
|
53
|
+
parsed.lyrics_optimizer = true;
|
|
54
|
+
} else if (arg === "--instrumental") {
|
|
55
|
+
parsed.is_instrumental = true;
|
|
56
|
+
} else if (arg === "--vocal") {
|
|
57
|
+
parsed.is_instrumental = false;
|
|
58
|
+
} else if (arg === "--output-dir") {
|
|
59
|
+
parsed.output_dir = requireValue(args, index, arg);
|
|
60
|
+
index += 1;
|
|
61
|
+
} else if (arg === "--output-format") {
|
|
62
|
+
parsed.output_format = requireValue(args, index, arg);
|
|
63
|
+
index += 1;
|
|
64
|
+
} else if (arg === "--format") {
|
|
65
|
+
parsed.audio_format = requireValue(args, index, arg);
|
|
66
|
+
index += 1;
|
|
67
|
+
} else if (arg === "--sample-rate") {
|
|
68
|
+
parsed.sample_rate = Number.parseInt(requireValue(args, index, arg), 10);
|
|
69
|
+
index += 1;
|
|
70
|
+
} else if (arg === "--bitrate") {
|
|
71
|
+
parsed.bitrate = Number.parseInt(requireValue(args, index, arg), 10);
|
|
72
|
+
index += 1;
|
|
73
|
+
} else if (arg === "--model") {
|
|
74
|
+
parsed.model = requireValue(args, index, arg);
|
|
75
|
+
index += 1;
|
|
76
|
+
} else if (arg === "--config") {
|
|
77
|
+
parsed.config_path = requireValue(args, index, arg);
|
|
78
|
+
index += 1;
|
|
79
|
+
} else if (arg === "--dry-run") {
|
|
80
|
+
parsed.dry_run = true;
|
|
81
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
82
|
+
parsed.help = true;
|
|
83
|
+
} else {
|
|
84
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printHelp() {
|
|
92
|
+
console.log(`Usage:
|
|
93
|
+
node scripts/generate_minimax_music.mjs --prompt <text> [options]
|
|
94
|
+
|
|
95
|
+
Options:
|
|
96
|
+
--prompt <text> Music style, mood, and scene prompt
|
|
97
|
+
--lyrics <text> Lyrics for vocal generation
|
|
98
|
+
--lyrics-optimizer Let MiniMax generate lyrics from prompt for vocal generation
|
|
99
|
+
--instrumental Generate instrumental music, default
|
|
100
|
+
--vocal Generate vocal music; requires --lyrics or --lyrics-optimizer
|
|
101
|
+
--output-dir <dir> Directory for generated audio and metadata
|
|
102
|
+
--output-format <hex|url> MiniMax response format, default hex
|
|
103
|
+
--format <mp3|wav|pcm> Audio encoding format, default mp3
|
|
104
|
+
--sample-rate <rate> 16000, 24000, 32000, or 44100; default 44100
|
|
105
|
+
--bitrate <bits> 32000, 64000, 128000, or 256000; default 256000
|
|
106
|
+
--model <model> music-2.6 or music-2.6-free
|
|
107
|
+
--config <file> Explicit MiniMax config JSON
|
|
108
|
+
--dry-run Print redacted request without contacting MiniMax
|
|
109
|
+
`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateOptions(options) {
|
|
113
|
+
if (!options.prompt || options.prompt.trim().length === 0) {
|
|
114
|
+
throw new Error("--prompt is required");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (options.prompt.length > 2000) {
|
|
118
|
+
throw new Error("--prompt must be 2000 characters or fewer");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!["hex", "url"].includes(options.output_format)) {
|
|
122
|
+
throw new Error("--output-format must be hex or url");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!["mp3", "wav", "pcm"].includes(options.audio_format)) {
|
|
126
|
+
throw new Error("--format must be mp3, wav, or pcm");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (![16000, 24000, 32000, 44100].includes(options.sample_rate)) {
|
|
130
|
+
throw new Error("--sample-rate must be 16000, 24000, 32000, or 44100");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (![32000, 64000, 128000, 256000].includes(options.bitrate)) {
|
|
134
|
+
throw new Error("--bitrate must be 32000, 64000, 128000, or 256000");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!options.is_instrumental && !options.lyrics && !options.lyrics_optimizer) {
|
|
138
|
+
throw new Error("Vocal MiniMax generation requires --lyrics or --lyrics-optimizer");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (options.lyrics && options.lyrics.length > 3500) {
|
|
142
|
+
throw new Error("--lyrics must be 3500 characters or fewer");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildRequest(options, config) {
|
|
147
|
+
const model = options.model || config.model || DEFAULT_MINIMAX_MODEL;
|
|
148
|
+
const validatedConfig = validateMinimaxConfig({
|
|
149
|
+
...config,
|
|
150
|
+
model,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const request = {
|
|
154
|
+
model: validatedConfig.model,
|
|
155
|
+
prompt: options.prompt,
|
|
156
|
+
stream: false,
|
|
157
|
+
output_format: options.output_format,
|
|
158
|
+
audio_setting: {
|
|
159
|
+
sample_rate: options.sample_rate,
|
|
160
|
+
bitrate: options.bitrate,
|
|
161
|
+
format: options.audio_format,
|
|
162
|
+
},
|
|
163
|
+
is_instrumental: options.is_instrumental,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (options.lyrics) {
|
|
167
|
+
request.lyrics = options.lyrics;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (options.lyrics_optimizer) {
|
|
171
|
+
request.lyrics_optimizer = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return request;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function safeFileStem(prompt) {
|
|
178
|
+
const stem = prompt
|
|
179
|
+
.toLowerCase()
|
|
180
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
181
|
+
.replace(/^-+|-+$/g, "")
|
|
182
|
+
.slice(0, 48);
|
|
183
|
+
|
|
184
|
+
return stem || "minimax-music";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function downloadUrl(url) {
|
|
188
|
+
const response = await fetch(url);
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new Error(`MiniMax audio URL download failed with HTTP ${response.status}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return Buffer.from(await response.arrayBuffer());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function decodeHexAudio(value) {
|
|
198
|
+
if (!/^[0-9a-fA-F]+$/.test(value) || value.length % 2 !== 0) {
|
|
199
|
+
throw new Error("MiniMax response audio is not valid hex data");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return Buffer.from(value, "hex");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function callMiniMax(config, request) {
|
|
206
|
+
const response = await fetch(ENDPOINT, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify(request),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const text = await response.text();
|
|
216
|
+
let json;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
json = JSON.parse(text);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw new Error(`MiniMax returned non-JSON response with HTTP ${response.status}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
const message = json.base_resp?.status_msg || `HTTP ${response.status}`;
|
|
226
|
+
throw new Error(`MiniMax request failed: ${message}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const statusCode = json.base_resp?.status_code;
|
|
230
|
+
if (statusCode !== 0) {
|
|
231
|
+
const message = json.base_resp?.status_msg || "unknown MiniMax error";
|
|
232
|
+
throw new Error(`MiniMax request failed with status_code ${statusCode}: ${message}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (json.data?.status !== 2) {
|
|
236
|
+
throw new Error(`MiniMax generation is not complete; data.status is ${json.data?.status}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!json.data?.audio) {
|
|
240
|
+
throw new Error("MiniMax response did not include data.audio");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return json;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
|
|
247
|
+
await mkdir(outputDir, { recursive: true });
|
|
248
|
+
|
|
249
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
250
|
+
const stem = `${safeFileStem(prompt)}-${timestamp}`;
|
|
251
|
+
const extension = request.audio_setting.format === "pcm" ? "pcm" : request.audio_setting.format;
|
|
252
|
+
const audioPath = join(outputDir, `${stem}.${extension}`);
|
|
253
|
+
const metadataPath = join(outputDir, `${stem}.minimax.json`);
|
|
254
|
+
const audioValue = responseJson.data.audio;
|
|
255
|
+
const audioBuffer = request.output_format === "url"
|
|
256
|
+
? await downloadUrl(audioValue)
|
|
257
|
+
: decodeHexAudio(audioValue);
|
|
258
|
+
|
|
259
|
+
await writeFile(audioPath, audioBuffer);
|
|
260
|
+
await writeFile(
|
|
261
|
+
metadataPath,
|
|
262
|
+
`${JSON.stringify({
|
|
263
|
+
provider: "minimax",
|
|
264
|
+
endpoint: ENDPOINT,
|
|
265
|
+
request,
|
|
266
|
+
response: {
|
|
267
|
+
base_resp: responseJson.base_resp,
|
|
268
|
+
data: {
|
|
269
|
+
status: responseJson.data.status,
|
|
270
|
+
},
|
|
271
|
+
extra_info: responseJson.extra_info,
|
|
272
|
+
trace_id: responseJson.trace_id,
|
|
273
|
+
},
|
|
274
|
+
audio_path: audioPath,
|
|
275
|
+
}, null, 2)}\n`,
|
|
276
|
+
"utf8",
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
audioPath,
|
|
281
|
+
metadataPath,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function main() {
|
|
286
|
+
const options = parseArgs(process.argv.slice(2));
|
|
287
|
+
|
|
288
|
+
if (options.help) {
|
|
289
|
+
printHelp();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
validateOptions(options);
|
|
294
|
+
|
|
295
|
+
const runtime = await readMinimaxRuntimeConfig({
|
|
296
|
+
skillRoot,
|
|
297
|
+
env: process.env,
|
|
298
|
+
configPath: options.config_path,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!runtime) {
|
|
302
|
+
throw new Error("MiniMax config not found. Run hyper-animator-codex install with --minimax-api-key and --minimax-group-id, or set MINIMAX_API_KEY and MINIMAX_GROUP_ID.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const request = buildRequest(options, runtime.config);
|
|
306
|
+
const redactedConfig = redactMinimaxConfig(runtime.config);
|
|
307
|
+
|
|
308
|
+
if (options.dry_run) {
|
|
309
|
+
console.log(JSON.stringify({
|
|
310
|
+
ok: true,
|
|
311
|
+
dry_run: true,
|
|
312
|
+
provider: "minimax",
|
|
313
|
+
endpoint: ENDPOINT,
|
|
314
|
+
config: {
|
|
315
|
+
source: runtime.source,
|
|
316
|
+
path: runtime.configPath,
|
|
317
|
+
redacted: redactedConfig,
|
|
318
|
+
},
|
|
319
|
+
request,
|
|
320
|
+
output_dir: options.output_dir,
|
|
321
|
+
}, null, 2));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const responseJson = await callMiniMax(runtime.config, request);
|
|
326
|
+
const output = await writeAudioOutput({
|
|
327
|
+
responseJson,
|
|
328
|
+
request,
|
|
329
|
+
outputDir: options.output_dir,
|
|
330
|
+
prompt: options.prompt,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
console.log(JSON.stringify({
|
|
334
|
+
ok: true,
|
|
335
|
+
provider: "minimax",
|
|
336
|
+
model: request.model,
|
|
337
|
+
output_path: output.audioPath,
|
|
338
|
+
metadata_path: output.metadataPath,
|
|
339
|
+
beat_analysis_command: `python3 scripts/analyze_music_beats.py ${output.audioPath} -o ${join(options.output_dir, "beat-map.json")} --fps 60 --pretty`,
|
|
340
|
+
}, null, 2));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
main().catch((error) => {
|
|
344
|
+
console.error(`Error: ${error.message}`);
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_MINIMAX_MODEL = "music-2.6-free";
|
|
5
|
+
export const TEXT_MUSIC_MODELS = new Set(["music-2.6", "music-2.6-free"]);
|
|
6
|
+
|
|
7
|
+
async function pathExists(path) {
|
|
8
|
+
try {
|
|
9
|
+
await stat(path);
|
|
10
|
+
return true;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error && error.code === "ENOENT") {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cleanString(value) {
|
|
20
|
+
if (typeof value !== "string") {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalize(input = {}) {
|
|
29
|
+
return {
|
|
30
|
+
api_key: cleanString(input.api_key),
|
|
31
|
+
group_id: cleanString(input.group_id),
|
|
32
|
+
model: cleanString(input.model),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasValues(input = {}) {
|
|
37
|
+
return Boolean(cleanString(input.api_key) || cleanString(input.group_id) || cleanString(input.model));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function redactMinimaxConfig(config = {}) {
|
|
41
|
+
const normalized = normalize(config);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
api_key: normalized.api_key ? "[redacted]" : undefined,
|
|
45
|
+
group_id: normalized.group_id,
|
|
46
|
+
model: normalized.model || DEFAULT_MINIMAX_MODEL,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateMinimaxConfig(config = {}) {
|
|
51
|
+
const normalized = normalize(config);
|
|
52
|
+
|
|
53
|
+
if (!normalized.api_key) {
|
|
54
|
+
throw new Error("MiniMax api_key is required");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!normalized.group_id) {
|
|
58
|
+
throw new Error("MiniMax group_id is required");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const model = normalized.model || DEFAULT_MINIMAX_MODEL;
|
|
62
|
+
|
|
63
|
+
if (!TEXT_MUSIC_MODELS.has(model)) {
|
|
64
|
+
throw new Error(`Unsupported MiniMax text music model: ${model}. Use music-2.6 or music-2.6-free.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
api_key: normalized.api_key,
|
|
69
|
+
group_id: normalized.group_id,
|
|
70
|
+
model,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readJson(path) {
|
|
75
|
+
const raw = await readFile(path, "utf8");
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
|
|
78
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
79
|
+
throw new Error(`MiniMax config file must be a JSON object: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function readMinimaxRuntimeConfig({ skillRoot, env = process.env, configPath } = {}) {
|
|
86
|
+
const explicitConfigPath = cleanString(configPath);
|
|
87
|
+
const defaultConfigPath = skillRoot ? join(skillRoot, "config", "minimax.json") : undefined;
|
|
88
|
+
const filePath = explicitConfigPath || defaultConfigPath;
|
|
89
|
+
|
|
90
|
+
if (filePath && await pathExists(filePath)) {
|
|
91
|
+
return {
|
|
92
|
+
source: "file",
|
|
93
|
+
configPath: filePath,
|
|
94
|
+
config: validateMinimaxConfig(await readJson(filePath)),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const envConfig = normalize({
|
|
99
|
+
api_key: env.MINIMAX_API_KEY,
|
|
100
|
+
group_id: env.MINIMAX_GROUP_ID,
|
|
101
|
+
model: env.MINIMAX_MODEL,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!hasValues(envConfig)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
source: "environment",
|
|
110
|
+
configPath: null,
|
|
111
|
+
config: validateMinimaxConfig(envConfig),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Vendored music-beat-detector
|
|
2
|
+
|
|
3
|
+
Trimmed source from `git@github.com:realpkuasule/music-beat-detector.git`, commit `29b081fbe3bb38f0fa8cb569fa3150d7cfdb18cb`.
|
|
4
|
+
|
|
5
|
+
Only `beat_detector/` is vendored. Sample media, ffmpeg archives, development contracts, and upstream tests are intentionally omitted from the npm package.
|
|
6
|
+
|
|
7
|
+
Optional runtime dependencies:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python3 -m pip install librosa pydub numpy click
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
MP3/M4A/AAC/WMA files require system `ffmpeg`; WAV/FLAC/OGG are preferred for fewer dependencies.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Music Beat Detector - 音乐自动打点工具"""
|
|
2
|
+
|
|
3
|
+
from .analyzer import analyze, AnalysisResult, MetaInfo
|
|
4
|
+
from .beat import Beat, BeatResult, EnergyLevel
|
|
5
|
+
from .structure import Segment, EnergyPeak, SilenceRegion, StructureResult
|
|
6
|
+
from .errors import BeatDetectorError, FileNotFoundError, UnsupportedFormatError, AnalysisError, FFmpegRequiredError
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
# 主要 API
|
|
12
|
+
'analyze',
|
|
13
|
+
'AnalysisResult',
|
|
14
|
+
'MetaInfo',
|
|
15
|
+
|
|
16
|
+
# 节拍检测
|
|
17
|
+
'Beat',
|
|
18
|
+
'BeatResult',
|
|
19
|
+
'EnergyLevel',
|
|
20
|
+
|
|
21
|
+
# 结构检测
|
|
22
|
+
'StructureResult',
|
|
23
|
+
'Segment',
|
|
24
|
+
'EnergyPeak',
|
|
25
|
+
'SilenceRegion',
|
|
26
|
+
|
|
27
|
+
# 异常
|
|
28
|
+
'BeatDetectorError',
|
|
29
|
+
'FileNotFoundError',
|
|
30
|
+
'UnsupportedFormatError',
|
|
31
|
+
'FFmpegRequiredError',
|
|
32
|
+
'AnalysisError',
|
|
33
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""分析器模块"""
|
|
2
|
+
from dataclasses import dataclass, field, asdict
|
|
3
|
+
from typing import List, Callable, Optional, Any
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .beat import Beat, BeatResult, detect_beats
|
|
9
|
+
from .structure import StructureResult, detect_structure
|
|
10
|
+
from .utils import load_audio, setup_logging
|
|
11
|
+
from .errors import (
|
|
12
|
+
FileNotFoundError as BeatDetectorFileNotFoundError,
|
|
13
|
+
FFmpegRequiredError,
|
|
14
|
+
UnsupportedFormatError
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MetaInfo:
|
|
20
|
+
"""元信息"""
|
|
21
|
+
file: str
|
|
22
|
+
duration_ms: int
|
|
23
|
+
sample_rate: int
|
|
24
|
+
bpm: float
|
|
25
|
+
time_signature: str = "4/4"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AnalysisResult:
|
|
30
|
+
"""完整分析结果"""
|
|
31
|
+
meta: MetaInfo
|
|
32
|
+
beats: List[Beat] = field(default_factory=list)
|
|
33
|
+
structure: Optional[StructureResult] = None
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
"""转换为字典"""
|
|
37
|
+
result = {
|
|
38
|
+
'meta': asdict(self.meta),
|
|
39
|
+
'beats': [
|
|
40
|
+
{
|
|
41
|
+
'time_ms': beat.time_ms,
|
|
42
|
+
'frame': beat.frame,
|
|
43
|
+
'beat_in_bar': beat.beat_in_bar,
|
|
44
|
+
'energy_level': beat.energy_level.value
|
|
45
|
+
}
|
|
46
|
+
for beat in self.beats
|
|
47
|
+
],
|
|
48
|
+
'structure': {
|
|
49
|
+
'segments': [asdict(seg) for seg in self.structure.segments],
|
|
50
|
+
'energy_peaks': [asdict(peak) for peak in self.structure.energy_peaks],
|
|
51
|
+
'silence_regions': [asdict(region) for region in self.structure.silence_regions],
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
def to_json(self, pretty: bool = False) -> str:
|
|
57
|
+
"""导出为 JSON"""
|
|
58
|
+
indent = 2 if pretty else None
|
|
59
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
60
|
+
|
|
61
|
+
def save(self, path: str, pretty: bool = False) -> None:
|
|
62
|
+
"""保存到文件"""
|
|
63
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
64
|
+
f.write(self.to_json(pretty=pretty))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def analyze(
|
|
68
|
+
file_path: str,
|
|
69
|
+
fps: int = 30,
|
|
70
|
+
log_level: str = "info",
|
|
71
|
+
on_progress: Optional[Callable[[float], None]] = None
|
|
72
|
+
) -> AnalysisResult:
|
|
73
|
+
"""分析音频文件"""
|
|
74
|
+
|
|
75
|
+
# 设置日志
|
|
76
|
+
setup_logging(log_level)
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
# 进度辅助函数
|
|
80
|
+
def report_progress(stage_progress: float):
|
|
81
|
+
if on_progress:
|
|
82
|
+
on_progress(stage_progress)
|
|
83
|
+
|
|
84
|
+
# 加载音频
|
|
85
|
+
logger.info(f"Loading audio file: {file_path}")
|
|
86
|
+
try:
|
|
87
|
+
y, sr = load_audio(file_path)
|
|
88
|
+
except (BeatDetectorFileNotFoundError, FFmpegRequiredError, UnsupportedFormatError):
|
|
89
|
+
raise
|
|
90
|
+
except Exception as e:
|
|
91
|
+
raise BeatDetectorFileNotFoundError(file_path)
|
|
92
|
+
|
|
93
|
+
report_progress(10.0)
|
|
94
|
+
|
|
95
|
+
# 计算时长
|
|
96
|
+
duration_ms = int(len(y) / sr * 1000)
|
|
97
|
+
logger.info(f"Audio duration: {duration_ms/1000:.1f}s, sample rate: {sr}Hz")
|
|
98
|
+
|
|
99
|
+
# 节拍检测
|
|
100
|
+
logger.info("Detecting beats...")
|
|
101
|
+
beat_result = detect_beats(y, sr, fps=fps, on_progress=lambda p: report_progress(10 + p * 0.45))
|
|
102
|
+
logger.info(f"Detected BPM: {beat_result.bpm:.1f}")
|
|
103
|
+
|
|
104
|
+
report_progress(55.0)
|
|
105
|
+
|
|
106
|
+
# 结构检测
|
|
107
|
+
logger.info("Analyzing structure...")
|
|
108
|
+
structure_result = detect_structure(y, sr, fps=fps, on_progress=lambda p: report_progress(55 + p * 0.45))
|
|
109
|
+
logger.info(f"Found {len(structure_result.segments)} segments")
|
|
110
|
+
|
|
111
|
+
report_progress(100.0)
|
|
112
|
+
|
|
113
|
+
# 构建结果
|
|
114
|
+
meta = MetaInfo(
|
|
115
|
+
file=file_path,
|
|
116
|
+
duration_ms=duration_ms,
|
|
117
|
+
sample_rate=sr,
|
|
118
|
+
bpm=beat_result.bpm,
|
|
119
|
+
time_signature=beat_result.time_signature
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
result = AnalysisResult(
|
|
123
|
+
meta=meta,
|
|
124
|
+
beats=beat_result.beats,
|
|
125
|
+
structure=structure_result
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
logger.info("Analysis complete")
|
|
129
|
+
return result
|