初始发布: 21 个 skills (Claude Code / Codex / DSH)
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: minimax-music
|
||||
description: 调用 MiniMax music-3.0 生成音乐(带唱歌曲/纯音乐/自动作词/翻唱),并可裁成定长无缝循环 BGM。触发关键词:「生成音乐」「做首歌」「写首歌」「AI 作曲」「生成 BGM」「背景音乐」「配乐」「纯音乐」「器乐」「MiniMax 音乐」「music-3.0」「翻唱」「循环 BGM」「游戏配乐」「短片配乐」。适用场景:游戏/短片/播客的背景音乐、Demo 小样、根据歌词谱曲、按参考音频翻唱。
|
||||
version: 1.0.0
|
||||
author: william
|
||||
---
|
||||
|
||||
# MiniMax 音乐生成 Skill
|
||||
|
||||
调用 MiniMax **music-3.0** 生成音乐并下载到本地;需要定长循环 BGM 时再用 `loopify.py` 做后期。
|
||||
|
||||
## 触发判断
|
||||
|
||||
用户说"生成一段音乐/BGM/配乐"、"做首歌"、"写首纯音乐"、"给这个视频配个乐"、"游戏循环 BGM"等 → 按下方流程执行。
|
||||
|
||||
## 两个脚本
|
||||
|
||||
| 脚本 | 作用 | 依赖 |
|
||||
|------|------|------|
|
||||
| `generate.py` | 调 API 生成音乐 | Python 3 标准库 |
|
||||
| `loopify.py` | 裁成定长无缝循环 + 客观验收 | ffmpeg + numpy |
|
||||
|
||||
## API Key
|
||||
|
||||
优先读环境变量 `MINIMAX_API_KEY`,否则读 `~/.claude/skills/minimax-music/key.txt`(权限 600)。切换用 `export MINIMAX_API_KEY=<新key>`。
|
||||
|
||||
⚠️ **区域必须匹配**:国内 key 配 `api.minimaxi.com`(脚本内置),海外 key 要改成 `api.minimax.io`,否则报鉴权失败。
|
||||
|
||||
## 计费
|
||||
|
||||
| 模型 | 价格 | RPM |
|
||||
|------|------|-----|
|
||||
| `music-3.0` / `music-2.6` / `music-cover` | **¥1.0 / 首** | 120 |
|
||||
| `music-3.0-free` / `music-2.6-free` / `music-cover-free` | **免费** | 3 |
|
||||
|
||||
**按"首"计费,不按秒。** 试风格、调 prompt 一律先用 `--free`(免费且不限次数,只限速率),定稿再跑付费模型。
|
||||
|
||||
## 四种模式
|
||||
|
||||
```bash
|
||||
S=~/.claude/skills/minimax-music
|
||||
|
||||
# 1. 带唱:lyrics 必填,prompt 可选
|
||||
python3 $S/generate.py "独立民谣,忧郁内省,木吉他分解和弦+弦乐铺底" -l @lyrics.txt -o song.mp3
|
||||
|
||||
# 2. 纯音乐:反过来,prompt 必填,lyrics 可省
|
||||
python3 $S/generate.py "中世纪奇幻大地图探索,鲁特琴+竖琴+木笛,中慢速,无鼓" --instrumental -o bgm.mp3
|
||||
|
||||
# 3. 自动作词:不用自己写词
|
||||
python3 $S/generate.py "抒情流行,夏夜告别,遗憾但释然" --auto-lyrics -o auto.mp3
|
||||
|
||||
# 4. 翻唱:参考音频 6s-6min、<=50MB
|
||||
python3 $S/generate.py "" --cover ref.mp3 -l "[Verse]\n新歌词..." -o cover.mp3
|
||||
|
||||
# 免费试跑(任何模式加 --free)
|
||||
python3 $S/generate.py "..." --instrumental --free -o test.mp3
|
||||
```
|
||||
|
||||
### 常用参数
|
||||
|
||||
| 参数 | 说明 | 默认 |
|
||||
|------|------|------|
|
||||
| `prompt`(位置) | 曲风/情绪/场景,≤2000 字 | 纯音乐时必填 |
|
||||
| `-l, --lyrics` | 歌词 ≤3500 字,`\n` 分行,支持 `@文件` | 带唱时必填 |
|
||||
| `--instrumental` | 纯音乐 | 否 |
|
||||
| `--auto-lyrics` | 模型自动作词 | 否 |
|
||||
| `--cover` | 翻唱参考音频(路径或 URL)| — |
|
||||
| `-m, --model` | 模型 | `music-3.0` |
|
||||
| `--free` | 换成对应免费模型 | 否 |
|
||||
| `-o, --output` | 输出路径 | 时间戳命名 |
|
||||
| `--sample-rate` / `--bitrate` / `--format` | 16000/24000/32000/**44100**、32000/64000/128000/**256000**、**mp3**/wav/pcm | 见粗体 |
|
||||
| `--hex` | 用 hex 返回而非 url | 否 |
|
||||
|
||||
## 歌词格式
|
||||
|
||||
用 `[Intro]` `[Verse]` `[Chorus]` `[Bridge]` `[Outro]` 分段,段间空行:
|
||||
|
||||
```
|
||||
[Verse]
|
||||
雨把街灯揉成一片橙黄
|
||||
玻璃门后面有人在张望
|
||||
|
||||
[Chorus]
|
||||
你转身的那一秒
|
||||
雨就轻了一点点
|
||||
```
|
||||
|
||||
## 定长无缝循环 BGM
|
||||
|
||||
**接口没有 duration 参数**,时长完全不可控(实测:带唱 61s、纯音乐 97-126s,纯音乐普遍更长因为没有歌词框住它)。游戏/短片要定长循环,必须后期处理:
|
||||
|
||||
```bash
|
||||
python3 $S/loopify.py raw.mp3 -o bgm_loop.mp3 -L 45 --preview 3
|
||||
```
|
||||
|
||||
它做四件事:
|
||||
|
||||
1. **测实际速度,把循环长度对齐到整数小节**。只对齐电平不对齐节奏的话,循环起来会丢拍——这是最容易翻车的地方。`-L 45 --tol 1.0` 表示在 44-46s 里找整小节长度。
|
||||
2. **扫描起点**,比较首尾 2 秒的 RMS + 频谱质心 + 低频占比,选最接近的窗口,并避开渐入、渐出、能量凹陷。
|
||||
3. **qsin 等功率曲线做尾→头交叉淡化**(默认 2.0s),并自动把峰值压到 `--peak`(默认 -1.0 dBFS)留余量。
|
||||
4. **客观验收**:峰值削波、异常静音、接缝逐样本跳变 vs 曲内 99.9 分位跳变、接缝前后 RMS 差、立体声宽度。
|
||||
|
||||
`--preview 3` 会额外导出连播三遍的文件——**循环 BGM 一定要循环着听至少 3 分钟**,单听一遍听不出接缝和"听腻"的问题。
|
||||
|
||||
## 写 prompt 的要点
|
||||
|
||||
**先想清楚这段音乐会不会被循环播放,两种写法是相反的:**
|
||||
|
||||
| | 一次性配乐(短片/过场)| 循环 BGM(游戏/等待画面)|
|
||||
|---|---|---|
|
||||
| 情绪 | 写**情绪曲线**:"开头克制,中段弦乐推起,结尾回落渐弱" | 写**稳定**:"全曲同调性、同速度、同织体密度,平稳流动" |
|
||||
| 结构 | 可以有前奏尾奏 | **明确不要前奏尾奏、不要渐强、不要淡出结尾** |
|
||||
| 旋律 | 可以抓耳 | **克制、留白多、听十分钟不腻** |
|
||||
|
||||
其余通用要点:
|
||||
|
||||
- **写死速度和拍号**:"中慢速约 80 BPM,6/8 摇曳律动,自然小调"
|
||||
- **逐件点名配器**:"鲁特琴分解和弦作骨架,竖琴琶音点缀,木笛吹主旋律,柔和弦乐铺底"
|
||||
- **一定要写否定项**。不写它很容易自己加鼓、加合成器、加人声,配画面就吵了:
|
||||
"严格不要:人声、歌词、吟唱、现代流行元素、电子合成器、重鼓组、密集打击乐"
|
||||
- 中英混写没问题;有必须命中的风格关键词就直接用英文写进去(`medieval fantasy, orchestral, instrumental, loopable, game soundtrack`)
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 明确用途(配画面?循环 BGM?独立歌曲?)和时长要求
|
||||
2. 按上表写 prompt,**先用 `--free` 试 1-2 版**确认方向
|
||||
3. 方向对了跑付费 `music-3.0` 定稿(¥1)
|
||||
4. 要定长循环就接 `loopify.py`,看验收输出是否全项通过
|
||||
5. 把成品路径告诉用户;循环 BGM 一并给 `--preview` 的试听文件
|
||||
|
||||
## 坑
|
||||
|
||||
1. **HTTP 200 不代表成功**。一律看 `base_resp.status_code`:`0` 成功、`1002` 限流、`1004` 鉴权失败、`1008` 余额不足、`1026` 敏感内容、`2013` 参数错。脚本已处理并翻译成人话。
|
||||
2. **`output_format` 接口默认是 `hex`**(一大串十六进制塞在 JSON 里)。脚本默认改成了 `url` 并自动下载,两种都兼容。**url 链接 24 小时过期**,别只存链接。
|
||||
3. **`extra_info` 里没有计费字段**(不像视频接口有 `usage`),没法从响应对账,只能自己按"首"数。
|
||||
4. **必填是条件性的**:纯音乐要 prompt、带唱要 lyrics,反了会 2013。脚本在发请求前就本地拦截,不会白花钱。
|
||||
5. **翻唱三个参数互斥**:`audio_url` / `audio_base64` / `cover_feature_id` 只能给一个;`cover_feature_id` 24 小时过期。
|
||||
6. **两份官方文档的歌词长度打架**:API 参考写 1-3500 字,指南写 10-1000 字。后者只适用于带 `cover_feature_id` 的翻唱。
|
||||
7. **free 模型限 RPM 3**,串行调用够用,别并发。
|
||||
8. **输出电平不稳定**:实测同样参数,有的曲子峰值 -2.3 dBFS,有的直接 0.0 dBFS 顶格。顶格素材做交叉淡化时两路叠加必然削波,`loopify.py` 已自动衰减留余量;如果你自己用 ffmpeg 拼接,记得先量峰值。
|
||||
|
||||
## 参考
|
||||
|
||||
- [音乐生成 API](https://platform.minimaxi.com/docs/api-reference/music-generation)
|
||||
- [音乐生成指南](https://platform.minimaxi.com/docs/guides/music-generation)
|
||||
- [按量付费定价](https://platform.minimaxi.com/docs/guides/pricing-paygo)
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MiniMax 音乐生成 CLI(Python 3 标准库,无需安装依赖)。
|
||||
|
||||
用法示例:
|
||||
# 带唱
|
||||
python3 generate.py "独立民谣,忧郁内省,木吉他+弦乐" -l @lyrics.txt
|
||||
# 纯音乐
|
||||
python3 generate.py "中世纪奇幻大地图,鲁特琴+竖琴,无鼓" --instrumental
|
||||
# 自动作词
|
||||
python3 generate.py "抒情流行,夏夜告别" --auto-lyrics
|
||||
# 翻唱
|
||||
python3 generate.py "" --cover ref.mp3 -l "[Verse]\n新歌词..."
|
||||
"""
|
||||
import argparse, base64, json, os, sys, time, urllib.request, urllib.error
|
||||
|
||||
API_URL = "https://api.minimaxi.com/v1/music_generation"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
PAID = {"music-3.0", "music-2.6", "music-cover"}
|
||||
FREE = {"music-3.0-free", "music-2.6-free", "music-cover-free"}
|
||||
MODELS = sorted(PAID | FREE)
|
||||
|
||||
# base_resp.status_code -> 人话
|
||||
ERRORS = {
|
||||
1002: "触发限流(付费模型 RPM 120,free 模型 RPM 3)。等一会儿再试,别并发。",
|
||||
1004: "鉴权失败:API Key 无效。检查 MINIMAX_API_KEY 或 key.txt。",
|
||||
1008: "账户余额不足,去控制台充值。",
|
||||
1026: "命中敏感内容审核,改一下 prompt 或歌词。",
|
||||
2013: "参数不合法(看 status_msg 里的具体字段)。",
|
||||
2049: "API Key 格式不对。",
|
||||
}
|
||||
|
||||
|
||||
def load_key():
|
||||
k = os.environ.get("MINIMAX_API_KEY", "").strip()
|
||||
if k:
|
||||
return k
|
||||
p = os.path.join(HERE, "key.txt")
|
||||
if os.path.exists(p):
|
||||
k = open(p).read().strip()
|
||||
if k:
|
||||
return k
|
||||
sys.exit("没有 API Key。设置环境变量 MINIMAX_API_KEY,或写入 %s" % p)
|
||||
|
||||
|
||||
def read_maybe_file(v):
|
||||
"""支持 @path 从文件读取。"""
|
||||
if v and v.startswith("@"):
|
||||
return open(os.path.expanduser(v[1:]), encoding="utf-8").read()
|
||||
return v
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="MiniMax 音乐生成")
|
||||
ap.add_argument("prompt", nargs="?", default="",
|
||||
help="曲风/情绪/场景描述,<=2000 字。纯音乐时必填")
|
||||
ap.add_argument("-l", "--lyrics", default="",
|
||||
help="歌词,<=3500 字,用 \\n 分行;支持 @文件路径。带唱时必填")
|
||||
ap.add_argument("--instrumental", action="store_true", help="生成纯音乐(无人声)")
|
||||
ap.add_argument("--auto-lyrics", action="store_true",
|
||||
help="让模型按 prompt 自动作词(lyrics_optimizer)")
|
||||
ap.add_argument("--cover", default="",
|
||||
help="翻唱参考音频:本地文件路径 或 http(s) URL(6s-6min,<=50MB)")
|
||||
ap.add_argument("--cover-feature-id", default="",
|
||||
help="翻唱预处理接口拿到的 feature_id(24 小时有效)")
|
||||
ap.add_argument("-m", "--model", default="music-3.0", choices=MODELS)
|
||||
ap.add_argument("--free", action="store_true",
|
||||
help="改用对应的 -free 免费模型(RPM 3,不计费)")
|
||||
ap.add_argument("-o", "--output", default="", help="输出路径,默认按时间戳命名")
|
||||
ap.add_argument("--sample-rate", type=int, default=44100,
|
||||
choices=[16000, 24000, 32000, 44100])
|
||||
ap.add_argument("--bitrate", type=int, default=256000,
|
||||
choices=[32000, 64000, 128000, 256000])
|
||||
ap.add_argument("--format", default="mp3", choices=["mp3", "wav", "pcm"])
|
||||
ap.add_argument("--watermark", action="store_true", help="加 AIGC 水印")
|
||||
ap.add_argument("--hex", action="store_true",
|
||||
help="用 hex 返回而非 url(url 链接 24 小时过期)")
|
||||
ap.add_argument("--json", action="store_true", help="打印完整 JSON 返回")
|
||||
a = ap.parse_args()
|
||||
|
||||
model = a.model
|
||||
if a.free and not model.endswith("-free"):
|
||||
model += "-free"
|
||||
|
||||
lyrics = read_maybe_file(a.lyrics)
|
||||
is_cover = bool(a.cover or a.cover_feature_id)
|
||||
if is_cover and not model.startswith("music-cover"):
|
||||
model = "music-cover-free" if model.endswith("-free") else "music-cover"
|
||||
|
||||
# ---- 本地前置校验:省得白花钱 ----
|
||||
if a.cover and a.cover_feature_id:
|
||||
sys.exit("--cover 和 --cover-feature-id 互斥,只能给一个")
|
||||
if a.instrumental and not a.prompt.strip():
|
||||
sys.exit("纯音乐模式下 prompt 必填(要靠它定曲风和配器)")
|
||||
if not a.instrumental and not is_cover and not a.auto_lyrics and not lyrics.strip():
|
||||
sys.exit("带唱模式下 lyrics 必填。要么给 -l,要么加 --instrumental,"
|
||||
"要么加 --auto-lyrics 让模型自己写")
|
||||
if a.cover_feature_id and not (10 <= len(lyrics.strip()) <= 1000):
|
||||
sys.exit("带 feature_id 的翻唱要求歌词 10-1000 字,当前 %d 字" % len(lyrics.strip()))
|
||||
if len(a.prompt) > 2000:
|
||||
sys.exit("prompt 超长:%d > 2000 字" % len(a.prompt))
|
||||
if len(lyrics) > 3500:
|
||||
sys.exit("lyrics 超长:%d > 3500 字" % len(lyrics))
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"output_format": "hex" if a.hex else "url",
|
||||
"audio_setting": {"sample_rate": a.sample_rate, "bitrate": a.bitrate,
|
||||
"format": a.format},
|
||||
}
|
||||
if a.prompt.strip():
|
||||
body["prompt"] = a.prompt
|
||||
if lyrics.strip():
|
||||
body["lyrics"] = lyrics
|
||||
if a.instrumental:
|
||||
body["is_instrumental"] = True
|
||||
if a.auto_lyrics:
|
||||
body["lyrics_optimizer"] = True
|
||||
if a.watermark:
|
||||
body["aigc_watermark"] = True
|
||||
if a.cover:
|
||||
if a.cover.startswith("http"):
|
||||
body["audio_url"] = a.cover
|
||||
else:
|
||||
with open(os.path.expanduser(a.cover), "rb") as f:
|
||||
body["audio_base64"] = base64.b64encode(f.read()).decode()
|
||||
if a.cover_feature_id:
|
||||
body["cover_feature_id"] = a.cover_feature_id
|
||||
|
||||
cost = "免费" if model.endswith("-free") else "¥1.0"
|
||||
print("模型 %s(%s)· 提交中…" % (model, cost), flush=True)
|
||||
|
||||
req = urllib.request.Request(
|
||||
API_URL, data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Authorization": "Bearer " + load_key(),
|
||||
"Content-Type": "application/json"}, method="POST")
|
||||
t = time.time()
|
||||
try:
|
||||
r = json.loads(urllib.request.urlopen(req, timeout=600).read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit("HTTP %d %s" % (e.code, e.read().decode("utf-8", "replace")))
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit("网络错误:%s" % e)
|
||||
|
||||
if a.json:
|
||||
print(json.dumps(r, ensure_ascii=False, indent=2))
|
||||
|
||||
# HTTP 200 不代表成功,一律看 base_resp.status_code
|
||||
base = r.get("base_resp") or {}
|
||||
code = base.get("status_code")
|
||||
if code != 0:
|
||||
sys.exit("生成失败 %s: %s\n%s" % (code, base.get("status_msg"),
|
||||
ERRORS.get(code, "")))
|
||||
|
||||
info = r.get("extra_info") or {}
|
||||
dur = info.get("music_duration", 0) / 1000.0
|
||||
print("成功 · 时长 %.1fs · %s Hz · %s 声道 · %s bps · 耗时 %.0fs"
|
||||
% (dur, info.get("music_sample_rate"), info.get("music_channel"),
|
||||
info.get("bitrate"), time.time() - t), flush=True)
|
||||
|
||||
audio = (r.get("data") or {}).get("audio")
|
||||
if not audio:
|
||||
sys.exit("返回里没有音频数据")
|
||||
|
||||
out = a.output or "minimax-music-%s.%s" % (time.strftime("%Y%m%d-%H%M%S"), a.format)
|
||||
out = os.path.expanduser(out)
|
||||
d = os.path.dirname(os.path.abspath(out))
|
||||
if d:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
if audio.startswith("http"):
|
||||
urllib.request.urlretrieve(audio, out)
|
||||
else:
|
||||
with open(out, "wb") as f:
|
||||
f.write(bytes.fromhex(audio))
|
||||
print("已保存: %s (%.1f MB)" % (out, os.path.getsize(out) / 1e6))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""把生成的音乐裁成指定长度的无缝循环 BGM,并做客观验收。
|
||||
|
||||
MiniMax 接口既没有 duration 参数、也没有循环淡化,游戏/短片要定长循环 BGM
|
||||
只能后期做。本脚本负责:
|
||||
1. 测素材实际速度,把循环长度对齐到整数小节(只对齐电平不对齐节奏,
|
||||
循环起来会丢拍)
|
||||
2. 扫描起点,选首尾 2 秒在 RMS/频谱质心/低频占比上最接近的窗口,
|
||||
并避开渐入、渐出和能量凹陷
|
||||
3. 用 qsin 等功率曲线做尾→头交叉淡化
|
||||
4. 验收:峰值、静音、接缝跳变、立体声宽度
|
||||
|
||||
依赖:ffmpeg/ffprobe + numpy
|
||||
用法:
|
||||
python3 loopify.py raw.mp3 -o bgm_loop.mp3 -L 45 --preview 3
|
||||
"""
|
||||
import argparse, os, shutil, subprocess, sys
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
sys.exit("需要 numpy:pip3 install numpy")
|
||||
|
||||
if not shutil.which("ffmpeg"):
|
||||
sys.exit("需要 ffmpeg:brew install ffmpeg")
|
||||
|
||||
ANALYZE_SR = 22050
|
||||
HOP = 512
|
||||
|
||||
|
||||
def decode(path, sr, ch=1):
|
||||
r = subprocess.run(["ffmpeg", "-v", "error", "-i", path, "-ac", str(ch),
|
||||
"-ar", str(sr), "-f", "f32le", "-"],
|
||||
capture_output=True)
|
||||
if r.returncode != 0:
|
||||
sys.exit("解码失败:%s" % r.stderr.decode("utf-8", "replace")[:400])
|
||||
a = np.frombuffer(r.stdout, dtype=np.float32)
|
||||
return a.reshape(-1, ch) if ch > 1 else a
|
||||
|
||||
|
||||
def beat_period(x):
|
||||
"""谱通量 + 自相关,估计节拍周期(秒)。"""
|
||||
win = 1024
|
||||
n = (len(x) - win) // HOP
|
||||
if n < 64:
|
||||
return None
|
||||
idx = np.arange(n)[:, None] * HOP + np.arange(win)
|
||||
S = np.abs(np.fft.rfft(x[idx] * np.hanning(win), axis=1))
|
||||
flux = np.maximum(0, np.diff(S, axis=0)).sum(axis=1)
|
||||
flux = flux - flux.mean()
|
||||
fps = ANALYZE_SR / HOP
|
||||
ac = np.correlate(flux, flux, "full")[len(flux) - 1:]
|
||||
lo, hi = int(fps * 60 / 160), int(fps * 60 / 60)
|
||||
if hi >= len(ac):
|
||||
return None
|
||||
return (lo + int(np.argmax(ac[lo:hi]))) / fps
|
||||
|
||||
|
||||
def pick_window(x, dur, target, tol, fade):
|
||||
"""返回 (t0, L, score, 说明)。"""
|
||||
beat = beat_period(x)
|
||||
cands = []
|
||||
if beat:
|
||||
for bpb in (3, 4, 6, 8):
|
||||
bar = beat * bpb
|
||||
k = 1
|
||||
while bar * k <= target + tol:
|
||||
L = bar * k
|
||||
if target - tol <= L <= target + tol:
|
||||
cands.append((L, "%d 小节 × %d 拍 @ %.1f BPM"
|
||||
% (k, bpb, 60 / beat)))
|
||||
k += 1
|
||||
if not cands:
|
||||
cands = [(float(target), "未测出稳定节拍,用目标长度")]
|
||||
|
||||
def feat(t):
|
||||
a = x[int(t * ANALYZE_SR):int((t + fade) * ANALYZE_SR)]
|
||||
if len(a) < ANALYZE_SR // 2:
|
||||
return None
|
||||
sp = np.abs(np.fft.rfft(a * np.hanning(len(a))))
|
||||
fr = np.fft.rfftfreq(len(a), 1 / ANALYZE_SR)
|
||||
e = sp.sum() + 1e-9
|
||||
return np.array([20 * np.log10(np.sqrt((a ** 2).mean()) + 1e-9),
|
||||
(sp * fr).sum() / e / 1000.0,
|
||||
sp[fr < 300].sum() / e * 20])
|
||||
|
||||
best = None
|
||||
for L, why in cands:
|
||||
if L + fade + 1.5 > dur:
|
||||
continue
|
||||
t0 = 1.0
|
||||
while t0 + L + fade <= dur - 0.5:
|
||||
h, t = feat(t0), feat(t0 + L)
|
||||
if h is not None and t is not None:
|
||||
seg = x[int(t0 * ANALYZE_SR):int((t0 + L) * ANALYZE_SR)]
|
||||
k = int(ANALYZE_SR * 0.5)
|
||||
quietest = min(np.sqrt((seg[i:i + k] ** 2).mean())
|
||||
for i in range(0, max(1, len(seg) - k), k))
|
||||
penalty = max(0.0, -20 * np.log10(quietest + 1e-9) - 40) * 0.5
|
||||
d = float(np.abs(h - t).sum()) + penalty
|
||||
if best is None or d < best[2]:
|
||||
best = (t0, L, d, why)
|
||||
t0 += 0.05
|
||||
if best is None:
|
||||
sys.exit("素材太短,做不出 %.1fs 的循环(需要至少 %.1fs)"
|
||||
% (target, target + fade + 2.5))
|
||||
return best
|
||||
|
||||
|
||||
def build(src, out, t0, L, fade, bitrate, peak_dbfs):
|
||||
wav = os.path.splitext(out)[0] + ".wav"
|
||||
fc = ("[0:a]atrim=start=%.4f:duration=%.4f,asetpts=PTS-STARTPTS[tail];"
|
||||
"[1:a]atrim=start=%.4f:duration=%.4f,asetpts=PTS-STARTPTS[body];"
|
||||
"[tail][body]acrossfade=d=%.2f:c1=qsin:c2=qsin[out]"
|
||||
% (t0 + L, fade, t0, L, fade))
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y",
|
||||
"-i", src, "-i", src, "-filter_complex", fc,
|
||||
"-map", "[out]", "-c:a", "pcm_s24le", wav], check=True)
|
||||
|
||||
# MiniMax 的输出电平不稳定(实测有 -2.3 dBFS 的,也有 0.0 dBFS 顶格的)。
|
||||
# 顶格素材经交叉淡化两路叠加必然溢出,所以这里统一压到目标峰值。
|
||||
target = 10 ** (peak_dbfs / 20.0)
|
||||
p = float(np.abs(decode(wav, 44100, 2)).max())
|
||||
if p > target:
|
||||
g = target / max(p, 1e-9)
|
||||
tmp = wav + ".tmp.wav"
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y", "-i", wav,
|
||||
"-af", "volume=%.6f" % g, "-c:a", "pcm_s24le", tmp],
|
||||
check=True)
|
||||
os.replace(tmp, wav)
|
||||
print("留余量: 峰值 %.1f → %.1f dBFS(衰减 %.1f dB)"
|
||||
% (20 * np.log10(p + 1e-9), peak_dbfs, 20 * np.log10(g)))
|
||||
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y", "-i", wav,
|
||||
"-c:a", "libmp3lame", "-b:a", bitrate, out], check=True)
|
||||
return wav
|
||||
|
||||
|
||||
def verify(wav, sr=44100):
|
||||
x = decode(wav, sr, 2)
|
||||
ok = True
|
||||
print("\n=== 验收 ===")
|
||||
print("时长 %.3fs · %d 声道 · %d Hz" % (len(x) / sr, x.shape[1], sr))
|
||||
|
||||
peak = float(np.abs(x).max())
|
||||
good = peak < 0.999
|
||||
ok &= good
|
||||
print("峰值 %.4f (%.1f dBFS) %s" % (peak, 20 * np.log10(peak + 1e-9),
|
||||
"OK" if good else "削波!"))
|
||||
|
||||
def rms_db(a):
|
||||
return 20 * np.log10(np.sqrt((a ** 2).mean()) + 1e-9)
|
||||
|
||||
k = sr // 2
|
||||
q = min(rms_db(x[i:i + k]) for i in range(0, len(x) - k, k // 2))
|
||||
good = q > -45
|
||||
ok &= good
|
||||
print("最静 0.5s %.1f dB %s" % (q, "OK" if good else "有静音段!"))
|
||||
|
||||
mono = x.mean(axis=1)
|
||||
typ = float(np.percentile(np.abs(np.diff(mono)), 99.9))
|
||||
seam = float(abs(mono[0] - mono[-1]))
|
||||
good = seam <= typ
|
||||
ok &= good
|
||||
print("接缝跳变 %.6f vs 曲内 99.9 分位 %.6f(比值 %.2f)%s"
|
||||
% (seam, typ, seam / (typ + 1e-12), "OK" if good else "有咔哒声!"))
|
||||
|
||||
d = abs(rms_db(mono[-k:]) - rms_db(mono[:k]))
|
||||
good = d < 3
|
||||
ok &= good
|
||||
print("接缝前后 RMS 差 %.1f dB %s" % (d, "OK" if good else "电平不匹配"))
|
||||
|
||||
w = float(np.abs(x[:, 0] - x[:, 1]).mean() / (np.abs(x).mean() + 1e-9))
|
||||
print("立体声宽度 %.3f %s" % (w, "有空间感" if w > 0.1 else "接近单声道"))
|
||||
print("=== %s ===" % ("全项通过" if ok else "有项未通过,见上"))
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="裁成无缝循环 BGM")
|
||||
ap.add_argument("input")
|
||||
ap.add_argument("-o", "--output", default="bgm_loop.mp3")
|
||||
ap.add_argument("-L", "--length", type=float, default=45.0, help="目标秒数")
|
||||
ap.add_argument("--tol", type=float, default=1.0, help="长度容差秒")
|
||||
ap.add_argument("--fade", type=float, default=2.0, help="交叉淡化秒")
|
||||
ap.add_argument("--bitrate", default="256k")
|
||||
ap.add_argument("--peak", type=float, default=-1.0,
|
||||
help="目标峰值 dBFS,超了自动衰减留余量")
|
||||
ap.add_argument("--preview", type=int, default=0,
|
||||
help="额外导出连播 N 遍的试听文件,用来听接缝")
|
||||
ap.add_argument("--keep-wav", action="store_true", help="保留无损 wav")
|
||||
a = ap.parse_args()
|
||||
|
||||
src = os.path.expanduser(a.input)
|
||||
out = os.path.expanduser(a.output)
|
||||
x = decode(src, ANALYZE_SR)
|
||||
dur = len(x) / ANALYZE_SR
|
||||
print("素材 %s · %.2fs" % (os.path.basename(src), dur))
|
||||
|
||||
t0, L, score, why = pick_window(x, dur, a.length, a.tol, a.fade)
|
||||
print("循环长度 %.3fs(%s)· 起点 %.2fs · 接缝差异分 %.3f" % (L, why, t0, score))
|
||||
|
||||
wav = build(src, out, t0, L, a.fade, a.bitrate, a.peak)
|
||||
verify(wav)
|
||||
|
||||
if a.preview > 1:
|
||||
pv = os.path.splitext(out)[0] + "_x%d.mp3" % a.preview
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-v", "error", "-y",
|
||||
"-stream_loop", str(a.preview - 1), "-i", wav,
|
||||
"-c:a", "libmp3lame", "-b:a", a.bitrate, pv], check=True)
|
||||
print("接缝试听(连播 %d 遍): %s" % (a.preview, pv))
|
||||
if not a.keep_wav:
|
||||
os.remove(wav)
|
||||
else:
|
||||
print("无损: %s" % wav)
|
||||
print("成品: %s" % out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user