feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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())