feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
AVE 07_bgm_generator — 背景音乐生成 (三阶路由)
|
||||
|
||||
用法:
|
||||
from bgm_generator.suno import generate_bgm, get_available_moods
|
||||
generate_bgm(mood="calm", duration=30, output="/tmp/bgm.wav")
|
||||
"""
|
||||
from .suno import generate_bgm, get_available_moods
|
||||
|
||||
__all__ = ["generate_bgm", "get_available_moods"]
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AVE BGM 音乐库助手 v1.0
|
||||
下载 Pixabay Music 免费可商用 BGM 到本地库
|
||||
|
||||
用法:
|
||||
python bgm_download.py search --mood calm
|
||||
python bgm_download.py list
|
||||
python bgm_download.py scan # 扫描已下载文件更新 library.json
|
||||
python bgm_download.py import --file /path/to/music.mp3 --mood happy
|
||||
|
||||
注意:
|
||||
Pixabay Music 没有公开 API,本脚本提供两种方式添加音乐:
|
||||
1. 打开 https://pixabay.com/music/ 手动下载 → 放到对应情绪目录
|
||||
2. 运行 scan 自动扫描目录更新 library.json
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(SCRIPT_DIR, ".."))
|
||||
|
||||
from lib.logger import get_logger
|
||||
|
||||
logger = get_logger("bgm_lib")
|
||||
|
||||
# ── 常量 ──
|
||||
LIBRARY_DIR = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "cache" / "bgm_library"
|
||||
LIBRARY_CONFIG = LIBRARY_DIR / "library.json"
|
||||
|
||||
MOODS = ["calm", "soothing", "happy", "excited", "sad", "mystery",
|
||||
"professional", "funny", "inspiring", "normal"]
|
||||
|
||||
|
||||
def main():
|
||||
setup()
|
||||
parser = argparse.ArgumentParser(description="BGM 音乐库助手")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# list
|
||||
sub.add_parser("list", help="列出库中所有音乐")
|
||||
|
||||
# scan
|
||||
sub.add_parser("scan", help="扫描文件系统更新 library.json")
|
||||
|
||||
# search (open browser)
|
||||
p_search = sub.add_parser("search", help="打开浏览器搜索指定情绪的 BGM")
|
||||
p_search.add_argument("--mood", default="calm", choices=MOODS, help="情绪")
|
||||
|
||||
# import
|
||||
p_import = sub.add_parser("import", help="手动导入一个音乐文件")
|
||||
p_import.add_argument("--file", required=True, help="音乐文件路径")
|
||||
p_import.add_argument("--mood", required=True, choices=MOODS, help="情绪分类")
|
||||
p_import.add_argument("--title", default="", help="曲目标题")
|
||||
p_import.add_argument("--source", default="manual", help="来源")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "list":
|
||||
cmd_list()
|
||||
elif args.command == "scan":
|
||||
cmd_scan()
|
||||
elif args.command == "search":
|
||||
cmd_search(args.mood)
|
||||
elif args.command == "import":
|
||||
cmd_import(args.file, args.mood, args.title, args.source)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
def setup():
|
||||
"""确保目录结构存在"""
|
||||
LIBRARY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for m in MOODS:
|
||||
(LIBRARY_DIR / m).mkdir(parents=True, exist_ok=True)
|
||||
if not LIBRARY_CONFIG.exists():
|
||||
logger.info("初始化 library.json")
|
||||
cfg = {"库": {m: {"曲目": [], "说明": ""} for m in MOODS}}
|
||||
cfg["基础路径"] = str(LIBRARY_DIR)
|
||||
cfg["版本"] = "v1.0"
|
||||
with open(LIBRARY_CONFIG, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def cmd_list():
|
||||
"""列出所有已导入的音乐"""
|
||||
cfg = load_config()
|
||||
total = 0
|
||||
for mood in MOODS:
|
||||
tracks = cfg.get("库", {}).get(mood, {}).get("曲目", [])
|
||||
valid = [t for t in tracks if t.get("文件名")]
|
||||
if valid:
|
||||
print(f"\n [{mood}] ({len(valid)} 首)")
|
||||
for t in valid:
|
||||
fname = t.get("文件名", "?")
|
||||
title = t.get("标题", fname)
|
||||
duration = t.get("时长", 0)
|
||||
print(f" {title:30s} {duration:4.1f}s {fname}")
|
||||
total += len(valid)
|
||||
print(f"\n 共 {total} 首 BGM")
|
||||
|
||||
|
||||
def cmd_scan():
|
||||
"""扫描目录更新 library.json"""
|
||||
cfg = load_config()
|
||||
new_count = 0
|
||||
for mood in MOODS:
|
||||
mood_dir = LIBRARY_DIR / mood
|
||||
if not mood_dir.exists():
|
||||
continue
|
||||
existing = {t.get("文件名") for t in cfg["库"][mood]["曲目"]}
|
||||
for f in sorted(mood_dir.iterdir()):
|
||||
if f.suffix.lower() in (".mp3", ".wav", ".m4a", ".ogg", ".flac"):
|
||||
if f.name not in existing:
|
||||
duration = get_duration(str(f))
|
||||
cfg["库"][mood]["曲目"].append({
|
||||
"文件名": f.name,
|
||||
"标题": f.stem,
|
||||
"时长": duration,
|
||||
"源": "scan_import",
|
||||
})
|
||||
new_count += 1
|
||||
print(f" ✅ 新增 [{mood}] {f.name} ({duration:.1f}s)")
|
||||
|
||||
save_config(cfg)
|
||||
print(f"\n扫描完成,新增 {new_count} 首")
|
||||
|
||||
|
||||
def cmd_search(mood: str):
|
||||
"""打开浏览器搜索 Pixabay Music"""
|
||||
urls = {
|
||||
"calm": "https://pixabay.com/music/search/calm/",
|
||||
"soothing": "https://pixabay.com/music/search/relaxing/",
|
||||
"happy": "https://pixabay.com/music/search/upbeat/",
|
||||
"excited": "https://pixabay.com/music/search/energetic/",
|
||||
"sad": "https://pixabay.com/music/search/sad/",
|
||||
"mystery": "https://pixabay.com/music/search/cinematic/",
|
||||
"professional": "https://pixabay.com/music/search/corporate/",
|
||||
"funny": "https://pixabay.com/music/search/playful/",
|
||||
"inspiring": "https://pixabay.com/music/search/inspiring/",
|
||||
"normal": "https://pixabay.com/music/search/background/",
|
||||
}
|
||||
url = urls.get(mood, urls["normal"])
|
||||
print(f" 打开浏览器: {url}")
|
||||
print(f" 操作: 试听 → 免费下载 → 放入 bgm_library/{mood}/ 目录")
|
||||
print(f" 然后运行: python bgm_download.py scan 自动入库")
|
||||
subprocess.run(["open", url])
|
||||
|
||||
|
||||
def cmd_import(file_path: str, mood: str, title: str, source: str):
|
||||
"""导入一个音乐文件"""
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"文件不存在: {file_path}")
|
||||
return
|
||||
|
||||
dest = LIBRARY_DIR / mood / os.path.basename(file_path)
|
||||
if os.path.exists(dest):
|
||||
logger.warning(f"文件已存在: {dest}")
|
||||
return
|
||||
|
||||
import shutil
|
||||
shutil.copy2(file_path, dest)
|
||||
duration = get_duration(str(dest))
|
||||
|
||||
cfg = load_config()
|
||||
cfg["库"][mood]["曲目"].append({
|
||||
"文件名": dest.name,
|
||||
"标题": title or dest.stem,
|
||||
"时长": duration,
|
||||
"源": source,
|
||||
})
|
||||
save_config(cfg)
|
||||
print(f"✅ 导入完成: {dest.name} ({duration:.1f}s) → [{mood}]")
|
||||
|
||||
|
||||
def get_duration(file_path: str) -> float:
|
||||
"""获取音频时长(用 lib.ffmpeg —— 本机 ffprobe 实为 ffmpeg 符号链接)"""
|
||||
try:
|
||||
from lib.ffmpeg import probe_duration
|
||||
d = probe_duration(file_path)
|
||||
return round(d, 1) if d > 0 else 30.0
|
||||
except Exception:
|
||||
return 30.0
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
if LIBRARY_CONFIG.exists():
|
||||
with open(LIBRARY_CONFIG, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {"库": {}}
|
||||
|
||||
|
||||
def save_config(cfg: dict):
|
||||
with open(LIBRARY_CONFIG, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
|
||||
AVE 07_bgm_generator — Tier 3 FFmpeg 和弦垫音
|
||||
|
||||
FFmpeg 和弦氛围音生成,作为离线保障 (v2 移植)
|
||||
|
||||
用法:
|
||||
generate_chord_pad("funny", 60, "/tmp/bgm.wav")
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from lib.logger import get_logger
|
||||
|
||||
logger = get_logger("bgm_chord")
|
||||
|
||||
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
|
||||
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/bgm")))
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── 和弦进行预设 ──
|
||||
CHORD_PRESETS = {
|
||||
"calm": {
|
||||
"chords": [[220, 277, 330], [196, 247, 294], [261, 329, 392], [220, 277, 330]],
|
||||
"bpm": 60, "lowpass": 800, "reverb_decay": 0.6, "vol_factor": 0.08,
|
||||
},
|
||||
"soothing": {
|
||||
"chords": [[196, 233, 294], [220, 261, 330], [175, 220, 277], [196, 233, 294]],
|
||||
"bpm": 55, "lowpass": 700, "reverb_decay": 0.7, "vol_factor": 0.07,
|
||||
},
|
||||
"happy": {
|
||||
"chords": [[262, 330, 392], [330, 415, 523], [392, 494, 587], [440, 554, 659]],
|
||||
"bpm": 90, "lowpass": 1200, "reverb_decay": 0.3, "vol_factor": 0.06,
|
||||
},
|
||||
"excited": {
|
||||
"chords": [[294, 370, 440], [349, 440, 554], [392, 493, 587], [440, 554, 659]],
|
||||
"bpm": 110, "lowpass": 1500, "reverb_decay": 0.2, "vol_factor": 0.05,
|
||||
},
|
||||
"sad": {
|
||||
"chords": [[165, 196, 247], [147, 175, 220], [131, 165, 196], [147, 175, 220]],
|
||||
"bpm": 50, "lowpass": 600, "reverb_decay": 0.8, "vol_factor": 0.09,
|
||||
},
|
||||
"mystery": {
|
||||
"chords": [[165, 208, 262], [147, 185, 233], [131, 175, 220], [139, 185, 233]],
|
||||
"bpm": 50, "lowpass": 500, "reverb_decay": 0.9, "vol_factor": 0.10,
|
||||
},
|
||||
"angry": {
|
||||
"chords": [[131, 165, 196], [165, 208, 262], [147, 185, 233], [175, 220, 277]],
|
||||
"bpm": 130, "lowpass": 1000, "reverb_decay": 0.4, "vol_factor": 0.06,
|
||||
},
|
||||
"professional": {
|
||||
"chords": [[262, 330, 392], [392, 494, 587], [262, 330, 392], [330, 415, 523]],
|
||||
"bpm": 70, "lowpass": 1000, "reverb_decay": 0.4, "vol_factor": 0.06,
|
||||
},
|
||||
"normal": {
|
||||
"chords": [[220, 277, 330], [261, 329, 392], [196, 247, 294], [261, 329, 392]],
|
||||
"bpm": 70, "lowpass": 900, "reverb_decay": 0.5, "vol_factor": 0.07,
|
||||
},
|
||||
"funny": {
|
||||
"chords": [[523, 659, 784], [659, 784, 988], [784, 988, 1175], [523, 659, 784]],
|
||||
"bpm": 100, "lowpass": 1400, "reverb_decay": 0.25, "vol_factor": 0.04,
|
||||
},
|
||||
"inspiring": {
|
||||
"chords": [[262, 330, 392, 523], [294, 370, 440, 587], [330, 415, 523, 659], [392, 494, 587, 784]],
|
||||
"bpm": 80, "lowpass": 1200, "reverb_decay": 0.5, "vol_factor": 0.05,
|
||||
},
|
||||
# ── BGM_LIBRARY 扩展 ──
|
||||
"epic": {
|
||||
"chords": [[262, 330, 392], [330, 415, 523], [392, 494, 587], [440, 554, 659]],
|
||||
"bpm": 120, "lowpass": 1500, "reverb_decay": 0.3, "vol_factor": 0.06,
|
||||
},
|
||||
"tech": {
|
||||
"chords": [[294, 370, 440], [349, 440, 554], [392, 493, 587], [440, 554, 659]],
|
||||
"bpm": 120, "lowpass": 1800, "reverb_decay": 0.2, "vol_factor": 0.05,
|
||||
},
|
||||
"warm": {
|
||||
"chords": [[220, 277, 330], [196, 247, 294], [261, 329, 392], [220, 277, 330]],
|
||||
"bpm": 80, "lowpass": 700, "reverb_decay": 0.6, "vol_factor": 0.07,
|
||||
},
|
||||
"cinematic": {
|
||||
"chords": [[175, 220, 277], [196, 247, 294], [220, 277, 330], [261, 329, 392]],
|
||||
"bpm": 80, "lowpass": 900, "reverb_decay": 0.7, "vol_factor": 0.08,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_chord_pad(mood: str, duration: float, output: str) -> str:
|
||||
"""生成和弦垫音 (Tier 3)"""
|
||||
preset = CHORD_PRESETS.get(mood, CHORD_PRESETS["normal"])
|
||||
chord_freqs_list = preset["chords"]
|
||||
bpm = preset["bpm"]
|
||||
lowpass_freq = preset["lowpass"]
|
||||
reverb_decay = preset["reverb_decay"]
|
||||
vol_factor = preset["vol_factor"]
|
||||
|
||||
num_chords = len(chord_freqs_list)
|
||||
chord_duration = duration / num_chords
|
||||
transition = min(2.0, chord_duration * 0.3)
|
||||
|
||||
logger.info(f" Tier 3: {num_chords} 和弦 × {chord_duration:.1f}s, "
|
||||
f"低通={lowpass_freq}Hz, 混响={reverb_decay}")
|
||||
|
||||
# 生成每个和弦
|
||||
chord_wavs = []
|
||||
for idx, freqs in enumerate(chord_freqs_list):
|
||||
part_file = str(CACHE_DIR / f"_chord_{mood}_{idx}.wav")
|
||||
if os.path.exists(part_file):
|
||||
try: os.remove(part_file)
|
||||
except: pass
|
||||
|
||||
expressions = []
|
||||
for freq in freqs:
|
||||
for detune in [0.995, 1.000, 1.005]:
|
||||
expressions.append(f"sin(2*PI*{freq * detune}*t)")
|
||||
expr = f"({'+'.join(expressions)})/{len(expressions)}"
|
||||
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-f", "lavfi",
|
||||
"-i", f"aevalsrc={expr}:s=44100:c=mono",
|
||||
"-t", str(chord_duration),
|
||||
"-af", f"afade=t=in:ss=0:d={transition},"
|
||||
f"afade=t=out:st={chord_duration - transition}:d={transition},"
|
||||
f"volume={vol_factor}",
|
||||
"-acodec", "pcm_s16le", part_file,
|
||||
], capture_output=True, timeout=120, check=True)
|
||||
chord_wavs.append(part_file)
|
||||
|
||||
# 拼接
|
||||
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
|
||||
with open(concat_file, "w", encoding="utf-8") as f:
|
||||
for w in chord_wavs:
|
||||
f.write(f"file '{w}'\n")
|
||||
|
||||
# 全局效果
|
||||
if reverb_decay < 0.5:
|
||||
delays = "50|80|110"
|
||||
gains = f"{reverb_decay*0.6}|{reverb_decay*0.4}|{reverb_decay*0.2}"
|
||||
else:
|
||||
delays = "60|100|150|200"
|
||||
gains = f"{reverb_decay*0.6}|{reverb_decay*0.4}|{reverb_decay*0.2}|{reverb_decay*0.1}"
|
||||
|
||||
try:
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
|
||||
"-af", f"lowpass=f={lowpass_freq},aecho=0.8:0.7:{delays}:{gains}",
|
||||
"-acodec", "pcm_s16le", output,
|
||||
], capture_output=True, timeout=120, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
logger.warning("全局效果失败,回落无效果拼接")
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
|
||||
"-acodec", "pcm_s16le", output,
|
||||
], capture_output=True, check=True)
|
||||
|
||||
# 清理
|
||||
for w in chord_wavs:
|
||||
try: os.remove(w)
|
||||
except: pass
|
||||
try: os.remove(concat_file)
|
||||
except: pass
|
||||
|
||||
return output
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
|
||||
AVE 07_bgm_generator — 背景音乐生成模块 v3.0
|
||||
|
||||
版本: v3.0 | 更新: 2026-05-06
|
||||
|
||||
三阶路由策略:
|
||||
Tier 1: 本地 BGM 音乐库 (真实音乐,最高质量)
|
||||
Tier 2: mlx-audiocraft AI 生成 (本地 MLX,按需定制)
|
||||
Tier 3: FFmpeg 和弦垫音 (离线保障)
|
||||
|
||||
Tier 1 操作流程:
|
||||
1. 从 library.json 读取 mood→曲目映射
|
||||
2. 如果有匹配曲目 → 选择一首,用 FFmpeg 循环/截取到目标时长
|
||||
3. 应用淡入淡出过渡
|
||||
4. 混音时音量设为背景级 (0.15-0.25)
|
||||
|
||||
用法:
|
||||
generate_bgm(mood="funny", duration=60, output="/tmp/bgm.wav")
|
||||
generate_bgm(mood="happy", duration=30, output="/tmp/bgm.wav")
|
||||
"""
|
||||
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from lib.logger import get_logger
|
||||
|
||||
logger = get_logger("bgm")
|
||||
|
||||
# ── 路径 ──
|
||||
LIBRARY_DIR = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "cache" / "bgm_library"
|
||||
LIBRARY_CONFIG = LIBRARY_DIR / "library.json"
|
||||
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
|
||||
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/bgm")))
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def generate_bgm(
|
||||
mood: str = "normal",
|
||||
duration: float = 30.0,
|
||||
output: str = "bgm.wav",
|
||||
pixabay_key: str = "",
|
||||
style: str = "",
|
||||
use_mlx: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
三阶背景音乐生成 v3.0
|
||||
|
||||
参数:
|
||||
mood: 情绪类型
|
||||
duration: 目标时长(秒)
|
||||
output: 输出路径
|
||||
pixabay_key: (保留,暂未用)
|
||||
style: 风格别名
|
||||
use_mlx: 是否尝试 mlx-audiocraft (需提前安装)
|
||||
|
||||
返回:
|
||||
生成的 BGM 文件路径
|
||||
"""
|
||||
if style and not mood:
|
||||
mood = _style_to_mood(style)
|
||||
|
||||
effective_mood = mood or "normal"
|
||||
logger.info(f"BGM v3: mood={effective_mood}, duration={duration}s, output={output}")
|
||||
|
||||
# ── Tier 1: 音乐库 ──
|
||||
result = _try_library(effective_mood, duration, output)
|
||||
if result:
|
||||
logger.info(f"✅ Tier 1 (音乐库) → {output}")
|
||||
return result
|
||||
|
||||
# ── Tier 2: mlx-audiocraft (按需) ──
|
||||
if use_mlx:
|
||||
result = _try_mlx_audiocraft(effective_mood, duration, output)
|
||||
if result:
|
||||
logger.info(f"✅ Tier 2 (mlx-audiocraft) → {output}")
|
||||
return result
|
||||
logger.warning("mlx-audiocraft 回落 → Tier 3")
|
||||
|
||||
# ── Tier 3: FFmpeg 和弦垫音 ──
|
||||
result = _chord_pad(effective_mood, duration, output)
|
||||
logger.info(f"✅ Tier 3 (和弦垫音) → {output}")
|
||||
return result
|
||||
|
||||
|
||||
def _try_library(mood: str, duration: float, output: str) -> str | None:
|
||||
"""Tier 1: 从本地音乐库载入 BGM 并匹配时长"""
|
||||
if not LIBRARY_CONFIG.exists():
|
||||
logger.info(" library.json 不存在,跳过 Tier 1")
|
||||
return None
|
||||
|
||||
with open(LIBRARY_CONFIG, encoding="utf-8") as f:
|
||||
lib = json.load(f)
|
||||
|
||||
tracks = lib.get("库", {}).get(mood, {}).get("曲目", [])
|
||||
valid = [t for t in tracks if t.get("文件名")]
|
||||
if not valid:
|
||||
logger.info(f" [{mood}] 库中无曲目")
|
||||
return None
|
||||
|
||||
# 选择曲目: 优先选最接近目标时长的,没有完美匹配就选最短的
|
||||
chosen = min(valid, key=lambda t: abs((t.get("时长") or 30) - duration))
|
||||
track_file = LIBRARY_DIR / mood / chosen["文件名"]
|
||||
|
||||
if not track_file.exists():
|
||||
logger.warning(f" 曲目文件不存在: {track_file}")
|
||||
return None
|
||||
|
||||
track_duration = chosen.get("时长", 30)
|
||||
title = chosen.get("标题", chosen["文件名"])
|
||||
logger.info(f" [{mood}] 选用: {title} ({track_duration:.1f}s) → 适配 {duration:.0f}s")
|
||||
|
||||
os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
|
||||
|
||||
# FFmpeg 循环/截取
|
||||
if track_duration >= duration:
|
||||
# 截取前段
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(track_file),
|
||||
"-t", str(duration),
|
||||
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
|
||||
"-acodec", "pcm_s16le",
|
||||
output,
|
||||
]
|
||||
else:
|
||||
# 循环补齐
|
||||
loops = int(duration / track_duration) + 1
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-stream_loop", str(loops),
|
||||
"-i", str(track_file),
|
||||
"-t", str(duration),
|
||||
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
|
||||
"-acodec", "pcm_s16le",
|
||||
output,
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True, timeout=30)
|
||||
return output
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning(f" FFmpeg 处理失败: {e.stderr[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
def _try_mlx_audiocraft(mood: str, duration: float, output: str) -> str | None:
|
||||
"""Tier 2: 使用 mlx-audiocraft 生成音乐 (需 pip install mlx-audiocraft)
|
||||
|
||||
注意事项:
|
||||
- 国内用户需设置 HF_ENDPOINT=https://hf-mirror.com 下载模型
|
||||
- 首次运行会下载 ~300MB 模型 (musicgen-small)
|
||||
- 模型缓存后二次加载约 15s
|
||||
- M1 上 10s 音乐生成约 46s
|
||||
"""
|
||||
try:
|
||||
import mlx_audiocraft
|
||||
except ImportError:
|
||||
logger.info(" mlx-audiocraft 未安装,跳过 Tier 2")
|
||||
return None
|
||||
|
||||
# mood→prompt 映射 (与 BGM_LIBRARY.md 同步)
|
||||
prompts = {
|
||||
"calm": "soft ambient piano, gentle pad, nature sounds, 60 BPM, no percussion, no vocals",
|
||||
"soothing": "warm ambient drone, slow strings, peaceful atmosphere, 50 BPM, no vocals",
|
||||
"happy": "bright ukulele, cheerful melody, acoustic guitar, 90 BPM, no vocals",
|
||||
"excited": "upbeat electronic, driving beat, synth pads, 120 BPM, no vocals",
|
||||
"sad": "melancholic piano, slow strings, emotional, 55 BPM, no vocals",
|
||||
"mystery": "dark ambient, deep synth pad, suspenseful, 60 BPM, no vocals",
|
||||
"angry": "intense orchestral, heavy drums, dramatic, 110 BPM, no vocals",
|
||||
"professional": "corporate soft jazz, clean piano, polished, 75 BPM, no vocals",
|
||||
"funny": "playful xylophone, quirky light music, cartoon style, 100 BPM, no vocals",
|
||||
"inspiring": "uplifting cinematic, building strings, triumphant, 85 BPM, no vocals",
|
||||
"normal": "soft background music, gentle, ambient, 70 BPM, no vocals",
|
||||
# ── BGM_LIBRARY.md 扩展 ──
|
||||
"epic": "epic orchestral, brass and strings, powerful drums, triumphant, building tension, 120 BPM, no vocals",
|
||||
"tech": "electronic futuristic, synth pads, pulsing beat, modern, innovative, 120 BPM, no vocals",
|
||||
"warm": "warm acoustic guitar, soft piano, gentle, cozy atmosphere, 80 BPM, no vocals",
|
||||
"cinematic": "cinematic ambient, orchestral swells, emotional strings, majestic, 80 BPM, no vocals",
|
||||
"documentary": "documentary style, soft piano, ambient strings, professional, serious, 90 BPM, no vocals",
|
||||
}
|
||||
prompt = prompts.get(mood, prompts["normal"])
|
||||
|
||||
logger.info(f" mlx-audiocraft: {prompt}")
|
||||
try:
|
||||
from mlx_audiocraft import MusicGen
|
||||
import os as _os
|
||||
|
||||
# 设置国内镜像 (如果没设且连不上 huggingface)
|
||||
if "HF_ENDPOINT" not in _os.environ:
|
||||
_os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
||||
|
||||
# 加载模型 (会缓存,第二次更快)
|
||||
gen_duration = min(duration, 15) # 音乐生成最长 15s
|
||||
model = MusicGen.get_pretrained("facebook/musicgen-small")
|
||||
|
||||
# 生成
|
||||
import mlx.core as mx
|
||||
wav = model.generate(
|
||||
descriptions=[prompt],
|
||||
progress=False,
|
||||
)
|
||||
# wav 是 mlx.core.array, shape=(1, samples), float32, 32000Hz
|
||||
audio = wav[0] # shape: (samples,)
|
||||
sample_rate = model.sample_rate # 32000
|
||||
|
||||
# 转为 numpy
|
||||
import numpy as np
|
||||
audio_np = np.array(audio, dtype=np.float32)
|
||||
|
||||
# 保存为 raw float -> FFmpeg 转 WAV (避免 soundfile/scipy 依赖问题)
|
||||
raw_path = str(CACHE_DIR / f"_mlx_raw_{mood}.f32")
|
||||
audio_np.tofile(raw_path)
|
||||
|
||||
# 用 FFmpeg 转 44.1kHz WAV 并调整时长
|
||||
if duration <= gen_duration:
|
||||
# 截取
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
|
||||
"-i", raw_path,
|
||||
"-t", str(duration),
|
||||
"-ar", "44100",
|
||||
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
|
||||
"-acodec", "pcm_s16le", output,
|
||||
], capture_output=True, check=True, timeout=30)
|
||||
else:
|
||||
# 循环
|
||||
loops = int(duration / gen_duration) + 1
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y",
|
||||
"-stream_loop", str(loops),
|
||||
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
|
||||
"-i", raw_path,
|
||||
"-t", str(duration),
|
||||
"-ar", "44100",
|
||||
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
|
||||
"-acodec", "pcm_s16le", output,
|
||||
], capture_output=True, check=True, timeout=30)
|
||||
|
||||
# 清理
|
||||
try: _os.remove(raw_path)
|
||||
except: pass
|
||||
|
||||
return output
|
||||
except Exception as e:
|
||||
logger.warning(f" mlx-audiocraft 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _chord_pad(mood: str, duration: float, output: str) -> str:
|
||||
"""Tier 3: FFmpeg 和弦垫音 (v2 保留)"""
|
||||
from .chord_pad import generate_chord_pad
|
||||
return generate_chord_pad(mood, duration, output)
|
||||
|
||||
|
||||
def _style_to_mood(style: str) -> str:
|
||||
s = style.lower()
|
||||
if "upbeat" in s or "playful" in s: return "happy"
|
||||
if "cinematic" in s or "dramatic" in s: return "mystery"
|
||||
if "jazz" in s or "corporate" in s: return "professional"
|
||||
if "nature" in s or "ambient" in s: return "calm"
|
||||
if "funny" in s or "cartoon" in s: return "funny"
|
||||
if "inspiring" in s or "motivational" in s: return "inspiring"
|
||||
return "normal"
|
||||
|
||||
|
||||
def get_available_moods() -> list[str]:
|
||||
from .chord_pad import CHORD_PRESETS
|
||||
return list(CHORD_PRESETS.keys())
|
||||
Reference in New Issue
Block a user