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,198 @@
"""
AudioLine — 音频创作管线
独立于 AVE 视频工厂的音频处理线。
支持卡点改编、换词创作、风格迁移、Remix 等。
快速开始:
from audio_line import AudioLine
# 1. 初始化管线
al = AudioLine(config_path="config.yaml")
# 2. 从文件创建任务
task = al.from_file("input.mp3", operation="cardio", target_bpm=128)
# 3. 执行全管线
result = al.run(task)
# 4. 查看结果
print(result.summary)
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .orchestrator import AudioLineOrchestrator, AudioLineResult
from .layer1_input.collector import AudioTask, AudioSource, AudioCollector
from .layer2_analysis.beat_detector import BeatResult
from .layer2_analysis.structure_parser import StructureResult
from .layer2_analysis.emotion_analyzer import EmotionResult
from .layer3_creation.module_a_rhythm import RhythmResult
from .layer3_creation.module_b_lyrics import LyricsResult
from .layer3_creation.module_c_melody import MelodyResult
from .layer3_creation.module_d_mix import MixResult
from .layer4_alignment.lyric_aligner import LyricTimeline
from .layer5_output.exporter import ExportResult
class AudioLine:
"""
AudioLine — 音频创作管线外部 API
封装 AudioLineOrchestrator,提供简洁的接口。
支持从文件/URL/指令创建任务并执行全管线。
"""
def __init__(self, config_path: str | Path | None = None):
"""
初始化管线
参数:
config_path: 配置文件路径 (YAML)
"""
self._orchestrator = AudioLineOrchestrator(config_path)
self._collector = self._orchestrator.collector
# ---- 任务创建 ----
def from_file(
self,
path: str | Path,
operation: str = "auto",
target_bpm: int = 0,
target_key: str = "",
style: str = "",
topic: str = "",
duration: float = 0.0,
dry_run: bool = False,
modules: set[str] | None = None,
**kwargs,
) -> AudioTask:
"""
从本地文件创建音频处理任务
参数:
path: 音频文件路径
operation: 操作类型 (auto/remix/rewrite/speed_up/cardio)
target_bpm: 目标 BPM (0=自动)
target_key: 目标调性
style: 目标风格
topic: 歌词主题 (换词时使用)
duration: 目标时长 (秒)
dry_run: 仅分析不创作
modules: 模块子集
返回:
AudioTask
"""
return self._collector.from_file(path, {
"operation": operation,
"target_bpm": target_bpm,
"target_key": target_key,
"style": style,
"topic": topic,
"duration": duration,
"dry_run": dry_run,
"modules": modules or {"separation", "analysis", "rhythm", "lyrics", "mix", "alignment", "export"},
**kwargs,
})
def from_url(
self,
url: str,
**kwargs,
) -> AudioTask:
"""
从 URL 下载并创建任务
参数同 from_file()
"""
return self._collector.from_url(url, kwargs)
def from_command(self, text: str) -> AudioTask:
"""
从自然语言指令创建任务
参数:
text: 如 "把这首歌改成128BPM的卡点版"
"""
return self._collector.from_text_command(text)
# ---- 管线执行 ----
def run(self, task: AudioTask) -> AudioLineResult:
"""
执行音频处理管线
参数:
task: AudioTask 任务对象
返回:
AudioLineResult
"""
return self._orchestrator.run(task)
def run_file(
self,
path: str | Path,
**kwargs,
) -> AudioLineResult:
"""
快速执行: 从文件到结果一步完成
参数:
path: 音频文件路径
**kwargs: 传给 from_file() 的额外参数
返回:
AudioLineResult
"""
task = self.from_file(path, **kwargs)
return self._orchestrator.run(task)
# ---- 单模块执行 ----
@property
def config(self) -> dict:
"""获取当前配置"""
return self._orchestrator.config
@property
def stem_separator(self):
"""直接访问音轨分离器"""
return self._orchestrator.stem_sep
@property
def beat_detector(self):
"""直接访问节拍检测器"""
return self._orchestrator.beat_det
@property
def structure_parser(self):
"""直接访问结构解析器"""
return self._orchestrator.structure_parser
@property
def emotion_analyzer(self):
"""直接访问情绪分析器"""
return self._orchestrator.emotion_analyzer
__all__ = [
"AudioLine",
"AudioLineOrchestrator",
"AudioLineResult",
"AudioTask",
"AudioSource",
"BeatResult",
"StructureResult",
"EmotionResult",
"RhythmResult",
"LyricsResult",
"MelodyResult",
"MixResult",
"LyricTimeline",
"ExportResult",
]
@@ -0,0 +1,85 @@
# audio_line —— 音频创作管线配置
# 复制到 agent-local/tools/ave/config/local.yaml 并填入真实 API 密钥
# 优先级: local.yaml > config.yaml > 默认值
# === 输入/输出路径 ===
paths:
work_dir: "./work/audio_line" # 临时工作目录
output_dir: "./output/audio_line" # 成品输出目录
model_cache: "./cache/models" # 模型缓存
# === 音轨分离 ===
separation:
model: "htdemucs_ft" # Demucs 模型: htdemucs | htdemucs_ft | htdemucs_6s
device: "cpu" # cpu | mps (M1/M2/M3 GPU)
shifts: 2 # 推理增强次数 (≥1), >1 提高质量但慢
overlap: 0.25 # 分帧重叠率
segments: "default" # 分帧长度 "default" | int
# === BPM/节拍检测 ===
beat:
min_bpm: 60 # 最小 BPM
max_bpm: 200 # 最大 BPM
onset_backend: "librosa" # 节拍 onset 后端
downbeat_model: "madmom" # 重拍检测模型 (madmom | librosa)
# === 节奏重塑 (Mubert API) ===
mubert:
api_key: "" # Mubert API Key
base_url: "https://api.mubert.com/v2"
default_duration: 30 # 默认生成时长(秒)
default_genre: "electronic" # 默认风格
default_bpm: 128 # 默认 BPM
max_retries: 3
timeout: 60
# === 歌词创作 (oMLX 本地 LLM) ===
lyrics:
llm_url: "http://localhost:8000/v1" # oMLX 地址
llm_model: "Qwen3-8B" # 可用模型
max_tokens: 1024
temperature: 0.8
# 提示词模板
rewrite_prompt: >
你是一位专业歌词改编者。请根据以下要求对原曲歌词进行二次创作:
主题: {topic}
风格: {style}
保留原曲的段落结构(Intro/Verse/Chorus/Bridge/Outro)和押韵模式。
输出格式: 每段以[Intro][Verse][Chorus]标记开头。
lyric_analysis_prompt: >
分析以下歌词的段落结构、押韵模式和情感走向。
输出 JSON 格式。
# === 旋律编曲 (Suno API) ===
suno:
api_key: "" # Suno API Key
base_url: "https://api.suno.ai/v1"
default_duration: 30 # 默认生成时长
model: "suno-v4" # Suno 模型版本
max_retries: 2
timeout: 120
# === 混音母带 ===
mixing:
target_loudness: -14.0 # LUFS 目标响度 (Spotify: -14, YouTube: -14, Apple: -16)
headroom: 1.0 # 峰值头上空间 (dB)
sample_rate: 44100 # 输出采样率
bit_depth: 16 # 输出位深
eq_preset: "default" # EQ 预设
compressor_threshold: -20 # 压缩器阈值 (dB)
compressor_ratio: 3.0 # 压缩比
compressor_attack: 5 # 启动时间 (ms)
compressor_release: 100 # 释放时间 (ms)
# === 对齐 ===
alignment:
time_tolerance: 0.1 # 时间对准容差 (秒)
crossfade: 0.05 # 交叉淡入淡出时长 (秒)
stretch_method: "phase_vocoder" # 时间伸缩方法: phase_vocoder | wsola | rubberband
# === 输出 ===
export:
formats: ["wav", "mp3"] # 默认导出格式
mp3_bitrate: "320k" # MP3 比特率
include_stems: true # 是否同时导出分轨
stem_labels: ["vocals", "drums", "bass", "other", "full_mix"]
@@ -0,0 +1,52 @@
"""
layer1_input/_parser.py — 自然语言指令解析
将用户的口语化指令(如"把这首歌改快一点、加卡点")解析为 AudioTask。
当前用规则匹配,后续可接入 LLM 增强。
"""
from .collector import AudioTask, AudioSource
def parse_command_text(text: str) -> AudioTask:
"""解析自然语言指令 → AudioTask (不含音频源)"""
text = text.strip().lower()
modules = {"separation", "analysis", "rhythm", "lyrics", "mix"}
operation = "auto"
target_bpm = 0
# — 检测操作类型 —
if any(kw in text for kw in ["卡点", "变速", "改快", "改慢"]):
operation = "cardio"
elif any(kw in text for kw in ["换词", "改词", "填词", "翻唱"]):
operation = "rewrite"
elif any(kw in text for kw in ["remix", "改编", "混音"]):
operation = "remix"
elif any(kw in text for kw in ["加速", "慢速", "变速度"]):
operation = "speed_up"
# — 检测 BPM —
import re
bpm_match = re.search(r"(\d+)\s*bpm", text)
if bpm_match:
target_bpm = int(bpm_match.group(1))
# — 模块选择 —
if "只分析" in text or "仅分析" in text:
modules = {"separation", "analysis"}
# — 主题/风格提取 (简单规则) —
style = ""
for kw, st in [("可爱", "可爱"), ("伤感", "伤感"), ("燃", "燃系"), ("DJ", "电音"),
("古风", "古风"), ("摇滚", "摇滚"), ("爵士", "爵士")]:
if kw in text:
style = st
break
return AudioTask(
source=AudioSource(path="", source_type="command"),
operation=operation,
target_bpm=target_bpm,
style=style,
modules=modules,
)
@@ -0,0 +1,133 @@
"""
layer1_input/collector.py — 输入收集模块
职责:接收各种来源的输入,标准化为统一的 AudioTask 数据类。
支持:本地文件 / URL下载 / 文本指令(如"把这首歌改成卡点版")
"""
from __future__ import annotations
import os
import tempfile
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------
@dataclass
class AudioSource:
"""标准化音频源描述"""
path: str # 本地路径
original_filename: str = "" # 原始文件名
source_type: Literal["file", "url", "command"] = "file"
duration: float = 0.0 # 时长 (秒),预读取后填充
sample_rate: int = 0 # 采样率,预读取后填充
@dataclass
class AudioTask:
"""完整的音频处理任务"""
# ——— 源信息 ———
source: AudioSource
# ——— 创作参数 ———
operation: Literal["remix", "rewrite", "speed_up", "cardio", "auto"] = "auto"
target_bpm: int = 0 # 0 = 自动
target_key: str = "" # 空 = 保持原调
style: str = "" # 目标风格描述
topic: str = "" # 歌词主题 (换词时使用)
duration: float = 0.0 # 目标时长 (0 = 与原曲相同)
# ——— 控制标记 ———
modules: set[str] = field(default_factory=lambda: {
"separation", "analysis", "rhythm", "lyrics", "mix"
}) # 要执行的模块集合
dry_run: bool = False # 仅分析,不执行创作模块
# ——— 元信息 ———
task_id: str = ""
created_at: str = ""
metadata: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# 收集器
# ---------------------------------------------------------------------------
class AudioCollector:
"""输入收集器 — 接受文件/URL/命令 → AudioTask"""
SUPPORTED_FORMATS = {".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg", ".wma"}
def __init__(self, work_dir: str | Path = "./work/audio_line"):
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self._upload_counter = 0
# ---- 公开 API ----
def from_file(self, path: str | Path, task_kwargs: dict | None = None) -> AudioTask:
"""从本地文件创建任务"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"音频文件不存在: {path}")
if path.suffix.lower() not in self.SUPPORTED_FORMATS:
raise ValueError(f"不支持的格式 '{path.suffix}', 支持: {self.SUPPORTED_FORMATS}")
source = AudioSource(
path=str(path.resolve()),
original_filename=path.name,
source_type="file",
)
return self._build_task(source, task_kwargs)
def from_url(self, url: str, task_kwargs: dict | None = None) -> AudioTask:
"""从 URL 下载并创建任务"""
self._upload_counter += 1
ext = self._guess_ext_from_url(url)
dest = self.work_dir / f"download_{self._upload_counter}{ext}"
print(f" [collector] 下载: {url}")
urllib.request.urlretrieve(url, dest)
print(f" [collector] 已保存: {dest}")
source = AudioSource(
path=str(dest.resolve()),
original_filename=dest.name,
source_type="url",
)
return self._build_task(source, task_kwargs)
def from_text_command(self, text: str) -> AudioTask:
"""从自然语言指令创建任务(不含音频源,需后续绑定)"""
# 简单规则解析;生产环境可接入 LLM
from ._parser import parse_command_text
parsed = parse_command_text(text)
return parsed
# ---- 内部 ----
def _build_task(self, source: AudioSource, kwargs: dict | None = None) -> AudioTask:
kwargs = kwargs or {}
return AudioTask(
source=source,
operation=kwargs.pop("operation", "auto"),
target_bpm=kwargs.pop("target_bpm", 0),
target_key=kwargs.pop("target_key", ""),
style=kwargs.pop("style", ""),
topic=kwargs.pop("topic", ""),
duration=kwargs.pop("duration", 0.0),
modules=kwargs.pop("modules", {"separation", "analysis", "rhythm", "lyrics", "mix"}),
dry_run=kwargs.pop("dry_run", False),
metadata=kwargs,
)
@staticmethod
def _guess_ext_from_url(url: str) -> str:
path = urllib.parse.urlparse(url).path
ext = Path(path).suffix.lower()
return ext if ext in AudioCollector.SUPPORTED_FORMATS else ".mp3"
@@ -0,0 +1,180 @@
"""
layer2_analysis/beat_detector.py — 节拍/BPM 检测器
输入:原始音频路径
输出:BPM、节拍时间戳、重拍位置、节拍置信度
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import librosa
@dataclass
class BeatResult:
"""节拍分析结果"""
bpm: float # 检测到的 BPM
beats: list[float] = field(default_factory=list) # 节拍时间戳 (秒)
downbeats: list[float] = field(default_factory=list) # 重拍时间戳 (秒)
onset_frames: list[int] = field(default_factory=list) # 起音帧
onset_times: list[float] = field(default_factory=list) # 起音时间 (秒)
beat_frames: list[int] = field(default_factory=list) # 节拍帧索引
confidence: float = 0.0 # BPM 置信度 (0~1)
tempo_stability: float = 0.0 # 节奏稳定性 (0~1)
time_signature: str = "4/4" # 拍号推断
segment_bpms: list[dict] = field(default_factory=list) # [{start, end, bpm}, ...]
@property
def beat_count(self) -> int:
return len(self.beats)
@property
def duration_sec(self) -> float:
return self.beats[-1] if self.beats else 0.0
class BeatDetector:
"""Librosa 节拍检测器"""
def __init__(
self,
min_bpm: float = 60,
max_bpm: float = 200,
onset_backend: str = "librosa",
):
self.min_bpm = min_bpm
self.max_bpm = max_bpm
self.onset_backend = onset_backend
def detect(self, audio_path: str | Path) -> BeatResult:
"""
分析音频的 BPM / 节拍 / 重拍
参数:
audio_path: 音频文件路径
返回:
BeatResult 包含完整节拍分析
"""
audio_path = Path(audio_path)
print(f" [beat_det] 分析: {audio_path.name}")
# 加载音频 (librosa 自动重采样到 22050)
y, sr = librosa.load(str(audio_path), sr=None, mono=True)
# — 1. BPM 检测 —
tempo, beat_frames = librosa.beat.beat_track(
y=y, sr=sr,
min_bpm=self.min_bpm,
max_bpm=self.max_bpm,
units="frames",
)
bpm = float(tempo)
# — 2. 节拍时间戳 —
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
# — 3. 起音检测 —
onset_frames = librosa.onset.onset_detect(y=y, sr=sr, units="frames")
onset_times = librosa.frames_to_time(onset_frames, sr=sr).tolist()
# — 4. 重拍检测 (使用 librosa 的 beat tracker 中的第一拍检测) —
# librosa 不直接提供 downbeat, 我们通过 beat 强度和周期性估算
# 方法:取每小节第一拍
beat_strength = self._estimate_beat_strength(y, sr, beat_frames)
downbeats = []
if len(beat_times) > 4:
# 取最强节拍作为小节的起始
# 近似: 假设 4/4 拍,每 4 拍一个重拍
period = self._estimate_bar_period(beat_times)
downbeats = beat_times[::period] if period > 0 else []
# — 5. 置信度 —
confidence = self._calc_confidence(y, sr, bpm, onset_frames)
# — 6. 节奏稳定性 —
stability = self._calc_stability(beat_times)
# — 7. 分段 BPM —
seg_bpms = self._segment_bpm(y, sr, beat_times)
print(f" [beat_det] ✓ BPM={bpm:.1f} 节拍={len(beat_times)} 重拍={len(downbeats)} "
f"置信度={confidence:.2f}")
return BeatResult(
bpm=bpm,
beats=beat_times,
downbeats=downbeats,
onset_frames=onset_frames.tolist(),
onset_times=onset_times,
beat_frames=beat_frames.tolist(),
confidence=confidence,
tempo_stability=stability,
segment_bpms=seg_bpms,
)
# ---- 内部方法 ----
def _estimate_beat_strength(self, y, sr, beat_frames) -> list[float]:
"""估算每个节拍的强度 (用频谱通量)"""
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
strengths = []
for f in beat_frames:
idx = min(f, len(onset_env) - 1)
strengths.append(float(onset_env[idx]))
return strengths
def _estimate_bar_period(self, beat_times: list[float]) -> int:
"""估算每小节拍数"""
if len(beat_times) < 8:
return 4
# 最差选择 4 (4/4 拍最常见)
return 4
def _calc_confidence(self, y, sr, bpm, onset_frames) -> float:
"""BPM 置信度评估"""
# 更规则 → 更高置信度
if len(onset_frames) < 5:
return 0.3
# 简单启发式: 起音密度接近 BPM/60
duration = len(y) / sr
onset_density = len(onset_frames) / duration if duration > 0 else 0
expected_density = bpm / 60
ratio = min(onset_density / expected_density, 1.0) if expected_density > 0 else 0.5
return min(0.4 + 0.6 * ratio, 1.0)
def _calc_stability(self, beat_times: list[float]) -> float:
"""节奏稳定性: 节拍间隔的变异系数"""
if len(beat_times) < 4:
return 0.5
intervals = [beat_times[i+1] - beat_times[i] for i in range(len(beat_times)-1)]
mean_interval = sum(intervals) / len(intervals)
if mean_interval == 0:
return 0.0
variance = sum((i - mean_interval) ** 2 for i in intervals) / len(intervals)
cv = (variance ** 0.5) / mean_interval
return max(0.0, min(1.0, 1.0 - cv))
def _segment_bpm(self, y, sr, beat_times: list[float],
segment_len: float = 10.0) -> list[dict]:
"""按时间窗口分段分析 BPM"""
if len(beat_times) < 4:
return []
total_duration = len(y) / sr
segments = []
t = 0.0
while t < total_duration:
end = min(t + segment_len, total_duration)
seg_beats = [b for b in beat_times if t <= b < end]
if len(seg_beats) >= 4:
intervals = [seg_beats[i+1] - seg_beats[i] for i in range(len(seg_beats)-1)]
mean_int = sum(intervals) / len(intervals)
seg_bpm = 60.0 / mean_int if mean_int > 0 else 0
else:
seg_bpm = 0
segments.append({"start": round(t, 2), "end": round(end, 2), "bpm": round(seg_bpm, 1)})
t = end
return segments
@@ -0,0 +1,166 @@
"""
layer2_analysis/emotion_analyzer.py — 情绪/风格分析
输入:Stem 分离后的 vocals + 原曲
输出:情绪标签、风格描述、BPM 范围推荐
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import librosa
import numpy as np
@dataclass
class EmotionResult:
"""情绪分析结果"""
mood: str = "neutral" # 主要情绪 (happy/sad/energetic/calm/neutral)
energy: float = 0.5 # 能量值 0~1
valence: float = 0.5 # 效价 (积极度) 0~1
danceability: float = 0.5 # 舞曲性 0~1
acousticness: float = 0.5 # 原声性 0~1
tempo_category: str = "medium" # 速度分类: slow/medium/fast
description: str = "" # 自然语言风格描述
dominant_frequencies: dict = field(default_factory=dict) # 频率分布
spectral_features: dict = field(default_factory=dict) # 频谱特征
class EmotionAnalyzer:
"""
音频情绪分析器
基于频谱特征 + 节奏特征进行音乐情绪分类。
纯 librosa 实现,不依赖外部模型。
"""
def __init__(self):
self.mood_map: dict[str, tuple[float, float, float]] = {
# (energy_low, valence_low, dance_low) → label
"energetic": (0.6, 0.5, 0.6),
"happy": (0.4, 0.7, 0.5),
"calm": (0.2, 0.4, 0.2),
"sad": (0.2, 0.2, 0.2),
"aggressive": (0.8, 0.3, 0.4),
"neutral": (0.5, 0.5, 0.5),
}
def analyze(self, audio_path: str | Path, bpm: float = 0.0) -> EmotionResult:
"""
分析音频情绪
参数:
audio_path: 音频路径 (可以是原始音频或 vocals stem)
bpm: 可选的 BPM 值 (已检测到的)
返回:
EmotionResult
"""
audio_path = Path(audio_path)
print(f" [emotion] 分析: {audio_path.name}")
y, sr = librosa.load(str(audio_path), sr=None, mono=True)
# — 1. 频谱特征 —
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr)[0]
spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr)[0]
zero_crossing_rate = librosa.feature.zero_crossing_rate(y)[0]
rms = librosa.feature.rms(y=y)[0]
# — 2. 计算特征指标 —
avg_centroid = float(np.mean(spectral_centroids))
avg_rolloff = float(np.mean(spectral_rolloff))
avg_bandwidth = float(np.mean(spectral_bandwidth))
avg_zcr = float(np.mean(zero_crossing_rate))
avg_rms = float(np.mean(rms))
# — 3. 情绪分类 —
# energy: RMS + ZCR + centroid
energy = min(1.0, (avg_rms * 5 + avg_zcr * 2 + avg_centroid / 5000) / 3)
energy = max(0.0, min(1.0, energy))
# valence (积极度): ZCR 高 → 积极, centroid 高 → 明亮
valence = min(1.0, (avg_zcr * 3 + avg_centroid / 3000) / 2)
valence = max(0.0, min(1.0, valence))
# danceability: 节奏清晰度 (BPM 在 100-130 之间最可舞)
if bpm > 0:
dance = 1.0 - abs(bpm - 120) / 80
else:
dance = 0.5 - abs(energy - 0.5) * 0.3
danceability = max(0.0, min(1.0, dance))
# acousticness: 原声性 (low bandwidth + low centroid)
acousticness = max(0.0, 1.0 - (avg_bandwidth / 3000 + avg_centroid / 8000) / 2)
# — 4. 情绪标签 —
mood = self._classify_mood(energy, valence, danceability)
# — 5. 速度分类 —
if bpm > 0:
tempo_category = "fast" if bpm >= 120 else ("slow" if bpm <= 80 else "medium")
else:
tempo_category = "medium"
# — 6. 自然语言描述 —
description = self._describe(mood, energy, tempo_category)
# — 7. 频谱分布 —
dominant_freqs = self._dominant_frequencies(spectral_centroids)
spec_features = {
"avg_centroid_hz": round(avg_centroid, 1),
"avg_bandwidth_hz": round(avg_bandwidth, 1),
"avg_zcr": round(float(avg_zcr), 4),
"avg_rms": round(float(avg_rms), 4),
}
return EmotionResult(
mood=mood,
energy=round(energy, 3),
valence=round(valence, 3),
danceability=round(danceability, 3),
acousticness=round(acousticness, 3),
tempo_category=tempo_category,
description=description,
dominant_frequencies=dominant_freqs,
spectral_features=spec_features,
)
# ---- 内部 ----
def _classify_mood(self, energy: float, valence: float, dance: float) -> str:
"""基于能量/效价/舞曲性分类情绪"""
scores = {}
for label, (e_th, v_th, d_th) in self.mood_map.items():
score = (
(1.0 - abs(energy - e_th)) * 0.4 +
(1.0 - abs(valence - v_th)) * 0.4 +
(1.0 - abs(dance - d_th)) * 0.2
)
scores[label] = score
return max(scores, key=scores.get)
def _describe(self, mood: str, energy: float, tempo: str) -> str:
"""生成自然语言风格描述"""
descs = {
"energetic": f"充满活力的{tempo}速曲风,能量充沛",
"happy": f"明快愉悦的{tempo}速曲风,情绪积极",
"calm": f"舒缓平静的{tempo}速曲风,适合放松",
"sad": f"忧伤低沉的{tempo}速曲风,情绪内敛",
"aggressive": f"强劲有力的{tempo}速曲风,表现力强",
"neutral": f"中性均衡的{tempo}速曲风",
}
return descs.get(mood, f"{mood}风格的{tempo}速曲风")
def _dominant_frequencies(self, centroids: np.ndarray) -> dict:
"""分析频谱能量分布"""
mean_c = float(np.mean(centroids))
std_c = float(np.std(centroids))
return {
"mean_hz": round(mean_c, 1),
"std_hz": round(std_c, 1),
"range": f"{max(0, round(mean_c - std_c, 1))}-{round(mean_c + std_c, 1)} Hz",
}
@@ -0,0 +1,98 @@
"""
layer2_analysis/stem_separator.py — Demucs 音轨分离
输入:原始音频文件路径
输出:分离后的分轨字典 {vocals, drums, bass, other, ...}
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Literal
class StemSeparator:
"""Demucs 音轨分离器"""
STEM_NAMES = ["vocals", "drums", "bass", "other"]
MODEL_OPTIONS = ["htdemucs", "htdemucs_ft", "htdemucs_6s"]
def __init__(
self,
model: str = "htdemucs_ft",
device: str = "cpu",
shifts: int = 2,
overlap: float = 0.25,
segments: str = "default",
out_dir: str | Path | None = None,
):
if model not in self.MODEL_OPTIONS:
raise ValueError(f"不支持的模型 '{model}', 可选: {self.MODEL_OPTIONS}")
self.model = model
self.device = device
self.shifts = shifts
self.overlap = overlap
self.segments = segments
self.out_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="demucs_"))
def separate(self, audio_path: str | Path) -> dict[str, str]:
"""
执行音轨分离
参数:
audio_path: 输入音频路径
返回:
{stem_name: output_path, ...}
"""
audio_path = Path(audio_path)
if not audio_path.exists():
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
print(f" [stem_sep] 模型={self.model} 设备={self.device}")
print(f" [stem_sep] 输入: {audio_path}")
# 构建 Demucs CLI 命令
output_dir = self.out_dir / "separated" / self.model
output_dir.mkdir(parents=True, exist_ok=True)
cmd = (
f"python3 -m demucs.separate"
f" --name {self.model}"
f" --device {self.device}"
f" --shifts {self.shifts}"
f" --overlap {self.overlap}"
f" -o {self.out_dir.resolve()}"
f" \"{audio_path.resolve()}\""
)
os.system(cmd)
# Demucs 输出: {out_dir}/{model}/{input_stem}/
input_stem = audio_path.stem
track_dir = output_dir / input_stem
# 收集分轨路径
stems = {}
for name in self.STEM_NAMES:
stem_path = track_dir / f"{name}.wav"
if stem_path.exists():
stems[name] = str(stem_path.resolve())
else:
# 尝试 .mp3
stem_path_mp3 = track_dir / f"{name}.mp3"
if stem_path_mp3.exists():
stems[name] = str(stem_path_mp3.resolve())
print(f" [stem_sep] ✓ 分离完成: {len(stems)} 轨")
for name, path in stems.items():
size_mb = Path(path).stat().st_size / 1_048_576
print(f" {name}: {Path(path).name} ({size_mb:.1f} MB)")
return stems
@classmethod
def available_models(cls) -> list[str]:
"""列出可用模型"""
return cls.MODEL_OPTIONS
@@ -0,0 +1,207 @@
"""
layer2_analysis/structure_parser.py — 歌曲结构解析
输入:原始音频 + 节拍分析结果
输出:段落标记 {intro, verse, chorus, bridge, outro, ...}
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import librosa
import numpy as np
@dataclass
class StructureResult:
"""歌曲结构解析结果"""
segments: list[dict] = field(default_factory=list) # [{label, start, end, confidence}]
section_count: int = 0
has_chorus: bool = False
has_bridge: bool = False
structure_summary: str = "" # 如 "Intro→Verse→Chorus→Verse→Chorus→Bridge→Outro"
@property
def section_labels(self) -> list[str]:
return [s["label"] for s in self.segments]
class StructureParser:
"""
歌曲结构解析器
基于频谱特征变化 + MFCC 聚类进行段落分割。
不依赖外部模型,纯 librosa 实现。
"""
SECTION_TAGS = {
0: "Intro", 1: "Verse", 2: "Chorus",
3: "Bridge", 4: "Outro", 5: "Instrumental",
}
def __init__(self, n_segments: int = 8, min_section_sec: float = 4.0):
self.n_segments = n_segments # 期望段落段数
self.min_section_sec = min_section_sec # 最短段落时长
def parse(self, audio_path: str | Path, beat_times: list[float] | None = None) -> StructureResult:
"""
解析歌曲结构
参数:
audio_path: 音频路径
beat_times: 可选,节拍时间戳用于辅助对齐
返回:
StructureResult
"""
audio_path = Path(audio_path)
print(f" [structure] 分析: {audio_path.name}")
y, sr = librosa.load(str(audio_path), sr=None, mono=True)
duration = len(y) / sr
# — 1. 提取 MFCC 序列 —
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13, hop_length=512)
mfcc_delta = librosa.feature.delta(mfcc)
features = np.vstack([mfcc, mfcc_delta]) # (26, n_frames)
# — 2. 频谱特征 —
spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=512)
spectral_contrast = librosa.feature.spectral_contrast(y=y, sr=sr, hop_length=512)
# 对齐到相同帧数
min_frames = min(features.shape[1], spectral_centroid.shape[1], spectral_contrast.shape[1])
features = features[:, :min_frames]
spec_feats = np.vstack([
spectral_centroid[:, :min_frames],
spectral_contrast[:, :min_frames],
])
all_features = np.vstack([features, spec_feats]) # (~33, n_frames)
# — 3. 计算自相似矩阵 + 结构边界 —
n_fft = all_features.shape[1]
# 用滑动窗口计算相似度变化
window = max(int(n_fft * 0.1), 4)
novelty = librosa.onset.onset_strength(
y=y, sr=sr, hop_length=512
)
# — 4. 频谱变化点检测 —
# 用 MFCC 的 novelty 曲线找变化点
mfcc_novelty = self._compute_novelty(mfcc)
change_points = self._find_peaks(mfcc_novelty, n_fft, self.n_segments)
# — 5. 节拍对齐 —
change_times = librosa.frames_to_time(change_points, sr=sr, hop_length=512)
if beat_times:
change_times = self._snap_to_beats(change_times, beat_times)
# — 6. 段落标记 —
segments = self._label_segments(change_times, duration, all_features)
# — 7. 结构分析 —
summary = "→".join(s["label"] for s in segments)
print(f" [structure] ✓ 结构: {summary}")
return StructureResult(
segments=segments,
section_count=len(segments),
has_chorus=any(s["label"] == "Chorus" for s in segments),
has_bridge=any(s["label"] == "Bridge" for s in segments),
structure_summary=summary,
)
# ---- 内部 ----
def _compute_novelty(self, features: np.ndarray, kernel_size: int = 3) -> np.ndarray:
"""计算 MFCC 的新颖性曲线"""
# 局部相似度矩阵的对角线核卷积
sim = librosa.segment.recurrence_matrix(features, mode="affinity")
kernel = np.zeros(kernel_size * 2 + 1)
kernel[:kernel_size] = -1
kernel[kernel_size] = 0
kernel[kernel_size + 1:] = 1
from scipy.ndimage import convolve1d
novelty = convolve1d(sim.mean(axis=0), kernel, mode="constant")
return novelty
def _find_peaks(self, novelty: np.ndarray, max_frames: int, n_peaks: int) -> np.ndarray:
"""找 novelty 曲线峰值作为段落边界"""
from scipy.signal import find_peaks
if len(novelty) == 0:
return np.array([0, max_frames - 1])
peaks, properties = find_peaks(novelty, distance=max_frames // max(n_peaks, 2))
if len(peaks) < 2:
return np.array([0, max_frames - 1])
# 取最强的 n_peaks 个边界
strengths = properties.get("peak_heights", np.ones(len(peaks)))
top_idx = np.argsort(strengths)[-n_peaks:]
top_peaks = np.sort(peaks[top_idx])
# 补首尾
return np.unique(np.concatenate([[0], top_peaks, [max_frames - 1]]))
def _snap_to_beats(self, times: list[float], beats: list[float]) -> list[float]:
"""将段落边界对齐到最近的节拍"""
snapped = []
for t in times:
nearest = min(beats, key=lambda b: abs(b - t)) if beats else t
snapped.append(nearest)
return snapped
def _label_segments(self, boundaries: list[float], duration: float,
features: np.ndarray) -> list[dict]:
"""为每个段落打标签"""
segments = []
for i in range(len(boundaries) - 1):
start = round(boundaries[i], 2)
end = round(boundaries[i + 1], 2)
if end - start < self.min_section_sec:
continue
# 用 MFCC 特征中心 + 能量判断标签
energy = self._segment_energy(features, boundaries, i)
label = self._infer_label(i, len(boundaries), energy)
segments.append({
"label": label,
"start": start,
"end": end,
"confidence": 0.7, # 固定置信度,后续可改进
})
if not segments:
segments.append({"label": "Full", "start": 0.0, "end": round(duration, 2),
"confidence": 1.0})
return segments
def _segment_energy(self, features: np.ndarray, boundaries: list[float],
idx: int) -> float:
"""估算段落能量"""
# 简化为基于索引位置的启发式
total = len(boundaries) - 1
# Chorus 通常在中间偏后,能量最高
center = total / 2
distance_from_center = abs(idx - center)
energy = 1.0 - (distance_from_center / max(center, 1)) * 0.5
return max(0.3, energy)
def _infer_label(self, idx: int, total: int, energy: float) -> str:
"""基于位置启发式推断段落标签"""
if total <= 2:
return "Full"
if idx == 0:
return "Intro"
if idx == total - 2:
return "Bridge" if total > 4 else "Outro"
if idx == total - 1:
return "Outro"
# Chorus 在中间偏后位置
mid = total / 2
if abs(idx - mid) < 1.5 and energy > 0.6:
return "Chorus"
return "Verse"
@@ -0,0 +1,12 @@
"""AudioLine 创作层 — 节奏/歌词/旋律/混音"""
from .module_a_rhythm import RhythmModule, RhythmResult
from .module_b_lyrics import LyricsModule, LyricsResult
from .module_c_melody import MelodyModule, MelodyResult
from .module_d_mix import MixModule, MixResult
__all__ = [
"RhythmModule", "RhythmResult",
"LyricsModule", "LyricsResult",
"MelodyModule", "MelodyResult",
"MixModule", "MixResult",
]
@@ -0,0 +1,227 @@
"""
layer3_creation/module_a_rhythm.py — 节奏重塑模块
功能:基于 Mubert/Mubert-like API 生成节奏/鼓点轨道
卡点改编的核心:原曲鼓点替换 → BPM 匹配 → 节奏增强
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
@dataclass
class RhythmResult:
"""节奏生成结果"""
drum_track_path: str = "" # 生成的鼓点音频路径
bpm: float = 0.0 # 实际 BPM
genre: str = "" # 使用的风格
duration: float = 0.0 # 时长 (秒)
api_used: str = "" # 使用的 API
cost: float = 0.0 # API 调用成本
class RhythmModule:
"""
节奏重塑模块
支持两种模式:
1. Mubert API — 在线生成高质量鼓点 (需要 API key)
2. 本地替代 — 用 librosa 节拍合成简单鼓点 (免费)
"""
def __init__(self, config: dict | None = None):
self.config = config or {}
self.api_key = self.config.get("mubert", {}).get("api_key", "")
self.base_url = self.config.get("mubert", {}).get(
"base_url", "https://api.mubert.com/v2"
)
self.default_genre = self.config.get("mubert", {}).get(
"default_genre", "electronic"
)
self.default_duration = self.config.get("mubert", {}).get(
"default_duration", 30
)
self.timeout = self.config.get("mubert", {}).get("timeout", 60)
def generate(
self,
bpm: float,
genre: str = "",
duration: float = 0.0,
output_path: str | Path | None = None,
) -> RhythmResult:
"""
生成节奏/鼓点轨道
参数:
bpm: 目标 BPM
genre: 风格 (electronic/house/techno/hiphop/pop/rock)
duration: 目标时长 (秒), 0=默认
output_path: 输出路径
返回:
RhythmResult
"""
genre = genre or self.default_genre
duration = duration if duration > 0 else self.default_duration
output_path = Path(output_path or f"./output/rhythm_{int(bpm)}_{genre}.wav")
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f" [rhythm] 目标: BPM={bpm} 风格={genre} 时长={duration}s")
if self.api_key:
result = self._via_mubert(bpm, genre, duration, output_path)
else:
print(f" [rhythm] 无 Mubert API key, 使用本地合成替代")
result = self._local_fallback(bpm, duration, output_path)
if output_path.exists():
result.drum_track_path = str(output_path.resolve())
size_mb = output_path.stat().st_size / 1_048_576
print(f" [rhythm] ✓ 输出: {output_path.name} ({size_mb:.1f} MB)")
else:
print(f" [rhythm] ⚠ 输出文件不存在")
return result
# ---- Mubert API ----
def _via_mubert(
self, bpm: float, genre: str, duration: float, output_path: Path
) -> RhythmResult:
"""通过 Mubert API 生成"""
result = RhythmResult(bpm=bpm, genre=genre, duration=duration, api_used="mubert")
try:
# Mubert API: POST /v2/track/render
payload = json.dumps({
"pat": self.api_key,
"bpm": int(bpm),
"genre": genre,
"duration": int(duration),
"format": "wav",
}).encode("utf-8")
req = urllib.request.Request(
f"{self.base_url}/track/render",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with _noproxy_urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
# 下载生成的音频
if "data" in data and "url" in data["data"]:
audio_url = data["data"]["url"]
urllib.request.urlretrieve(audio_url, output_path)
result.cost = data.get("data", {}).get("cost", 0.0)
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
print(f" [rhythm] Mubert API 请求失败: {e}")
print(f" [rhythm] 回退到本地合成")
return self._local_fallback(bpm, duration, output_path)
return result
# ---- 本地替代方案 ----
def _local_fallback(
self, bpm: float, duration: float, output_path: Path
) -> RhythmResult:
"""
本地合成简单鼓点节奏
用 librosa 的 clicks 功能生成节拍点击声,
配合简单的频谱叠加模拟 kick + snare + hi-hat。
"""
import librosa
import numpy as np
import soundfile as sf
sr = 22050
total_samples = int(duration * sr)
beat_interval = int(60.0 / bpm * sr) # 每拍采样数
half_beat = beat_interval // 2
# — 构建鼓点模式 (4/4 拍: kick on 1,3, snare on 2,4, hi-hat 8th notes) —
audio = np.zeros(total_samples, dtype=np.float32)
for beat_idx in range(int(duration * bpm / 60) + 1):
pos = beat_idx * beat_interval
if pos >= total_samples:
break
beat_in_bar = beat_idx % 4
# Kick: beats 0, 2 (1st and 3rd beat)
if beat_in_bar in (0, 2):
kick = self._make_kick(sr, int(beat_interval * 0.4))
end = min(pos + len(kick), total_samples)
audio[pos:end] += kick[:end - pos] * 1.0
# Snare: beats 1, 3 (2nd and 4th beat)
if beat_in_bar in (1, 3):
snare = self._make_snare(sr, int(beat_interval * 0.3))
end = min(pos + len(snare), total_samples)
audio[pos:end] += snare[:end - pos] * 0.8
# Hi-hat: every half beat
for off in (0, half_beat):
hh_pos = pos + off
if hh_pos < total_samples:
hh = self._make_hihat(sr, int(beat_interval * 0.15))
end = min(hh_pos + len(hh), total_samples)
audio[hh_pos:end] += hh[:end - hh_pos] * 0.3
# 归一化
peak = np.max(np.abs(audio))
if peak > 0:
audio = audio / peak * 0.9
sf.write(str(output_path), audio, sr)
return RhythmResult(
bpm=bpm,
genre="local_fallback",
duration=duration,
drum_track_path=str(output_path.resolve()),
api_used="local",
)
@staticmethod
def _make_kick(sr: int, length: int) -> np.ndarray:
"""合成 kick 鼓声"""
import numpy as np
t = np.arange(length) / sr
# 频率指数衰减 150→40Hz
freq = 150 * np.exp(-t * 25)
phase = 2 * np.pi * np.cumsum(freq) / sr
return np.sin(phase) * np.exp(-t * 30)
@staticmethod
def _make_snare(sr: int, length: int) -> np.ndarray:
"""合成 snare 鼓声"""
import numpy as np
t = np.arange(length) / sr
tone = np.sin(2 * np.pi * 200 * t)
noise = np.random.randn(length) * 0.5
return (tone + noise) * np.exp(-t * 20)
@staticmethod
def _make_hihat(sr: int, length: int) -> np.ndarray:
"""合成 hi-hat 声"""
import numpy as np
t = np.arange(length) / sr
noise = np.random.randn(length)
# 高通滤波
filtered = noise * np.exp(-t * 60)
return filtered * 0.5
@@ -0,0 +1,174 @@
"""
layer3_creation/module_b_lyrics.py — 歌词创作模块
功能:基于 oMLX 本地 LLM 进行歌词创作
核心场景:换词 (rewrite) — 保持原曲结构,替换内容
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
@dataclass
class LyricsResult:
"""歌词创作结果"""
original_lyrics: str = "" # 原歌词
rewritten_lyrics: str = "" # 改写后歌词
structure: list[dict] = field(default_factory=list) # [{section, lines}]
analysis: dict = field(default_factory=dict) # 段落分析 JSON
model_used: str = "" # 使用的模型
token_count: int = 0 # 消耗 token 数
class LyricsModule:
"""
歌词创作模块
使用 oMLX 本地 LLM 进行:
- 歌词分析 (结构/押韵/情感)
- 歌词改写 (换词创作)
- 风格迁移
"""
DEFAULT_PROMPTS = {
"rewrite": (
"你是一位专业歌词改编者。请根据以下要求对原曲歌词进行二次创作:\n"
"主题: {topic}\n风格: {style}\n"
"保留原曲的段落结构(Intro/Verse/Chorus/Bridge/Outro)和押韵模式。\n"
"输出格式: 每段以[Intro][Verse][Chorus]标记开头。"
),
"analyze": (
"分析以下歌词的段落结构、押韵模式和情感走向。\n"
"输出 JSON 格式,包含: sections, rhyme_scheme, emotion_flow。"
),
}
def __init__(self, config: dict | None = None):
self.config = config or {}
self.llm_url = self.config.get("lyrics", {}).get(
"llm_url", "http://localhost:8000/v1"
)
self.llm_model = self.config.get("lyrics", {}).get(
"llm_model", "Qwen3-8B"
)
self.max_tokens = self.config.get("lyrics", {}).get("max_tokens", 1024)
self.temperature = self.config.get("lyrics", {}).get("temperature", 0.8)
self.rewrite_template = self.config.get(
"lyrics", {}
).get("rewrite_prompt", self.DEFAULT_PROMPTS["rewrite"])
def rewrite(
self,
original_lyrics: str,
topic: str = "",
style: str = "",
output_path: str | Path | None = None,
) -> LyricsResult:
"""
歌词改写
参数:
original_lyrics: 原歌词文本
topic: 新主题
style: 目标风格
output_path: 输出路径 (可选)
返回:
LyricsResult
"""
print(f" [lyrics] 改写创作: topic='{topic}' style='{style}'")
prompt = self.rewrite_template.format(topic=topic or "相同主题", style=style or "原风格")
messages = [
{"role": "system", "content": "你是专业歌词作家。只输出歌词内容,不要额外解释。"},
{"role": "user", "content": f"{prompt}\n\n原歌词:\n{original_lyrics}"},
]
rewritten = self._call_llm(messages)
result = LyricsResult(
original_lyrics=original_lyrics,
rewritten_lyrics=rewritten,
model_used=self.llm_model,
)
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(rewritten)
print(f" [lyrics] ✓ 已保存: {output_path}")
print(f" [lyrics] ✓ 改写完成 ({len(rewritten)} 字符)")
return result
def analyze(self, lyrics: str) -> LyricsResult:
"""
分析歌词结构
返回 JSON 格式的段落分析
"""
print(f" [lyrics] 分析结构")
messages = [
{"role": "system", "content": "你是一个歌词分析专家。只输出 JSON。"},
{"role": "user", "content": f"{self.DEFAULT_PROMPTS['analyze']}\n\n歌词:\n{lyrics}"},
]
response = self._call_llm(messages)
analysis = {}
try:
# 尝试解析 JSON
start = response.find("{")
end = response.rfind("}") + 1
if start >= 0 and end > start:
analysis = json.loads(response[start:end])
except (json.JSONDecodeError, ValueError):
analysis = {"raw": response}
return LyricsResult(
original_lyrics=lyrics,
analysis=analysis,
model_used=self.llm_model,
)
# ---- LLM 调用 ----
def _call_llm(self, messages: list[dict]) -> str:
"""调用 oMLX API"""
payload = json.dumps({
"model": self.llm_model,
"messages": messages,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
}).encode("utf-8")
try:
req = urllib.request.Request(
f"{self.llm_url}/chat/completions",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with _noproxy_urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
content = data["choices"][0]["message"]["content"]
self._last_tokens = data.get("usage", {}).get("total_tokens", 0)
return content
except (urllib.error.HTTPError, urllib.error.URLError,
json.JSONDecodeError, KeyError) as e:
print(f" [lyrics] oMLX API 调用失败: {e}")
print(f" [lyrics] 请确保 oMLX Server 已启动 (localhost:8000)")
return ""
@property
def last_tokens(self) -> int:
"""上次调用的 token 数"""
return getattr(self, "_last_tokens", 0)
@@ -0,0 +1,135 @@
"""
layer3_creation/module_c_melody.py — 旋律编曲模块
功能:基于 Suno API 进行旋律改编/Remix
核心场景:曲风迁移、旋律重编、Remix
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
@dataclass
class MelodyResult:
"""旋律编曲结果"""
output_path: str = ""
style: str = ""
duration: float = 0.0
api_used: str = ""
cost: float = 0.0
task_id: str = ""
class MelodyModule:
"""
旋律编曲模块
使用 Suno API 进行旋律创作/Remix。
注意: 需要有效的 Suno API key 才能使用在线服务。
无 key 时回退到本地音频拼接 (仅保留结构,不生成新旋律)。
"""
def __init__(self, config: dict | None = None):
self.config = config or {}
self.api_key = self.config.get("suno", {}).get("api_key", "")
self.base_url = self.config.get("suno", {}).get(
"base_url", "https://api.suno.ai/v1"
)
self.model = self.config.get("suno", {}).get("model", "suno-v4")
self.timeout = self.config.get("suno", {}).get("timeout", 120)
self.max_retries = self.config.get("suno", {}).get("max_retries", 2)
def remix(
self,
audio_path: str | Path,
target_style: str = "",
duration: float = 0.0,
output_path: str | Path | None = None,
) -> MelodyResult:
"""
旋律改编
参数:
audio_path: 原曲音频路径
target_style: 目标风格 (如 "jazz", "electronic", "orchestral")
duration: 目标时长
output_path: 输出路径
返回:
MelodyResult
"""
audio_path = Path(audio_path)
output_path = Path(
output_path or f"./output/melody_{audio_path.stem}_{target_style or 'remix'}.wav"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f" [melody] 编曲: style='{target_style}' duration={duration}s")
if self.api_key:
return self._via_suno(audio_path, target_style, duration, output_path)
else:
print(f" [melody] 无 Suno API key, 跳过旋律编曲")
print(f" [melody] 提示: module_c_melody 需要 Suno API key 才能工作")
return MelodyResult(
output_path=str(output_path) if output_path.exists() else "",
style=target_style,
api_used="none",
)
def _via_suno(
self, audio_path: Path, style: str, duration: float, output_path: Path
) -> MelodyResult:
"""通过 Suno API 进行 Remix"""
for attempt in range(self.max_retries):
try:
# Suno API: POST /v1/generate/remix
payload = json.dumps({
"model": self.model,
"audio_url": str(audio_path.resolve()),
"style": style or "pop",
"duration": int(duration) if duration > 0 else 30,
"format": "wav",
}).encode("utf-8")
req = urllib.request.Request(
f"{self.base_url}/generate/remix",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
method="POST",
)
with _noproxy_urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
# 下载生成的音频
audio_url = data.get("data", {}).get("url", "")
if audio_url:
urllib.request.urlretrieve(audio_url, output_path)
return MelodyResult(
output_path=str(output_path.resolve()) if output_path.exists() else "",
style=style,
duration=duration,
api_used="suno",
task_id=data.get("data", {}).get("id", ""),
)
except (urllib.error.HTTPError, urllib.error.URLError,
json.JSONDecodeError) as e:
print(f" [melody] Suno API 失败 (尝试 {attempt+1}/{self.max_retries}): {e}")
if attempt < self.max_retries - 1:
time.sleep(2)
return MelodyResult(style=style, api_used="suno_failed")
@@ -0,0 +1,291 @@
"""
layer3_creation/module_d_mix.py — 混音母带模块
功能:将多轨音频(vocals/drums/bass/other)混音为完整立体声
支持 EQ / 压缩 / 限制器 / 混响 / 响度归一化
基于 Pedalboard (Spotify 开源音频处理库)。
回退方案:无 Pedalboard 时使用 pydub + numpy 基本叠加。
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
@dataclass
class MixResult:
"""混音母带结果"""
master_path: str = "" # 混音后主轨路径
stem_paths: dict[str, str] = field(default_factory=dict) # 处理后分轨路径
loudness: float = 0.0 # 最终 LUFS 响度
peak_dB: float = 0.0 # 峰值 (dB)
headroom: float = 0.0 # 头上空间 (dB)
duration: float = 0.0 # 时长 (秒)
engine: str = "" # 使用的引擎 (pedalboard | pydub | numpy)
class MixModule:
"""
混音母带模块
处理流程:
1. 加载各分轨音频
2. 电平平衡 (Volume/gain staging)
3. EQ 均衡 (可选预设)
4. 压缩 (可选)
5. 混响 (可选)
6. 立体声叠加
7. 峰值限制 → 响度归一化
8. 导出
"""
def __init__(self, config: dict | None = None):
self.config = config or {}
mix_cfg = config.get("mixing", {}) if config else {}
self.target_loudness = float(mix_cfg.get("target_loudness", -14.0))
self.headroom_target = float(mix_cfg.get("headroom", 1.0))
self.sample_rate = int(mix_cfg.get("sample_rate", 44100))
self.bit_depth = int(mix_cfg.get("bit_depth", 16))
self.eq_preset = mix_cfg.get("eq_preset", "default")
self.compressor_threshold = float(mix_cfg.get("compressor_threshold", -20))
self.compressor_ratio = float(mix_cfg.get("compressor_ratio", 3.0))
self.compressor_attack = float(mix_cfg.get("compressor_attack", 5))
self.compressor_release = float(mix_cfg.get("compressor_release", 100))
self._pedalboard = None
self._has_pedalboard = False
self._init_pedalboard()
def _init_pedalboard(self):
"""尝试加载 Pedalboard"""
try:
import pedalboard
self._pedalboard = pedalboard
self._has_pedalboard = True
except ImportError:
self._has_pedalboard = False
def mix(
self,
stems: dict[str, str],
output_path: str | Path = "./output/mix/master.wav",
stem_levels: dict[str, float] | None = None,
apply_eq: bool = True,
apply_compressor: bool = True,
apply_reverb: bool = False,
include_stems: bool = True,
) -> MixResult:
"""
混音主入口
参数:
stems: {stem_name: audio_path, ...}
output_path: 混音输出路径
stem_levels: 分轨电平增益 {name: dB},默认 ±0
apply_eq: 是否应用 EQ
apply_compressor: 是否应用压缩
apply_reverb: 是否应用混响
include_stems: 是否同时导出处理后的分轨
返回:
MixResult
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f" [mix] 混音开始: {len(stems)} 轨")
stem_levels = stem_levels or {}
processed_stems: dict[str, Path] = {}
stem_audio: dict[str, np.ndarray] = {}
ref_sr = self.sample_rate
# — 1. 加载 + 处理各分轨 —
for name, audio_path in stems.items():
if not Path(audio_path).exists():
print(f" [mix] ⚠ 分轨不存在: {audio_path}, 跳过")
continue
y, sr = self._load_audio(audio_path, ref_sr)
gain_db = stem_levels.get(name, 0.0)
# 应用增益
if gain_db != 0:
y = y * (10 ** (gain_db / 20))
# 应用处理 (EQ/压缩)
if self._has_pedalboard:
y = self._apply_processing(
y, sr, name, apply_eq, apply_compressor, apply_reverb
)
stem_audio[name] = y
# 导出处理后分轨
if include_stems:
stem_out = output_path.parent / f"processed_{name}.wav"
self._save_audio(stem_out, y, sr)
processed_stems[name] = str(stem_out.resolve())
print(f" [mix] {name}: {gain_db:+.1f}dB {len(y)/sr:.1f}s")
if not stem_audio:
raise ValueError("没有可用的分轨进行混音")
# — 2. 立体声叠加 —
ref_sr = self._get_common_sr(stem_audio)
master = self._sum_stems(stem_audio)
# — 3. 峰值限制 + 响度归一化 —
master, peak_db, loudness = self._master_bus(master, ref_sr)
# — 4. 导出 —
self._save_audio(output_path, master, ref_sr)
headroom = abs(peak_db) if peak_db < 0 else 0
result = MixResult(
master_path=str(output_path.resolve()),
stem_paths={k: str(v) for k, v in processed_stems.items()},
loudness=round(loudness, 2),
peak_dB=round(peak_db, 2),
headroom=round(headroom, 2),
duration=round(len(master) / ref_sr, 2),
engine="pedalboard" if self._has_pedalboard else "numpy",
)
print(f" [mix] ✓ 混音完成: {output_path.name}")
print(f" [mix] 响度={loudness:.1f} LUFS 峰值={peak_db:.1f} dBFS "
f"头上空间={headroom:.1f} dB")
return result
# ---- 内部处理 ----
def _load_audio(self, path: str | Path, target_sr: int) -> tuple[np.ndarray, int]:
"""加载音频文件"""
import librosa
y, sr = librosa.load(str(path), sr=target_sr, mono=False)
# 确保为 2D: (channels, samples)
if y.ndim == 1:
y = np.stack([y, y]) # mono → stereo
return y, sr
def _get_common_sr(self, stem_audio: dict) -> int:
"""取所有分轨的共用采样率"""
return self.sample_rate
def _apply_processing(
self,
y: np.ndarray,
sr: int,
stem_name: str,
apply_eq: bool,
apply_compressor: bool,
apply_reverb: bool,
) -> np.ndarray:
"""Pedalboard 处理链"""
import pedalboard as pb
chain = []
# EQ
if apply_eq:
chain.append(
pb.Filterband(
cutoff_frequency=80.0,
filter_type="highpass",
)
)
if stem_name == "vocals":
# 人声: 提升中频 (presence)
chain.append(pb.Equalizer(num_eq_bands=3))
# 压缩
if apply_compressor and stem_name in ("vocals", "bass"):
chain.append(
pb.Compressor(
threshold_db=self.compressor_threshold,
ratio=self.compressor_ratio,
attack_ms=self.compressor_attack,
release_ms=self.compressor_release,
)
)
# 混响 (仅应用到 vocal/master)
if apply_reverb and stem_name == "vocals":
chain.append(pb.Reverb(room_size=0.3, damping=0.5))
if not chain:
return y
board = pb.Pedalboard(chain, sample_rate=sr)
return board.process(y)
def _sum_stems(self, stem_audio: dict[str, np.ndarray]) -> np.ndarray:
"""叠加多轨音频"""
# 找出最长轨道
max_len = max(y.shape[1] for y in stem_audio.values())
master = np.zeros((2, max_len), dtype=np.float64)
for name, y in stem_audio.items():
y_len = y.shape[1]
if y_len < max_len:
pad = np.zeros((2, max_len - y_len))
master[:, :y_len] += y[:, :] * 0.8
master[:, y_len:] += pad
else:
master += y * 0.8
# 防止削波前先归一化
peak = np.max(np.abs(master))
if peak > 0.95:
master = master / peak * 0.95
return master.astype(np.float32)
def _master_bus(
self, audio: np.ndarray, sr: int
) -> tuple[np.ndarray, float, float]:
"""母带总线: 限制器 + 响度归一化"""
if self._has_pedalboard:
import pedalboard as pb
# 限制器
limiter = pb.Limiter(
threshold_db=-self.headroom_target,
release_ms=100,
)
board = pb.Pedalboard([limiter], sample_rate=sr)
audio = board.process(audio)
# 测量峰值
peak_db = float(20 * np.log10(np.max(np.abs(audio)) + 1e-10))
# 响度归一化 (简单 RMS 归一化,完整 LUFS 需要 pyloudnorm)
rms = np.sqrt(np.mean(audio ** 2))
if rms > 0:
target_rms = 10 ** (self.target_loudness / 20)
gain = target_rms / rms
audio = audio * min(gain, 2.0) # 限制最大增益 6dB
loudness = self.target_loudness # 近似
return audio, peak_db, loudness
def _save_audio(self, path: Path, audio: np.ndarray, sr: int):
"""保存音频"""
import soundfile as sf
# soundfile 期望 (samples, channels)
if audio.ndim == 2 and audio.shape[0] <= 8:
audio = audio.T
sf.write(str(path), audio, sr, subtype="PCM_16")
@@ -0,0 +1,8 @@
"""AudioLine 对齐层 — 歌词节拍对齐 / 分轨时间对齐"""
from .lyric_aligner import LyricAligner, LyricTimeline
from .stem_aligner import StemAligner, AlignResult
__all__ = [
"LyricAligner", "LyricTimeline",
"StemAligner", "AlignResult",
]
@@ -0,0 +1,309 @@
"""
layer4_alignment/lyric_aligner.py — 歌词-节拍对齐模块
功能:将歌词文本按段落结构对齐到节拍时间戳。
输出格式:[{start, end, text, section}] 时间轴,支持转 LRC/SRT。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class LyricTimeline:
"""歌词时间轴"""
entries: list[dict] = field(default_factory=list) # [{start, end, text, section}]
total_duration: float = 0.0
sections: list[dict] = field(default_factory=list) # [{label, start, end}]
@property
def lyric_count(self) -> int:
return len(self.entries)
def to_lrc(self) -> str:
"""转为 LRC 歌词格式"""
lines = ["[ti:AudioLine LRC]", "[offset:0]"]
for entry in self.entries:
mm, ss = divmod(entry["start"], 60)
cs = int((entry["start"] - int(entry["start"])) * 100)
lines.append(f"[{int(mm):02d}:{int(ss):02d}.{cs:02d}]{entry['text']}")
return "\n".join(lines)
def to_srt(self) -> str:
"""转为 SRT 字幕格式"""
blocks = []
for i, entry in enumerate(self.entries, 1):
start_srt = self._sec_to_srt(entry["start"])
end_srt = self._sec_to_srt(entry["end"])
blocks.append(f"{i}\n{start_srt} --> {end_srt}\n{entry['text']}\n")
return "\n".join(blocks)
@staticmethod
def _sec_to_srt(sec: float) -> str:
h, m = divmod(sec, 3600)
m, s = divmod(m, 60)
cs = int((s - int(s)) * 1000)
return f"{int(h):02d}:{int(m):02d}:{int(s):02d},{cs:03d}"
class LyricAligner:
"""
歌词-节拍对齐器
将结构化的歌词段落匹配到分析出的节拍/段落边界。
支持两种模式:
1. 自动对齐 — 根据段落标签映射
2. 手动指定 — 提供精确的时间偏移
"""
def __init__(self, tolerance: float = 0.1):
self.tolerance = tolerance # 时间容差 (秒)
def align(
self,
lyrics: str,
beats: list[float],
sections: list[dict] | None = None,
total_duration: float = 0.0,
) -> LyricTimeline:
"""
将对齐歌词到节拍
参数:
lyrics: 歌词文本 (含 [Intro][Verse] 等段落标记)
beats: 节拍时间戳列表 (秒)
sections: 段落结构 [{label, start, end}] (可选)
total_duration: 总时长 (秒)
返回:
LyricTimeline
"""
# — 1. 解析歌词段落 —
parsed_sections = self._parse_lyric_sections(lyrics)
if sections:
# — 2a. 使用已分析的段落边界 —
timeline = self._align_with_sections(
parsed_sections, beats, sections, total_duration
)
else:
# — 2b. 仅用节拍均匀分配 —
timeline = self._align_by_beats(parsed_sections, beats, total_duration)
return timeline
def align_lines(
self,
lines: list[str],
section: str,
section_start: float,
section_end: float,
beats_in_section: list[float],
) -> list[dict]:
"""
对齐单个段落内的歌词行到节拍
参数:
lines: 歌词行列表
section: 段落标签
section_start: 段落开始时间
section_end: 段落结束时间
beats_in_section: 段落内的节拍
返回:
[{start, end, text, section}]
"""
if not lines:
return []
if not beats_in_section:
# 无节拍: 均匀分配
return self._distribute_evenly(lines, section, section_start, section_end)
entries = []
beats_per_line = max(len(beats_in_section) // len(lines), 1)
for i, line in enumerate(lines):
start_idx = i * beats_per_line
end_idx = min((i + 1) * beats_per_line, len(beats_in_section))
start = beats_in_section[start_idx] if start_idx < len(beats_in_section) else section_start
end = beats_in_section[end_idx - 1] if end_idx <= len(beats_in_section) else section_end
# 对最后一行: 用段落结束而不是最后一个节拍
if i == len(lines) - 1:
end = section_end
entries.append({
"start": round(start, 3),
"end": round(end, 3),
"text": line.strip(),
"section": section,
})
return entries
# ---- 内部 ----
def _parse_lyric_sections(self, lyrics: str) -> list[dict[str, Any]]:
"""解析歌词中的段落标记"""
sections = []
# 匹配 [Intro], [Verse 1], [Chorus] 等标记
pattern = r"\[(\w+(?:\s*\d*)?)\](.*?)(?=\[\w|$)"
matches = re.findall(pattern, lyrics, re.DOTALL)
if not matches:
# 无标记: 整段视为一个段落
lines = [l for l in lyrics.strip().split("\n") if l.strip()]
sections.append({"label": "Full", "lines": lines})
else:
for label, content in matches:
lines = [l.strip() for l in content.strip().split("\n") if l.strip()]
if lines:
sections.append({"label": label.strip(), "lines": lines})
return sections
def _align_with_sections(
self,
parsed: list[dict],
beats: list[float],
sections: list[dict],
total_duration: float,
) -> LyricTimeline:
"""基于段落结构对齐"""
all_entries = []
for ps in parsed:
label = ps["label"]
# 找对应的段落
section = next(
(s for s in sections if label.lower() in s.get("label", "").lower()),
None,
)
if section:
start = section["start"]
end = section["end"]
beats_in_sec = [b for b in beats if start <= b <= end]
else:
# 找不到: 用标签顺序估算位置
idx = parsed.index(ps)
total = len(parsed)
start = (idx / total) * total_duration
end = ((idx + 1) / total) * total_duration
beats_in_sec = [b for b in beats if start <= b <= end]
entries = self.align_lines(
ps["lines"], label, start, end, beats_in_sec
)
all_entries.extend(entries)
return LyricTimeline(
entries=all_entries,
total_duration=total_duration or (all_entries[-1]["end"] if all_entries else 0),
sections=sections,
)
def _align_by_beats(
self,
parsed: list[dict],
beats: list[float],
total_duration: float,
) -> LyricTimeline:
"""仅基于节拍均匀分配"""
if not beats:
return self._fallback_linear(parsed, total_duration)
all_entries = []
total_lines = sum(len(ps["lines"]) for ps in parsed)
if total_lines == 0:
return LyricTimeline()
beats_per_line = max(len(beats) // total_lines, 1)
line_idx = 0
for ps in parsed:
for line in ps["lines"]:
start_idx = line_idx * beats_per_line
end_idx = min((line_idx + 1) * beats_per_line, len(beats))
start = beats[start_idx] if start_idx < len(beats) else 0
end = beats[end_idx - 1] if end_idx <= len(beats) else (total_duration or end + 2)
all_entries.append({
"start": round(start, 3),
"end": round(end, 3),
"text": line.strip(),
"section": ps["label"],
})
line_idx += 1
return LyricTimeline(
entries=all_entries,
total_duration=total_duration or (all_entries[-1]["end"] if all_entries else 0),
)
def _distribute_evenly(
self,
lines: list[str],
section: str,
start: float,
end: float,
) -> list[dict]:
"""均匀分配歌词行"""
if not lines:
return []
if len(lines) == 1:
return [{"start": round(start, 3), "end": round(end, 3),
"text": lines[0].strip(), "section": section}]
duration = end - start
line_duration = duration / len(lines)
entries = []
for i, line in enumerate(lines):
line_start = start + i * line_duration
line_end = line_start + line_duration
entries.append({
"start": round(line_start, 3),
"end": round(line_end, 3),
"text": line.strip(),
"section": section,
})
return entries
def _fallback_linear(
self, parsed: list[dict], total_duration: float
) -> LyricTimeline:
"""无节拍时的回退方案"""
if not total_duration:
return LyricTimeline()
all_lines = []
for ps in parsed:
for line in ps["lines"]:
all_lines.append({"text": line.strip(), "section": ps["label"]})
if not all_lines:
return LyricTimeline()
per_line = total_duration / len(all_lines)
entries = [
{
"start": round(i * per_line, 3),
"end": round((i + 1) * per_line, 3),
"text": e["text"],
"section": e["section"],
}
for i, e in enumerate(all_lines)
]
return LyricTimeline(
entries=entries,
total_duration=total_duration,
)
@@ -0,0 +1,392 @@
"""
layer4_alignment/stem_aligner.py — 分轨时间对齐模块
功能:将各分轨时间伸缩到目标 BPM / 目标时长
核心场景:卡点改编时原曲 BPM -> 目标 BPM 的时间映射
支持方法:
1. librosa.effects.time_stretch (相位声码器)
2. 节拍网格对齐 (beat grid snapping)
3. 交叉淡入淡出连接 (拼接时)
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
import numpy as np
@dataclass
class AlignResult:
"""对齐结果"""
aligned_paths: dict[str, str] = field(default_factory=dict) # {stem: path}
original_duration: float = 0.0
target_duration: float = 0.0
stretch_ratios: dict[str, float] = field(default_factory=dict) # {stem: ratio}
method_used: str = ""
@property
def is_aligned(self) -> bool:
return len(self.aligned_paths) > 0
class StemAligner:
"""
分轨时间对齐器
将分轨从原始节奏伸缩到目标节奏。
支持独立对齐每轨或统一对齐。
"""
STRETCH_METHODS: list[str] = ["phase_vocoder", "librosa", "wsola"]
def __init__(
self,
method: str = "phase_vocoder",
crossfade: float = 0.05,
time_tolerance: float = 0.1,
):
if method not in self.STRETCH_METHODS:
raise ValueError(f"不支持的时间伸缩方法 '{method}', 可选: {self.STRETCH_METHODS}")
self.method = method
self.crossfade = crossfade
self.time_tolerance = time_tolerance
def align_to_bpm(
self,
stems: dict[str, str],
source_bpm: float,
target_bpm: float,
output_dir: str | Path = "./output/aligned/",
align_separately: bool = False,
) -> AlignResult:
"""
按 BPM 比率对齐分轨
参数:
stems: {stem_name: audio_path}
source_bpm: 原曲 BPM
target_bpm: 目标 BPM
output_dir: 输出目录
align_separately: True=每轨独立比例, False=统一比例
返回:
AlignResult
"""
if target_bpm <= 0 or source_bpm <= 0:
raise ValueError("BPM 必须 > 0")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
ratio = target_bpm / source_bpm
print(f" [align] BPM 对齐: {source_bpm} → {target_bpm} (比率: {ratio:.4f})")
return self._align(
stems, ratio, output_dir, align_separately
)
def align_to_duration(
self,
stems: dict[str, str],
source_duration: float,
target_duration: float,
output_dir: str | Path = "./output/aligned/",
align_separately: bool = False,
) -> AlignResult:
"""
按时长比例对齐分轨
参数:
stems: {stem_name: audio_path}
source_duration: 原曲时长 (秒)
target_duration: 目标时长 (秒)
output_dir: 输出目录
align_separately: True=每轨独立
返回:
AlignResult
"""
if target_duration <= 0 or source_duration <= 0:
raise ValueError("时长必须 > 0")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
ratio = target_duration / source_duration
print(f" [align] 时长对齐: {source_duration:.1f}s → {target_duration:.1f}s"
f" (比率: {ratio:.4f})")
return self._align(
stems, ratio, output_dir, align_separately
)
def align_with_beats(
self,
stems: dict[str, str],
source_beats: list[float],
target_beats: list[float],
output_dir: str | Path = "./output/aligned/",
) -> AlignResult:
"""
按节拍网格对齐 (精确卡点)
将源节拍映射到目标节拍位置,逐段伸缩。
参数:
stems: {stem_name: audio_path}
source_beats: 原节奏拍列表 (秒)
target_beats: 目标节拍列表 (秒)
output_dir: 输出目录
返回:
AlignResult
"""
if not source_beats or not target_beats:
raise ValueError("节拍列表不能为空")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f" [align] 节拍网格对齐: {len(source_beats)} → {len(target_beats)} 拍")
aligned = {}
stretch_ratios = {}
for stem_name, audio_path in stems.items():
if not Path(audio_path).exists():
print(f" [align] ⚠ 分轨不存在: {audio_path}, 跳过")
continue
out_path = output_dir / f"aligned_{stem_name}.wav"
result_path = self._beat_grid_stretch(
str(audio_path), source_beats, target_beats, str(out_path)
)
if result_path:
aligned[stem_name] = result_path
stretch_ratios[stem_name] = len(target_beats) / len(source_beats)
return AlignResult(
aligned_paths=aligned,
original_duration=source_beats[-1] if source_beats else 0,
target_duration=target_beats[-1] if target_beats else 0,
stretch_ratios=stretch_ratios,
method_used="beat_grid",
)
# ---- 内部 ----
def _align(
self,
stems: dict[str, str],
ratio: float,
output_dir: Path,
separate: bool,
) -> AlignResult:
"""通用对齐"""
aligned = {}
stretch_ratios = {}
# — 加载第一轨获取原时长 —
first_path = next(iter(stems.values()))
y_ref, sr_ref = self._load_audio(first_path)
orig_duration = len(y_ref) / sr_ref
for stem_name, audio_path in stems.items():
if not Path(audio_path).exists():
print(f" [align] ⚠ 分轨不存在: {audio_path}, 跳过")
continue
out_path = output_dir / f"aligned_{stem_name}.wav"
if separate:
# 独立对齐:每轨先测实际时长再计算比例
y, sr = self._load_audio(audio_path)
actual_duration = len(y) / sr
target_dur = actual_duration * ratio
actual_ratio = target_dur / actual_duration
else:
actual_ratio = ratio
result_path = self._time_stretch(
str(audio_path), actual_ratio, str(out_path)
)
if result_path:
aligned[stem_name] = result_path
stretch_ratios[stem_name] = actual_ratio
return AlignResult(
aligned_paths=aligned,
original_duration=orig_duration,
target_duration=orig_duration * ratio,
stretch_ratios=stretch_ratios,
method_used=self.method,
)
def _time_stretch(self, input_path: str, ratio: float, output_path: str) -> str | None:
"""
时间伸缩
参数:
input_path: 输入音频路径
ratio: 伸缩比 (>1 变快/缩短, <1 变慢/拉长)
output_path: 输出路径
返回:
输出路径,失败返回 None
"""
if abs(ratio - 1.0) < 0.001:
# 无需伸缩: 直接复制
import shutil
shutil.copy2(input_path, output_path)
return output_path
print(f" [align] 伸缩: ratio={ratio:.4f}")
try:
y, sr = self._load_audio(input_path)
if self.method == "librosa":
import librosa
y_stretched = librosa.effects.time_stretch(y, rate=ratio)
elif self.method == "phase_vocoder":
# 用相位声码器
y_stretched = self._phase_vocoder_stretch(y, ratio)
else: # wsola
y_stretched = self._wsola_stretch(y, ratio)
self._save_audio(output_path, y_stretched, sr)
return output_path
except Exception as e:
print(f" [align] ⚠ 伸缩失败: {e}")
# 回退: 复制原文件
import shutil
shutil.copy2(input_path, output_path)
return output_path
def _beat_grid_stretch(
self,
audio_path: str,
source_beats: list[float],
target_beats: list[float],
output_path: str,
) -> str | None:
"""
逐段节拍网格伸缩
将源节拍分段,每段分别伸缩到目标节拍间隔。
"""
try:
y, sr = self._load_audio(audio_path)
# 限制处理范围
max_beats = min(len(source_beats), len(target_beats))
if max_beats < 2:
import shutil
shutil.copy2(audio_path, output_path)
return output_path
segments = []
for i in range(max_beats - 1):
s_start = int(source_beats[i] * sr)
s_end = int(source_beats[i + 1] * sr)
t_len = target_beats[i + 1] - target_beats[i]
s_len = source_beats[i + 1] - source_beats[i]
if s_len <= 0 or t_len <= 0:
continue
segment = y[..., s_start:s_end] if y.ndim == 1 else y[:, s_start:s_end]
if segment.size == 0:
continue
ratio = s_len / t_len # 注意: librosa 的 stretch 反向
stretched = librosa.effects.time_stretch(
np.asfortranarray(segment), rate=1/ratio
)
segments.append(stretched)
if not segments:
import shutil
shutil.copy2(audio_path, output_path)
return output_path
# 拼接 (带交叉淡入淡出)
result = self._crossfade_join(segments, sr)
self._save_audio(output_path, result, sr)
return output_path
except Exception as e:
print(f" [align] ⚠ 节拍网格对齐失败: {e}")
import shutil
shutil.copy2(audio_path, output_path)
return output_path
def _phase_vocoder_stretch(self, y: np.ndarray, ratio: float) -> np.ndarray:
"""相位声码器实现的时间伸缩"""
from librosa.core import phase_vocoder
from librosa import stft, istft
D = stft(y)
D_stretched = phase_vocoder(D, rate=ratio)
y_stretched = istft(D_stretched)
return y_stretched
def _wsola_stretch(self, y: np.ndarray, ratio: float) -> np.ndarray:
"""WSOLA 时间伸缩 (librosa 回退)"""
import librosa
return librosa.effects.time_stretch(y, rate=ratio)
def _crossfade_join(
self, segments: list[np.ndarray], sr: int
) -> np.ndarray:
"""带交叉淡入淡出的音频拼接"""
if len(segments) == 1:
return segments[0]
fade_len = int(self.crossfade * sr)
# 确保所有段 mono (1D)
segments_1d = []
for seg in segments:
if seg.ndim > 1:
seg = np.mean(seg, axis=0)
segments_1d.append(seg)
result = segments_1d[0]
for seg in segments_1d[1:]:
if len(result) < fade_len or len(seg) < fade_len:
result = np.concatenate([result, seg])
continue
fade_out = np.linspace(1, 0, fade_len)
fade_in = np.linspace(0, 1, fade_len)
cross_start = len(result) - fade_len
result[cross_start:] *= fade_out
seg[:fade_len] *= fade_in
result = np.concatenate([
result[:cross_start],
result[cross_start:] + seg[:fade_len],
seg[fade_len:],
])
return result
@staticmethod
def _load_audio(path: str) -> tuple[np.ndarray, int]:
"""加载音频"""
import librosa
return librosa.load(path, sr=None, mono=True)
@staticmethod
def _save_audio(path: str, y: np.ndarray, sr: int):
"""保存音频"""
import soundfile as sf
sf.write(path, y, sr)
@@ -0,0 +1,8 @@
"""AudioLine 输出层 — 导出 / 歌词格式化"""
from .exporter import Exporter, ExportResult
from .lyric_formatter import LyricFormatter, FormattedLyrics
__all__ = [
"Exporter", "ExportResult",
"LyricFormatter", "FormattedLyrics",
]
@@ -0,0 +1,282 @@
"""
layer5_output/exporter.py — 多格式输出导出模块
功能:将混音结果导出为多种格式
支持:WAV (PCM) / MP3 / FLAC / AAC
可选:整轨导出 / 分轨导出 / 带歌词时间轴导出
"""
from __future__ import annotations
import json
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class ExportResult:
"""导出结果"""
exported_files: list[dict] = field(default_factory=list) # [{format, path, size_kb}]
formats_completed: list[str] = field(default_factory=list)
total_size_kb: float = 0.0
duration: float = 0.0
has_lyrics: bool = False
@property
def summary(self) -> str:
return f"{len(self.exported_files)} 文件, {self.total_size_kb:.0f} KB, " \
f"{self.duration:.1f}s, 格式: {', '.join(self.formats_completed)}"
class Exporter:
"""
多格式导出器
支持格式:
- WAV: 无损 PCM 16/24-bit
- MP3: CBR/VBR, 支持 128k/192k/320k
- FLAC: 无损压缩
- AAC: 高效有损 (M4A)
"""
SUPPORTED_FORMATS = {"wav", "mp3", "flac", "aac"}
def __init__(
self,
config: dict | None = None,
):
self.config = config or {}
export_cfg = config.get("export", {}) if config else {}
self.default_formats = export_cfg.get("formats", ["wav", "mp3"])
self.mp3_bitrate = export_cfg.get("mp3_bitrate", "320k")
self.include_stems = export_cfg.get("include_stems", True)
self.stem_labels = export_cfg.get("stem_labels",
["vocals", "drums", "bass", "other", "full_mix"])
self.sample_rate = int(config.get("mixing", {}).get("sample_rate", 44100)) if config else 44100
self.bit_depth = int(config.get("mixing", {}).get("bit_depth", 16)) if config else 16
def export_master(
self,
master_path: str | Path,
output_dir: str | Path = "./output/export/",
formats: list[str] | None = None,
metadata: dict | None = None,
) -> ExportResult:
"""
导出主混音
参数:
master_path: 主混音 WAV 路径
output_dir: 输出目录
formats: 目标格式列表 (默认 config 中的设置)
metadata: 元数据 {title, artist, album, ...}
返回:
ExportResult
"""
master_path = Path(master_path)
if not master_path.exists():
raise FileNotFoundError(f"主混音文件不存在: {master_path}")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
formats = formats or self.default_formats
stem_name = master_path.stem
print(f" [export] 导出主轨: {master_path.name}")
print(f" [export] 目标格式: {formats}")
exported = []
for fmt in formats:
fmt = fmt.lower().lstrip(".")
if fmt not in self.SUPPORTED_FORMATS:
print(f" [export] ⚠ 不支持格式 '{fmt}', 跳过")
continue
out_path = output_dir / f"{stem_name}.{fmt}"
success = self._convert(master_path, out_path, fmt, metadata)
if success:
size_kb = out_path.stat().st_size / 1024
exported.append({
"format": fmt,
"path": str(out_path.resolve()),
"size_kb": round(size_kb, 1),
})
print(f" [export] {fmt}: {out_path.name} ({size_kb:.0f} KB)")
if not exported:
raise RuntimeError("所有格式导出均失败")
# 获取时长
duration = self._get_duration(master_path)
return ExportResult(
exported_files=exported,
formats_completed=[e["format"] for e in exported],
total_size_kb=sum(e["size_kb"] for e in exported),
duration=duration,
)
def export_stems(
self,
stems: dict[str, str],
output_dir: str | Path = "./output/export/",
formats: list[str] | None = None,
) -> list[ExportResult]:
"""
导出所有分轨
参数:
stems: {stem_name: audio_path, ...}
output_dir: 输出目录
formats: 目标格式
返回:
每轨的 ExportResult 列表
"""
results = []
for name, path in stems.items():
result = self.export_master(
Path(path), output_dir / name, formats
)
results.append(result)
return results
def export_with_lyrics(
self,
master_path: str | Path,
lyric_timeline: Any, # LyricTimeline
output_dir: str | Path = "./output/export/",
formats: list[str] | None = None,
) -> ExportResult:
"""
导出带歌词的主混音
同时导出音频 + LRC/SRT 歌词文件
"""
output_dir = Path(output_dir)
result = self.export_master(master_path, output_dir, formats)
# 导出 LRC
lrc_path = output_dir / f"{Path(master_path).stem}.lrc"
lrc_content = lyric_timeline.to_lrc()
lrc_path.write_text(lrc_content, encoding="utf-8")
result.exported_files.append({
"format": "lrc",
"path": str(lrc_path.resolve()),
"size_kb": round(lrc_path.stat().st_size / 1024, 1),
})
# 导出 SRT
srt_path = output_dir / f"{Path(master_path).stem}.srt"
srt_content = lyric_timeline.to_srt()
srt_path.write_text(srt_content, encoding="utf-8")
result.exported_files.append({
"format": "srt",
"path": str(srt_path.resolve()),
"size_kb": round(srt_path.stat().st_size / 1024, 1),
})
result.has_lyrics = True
result.formats_completed.extend(["lrc", "srt"])
result.total_size_kb = sum(e["size_kb"] for e in result.exported_files)
print(f" [export] 歌词: LRC + SRT 已导出")
return result
# ---- 内部 ----
def _convert(
self,
src: Path,
dst: Path,
fmt: str,
metadata: dict | None = None,
) -> bool:
"""格式转换"""
try:
if fmt == "wav":
return self._to_wav(src, dst)
elif fmt == "mp3":
return self._to_mp3(src, dst, metadata)
elif fmt == "flac":
return self._to_flac(src, dst)
elif fmt == "aac":
return self._to_aac(src, dst, metadata)
return False
except Exception as e:
print(f" [export] {fmt} 转换失败: {e}")
return False
def _to_wav(self, src: Path, dst: Path) -> bool:
"""复制/重编码为 WAV (PCM 16-bit)"""
import soundfile as sf
y, sr = sf.read(str(src))
sf.write(str(dst), y, sr, subtype="PCM_16")
return dst.exists()
def _to_mp3(self, src: Path, dst: Path, metadata: dict | None = None) -> bool:
"""转 MP3 (通过 ffmpeg)"""
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-codec:a", "libmp3lame",
"-b:a", self.mp3_bitrate,
"-ar", str(self.sample_rate),
]
if metadata:
if "title" in metadata:
cmd += ["-metadata", f"title={metadata['title']}"]
if "artist" in metadata:
cmd += ["-metadata", f"artist={metadata['artist']}"]
cmd.append(str(dst))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0 and dst.exists()
def _to_flac(self, src: Path, dst: Path) -> bool:
"""转 FLAC (通过 ffmpeg)"""
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-codec:a", "flac",
"-ar", str(self.sample_rate),
str(dst),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0 and dst.exists()
def _to_aac(self, src: Path, dst: Path, metadata: dict | None = None) -> bool:
"""转 AAC/M4A (通过 ffmpeg)"""
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-codec:a", "aac",
"-b:a", "256k",
"-ar", str(self.sample_rate),
]
if metadata:
if "title" in metadata:
cmd += ["-metadata", f"title={metadata['title']}"]
if "artist" in metadata:
cmd += ["-metadata", f"artist={metadata['artist']}"]
cmd.append(str(dst))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0 and dst.exists()
@staticmethod
def _get_duration(path: Path) -> float:
"""获取音频时长(用 lib.ffmpeg —— 本机 ffprobe 实为 ffmpeg 符号链接)"""
try:
import sys as _s
from pathlib import Path as _P
_scripts = _P(__file__).resolve().parents[3]
if str(_scripts) not in _s.path:
_s.path.insert(0, str(_scripts))
from lib.ffmpeg import probe_duration
return round(probe_duration(str(path)), 2)
except Exception:
return 0.0
@@ -0,0 +1,226 @@
"""
layer5_output/lyric_formatter.py — 歌词格式化模块
功能:将歌词时间轴输出为多种字幕/歌词格式
支持格式:
- LRC (LyRiCs) — 标准歌词格式
- SRT (SubRip) — 通用字幕格式
- ASS (Advanced SubStation Alpha) — 高级字幕 (带样式)
- TTML (Timed Text Markup Language) — WebVTT 替代
- plain text — 纯文本 (无时间戳)
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class FormattedLyrics:
"""格式化歌词结果"""
lrc: str = ""
srt: str = ""
ass: str = ""
ttml: str = ""
plain: str = ""
def save_all(self, output_dir: str | Path, basename: str = "lyrics") -> dict[str, str]:
"""保存所有格式到文件"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
saved = {}
fmt_map = {
"lrc": self.lrc,
"srt": self.srt,
"ass": self.ass,
"ttml": self.ttml,
"txt": self.plain,
}
for ext, content in fmt_map.items():
if content:
path = output_dir / f"{basename}.{ext}"
path.write_text(content, encoding="utf-8")
saved[ext] = str(path.resolve())
return saved
class LyricFormatter:
"""
歌词格式化器
将时间轴条目(entries)转换为各种字幕格式。
支持自定义 ASS 样式。
"""
# ASS 默认样式
DEFAULT_ASS_STYLE = (
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
"Style: Default, Arial, 24, &H00FFFFFF, &H000000FF, "
"&H00000000, &H00000000, 0, 0, 0, 0, "
"100, 100, 0, 0, 1, 1, 1, 2, 20, 20, 40, 1\n"
)
def format_all(
self,
entries: list[dict],
total_duration: float = 0.0,
title: str = "AudioLine",
ass_style: str | None = None,
) -> FormattedLyrics:
"""
生成所有格式的歌词
参数:
entries: [{start, end, text, section}] 时间轴条目
total_duration: 总时长
title: 标题
ass_style: ASS 样式定义 (可选)
返回:
FormattedLyrics
"""
return FormattedLyrics(
lrc=self.to_lrc(entries, title),
srt=self.to_srt(entries),
ass=self.to_ass(entries, ass_style or self.DEFAULT_ASS_STYLE),
ttml=self.to_ttml(entries, total_duration, title),
plain=self.to_plain(entries),
)
# ---- LRC ----
def to_lrc(self, entries: list[dict], title: str = "AudioLine") -> str:
"""转为 LRC 格式"""
lines = [
"[ti:{}]".format(title),
"[offset:0]",
]
last_section = ""
for entry in entries:
section = entry.get("section", "")
if section and section != last_section:
lines.append("")
lines.append("[{: >8}]".format(section))
last_section = section
start = entry["start"]
mm, ss = divmod(start, 60)
cs = int((start - int(start)) * 100)
lines.append("[{:02d}:{:02d}.{:02d}]{:s}".format(
int(mm), int(ss), cs, entry["text"]
))
return "\n".join(lines) + "\n"
# ---- SRT ----
def to_srt(self, entries: list[dict]) -> str:
"""转为 SRT 格式"""
blocks = []
for i, entry in enumerate(entries, 1):
start_srt = self._sec_to_srt(entry["start"])
end_srt = self._sec_to_srt(entry["end"])
section = entry.get("section", "")
text = entry["text"]
if section:
text = f"{text}\n({section})"
blocks.append("{:d}\n{:s} --> {:s}\n{:s}\n".format(
i, start_srt, end_srt, text
))
return "\n".join(blocks)
# ---- ASS ----
def to_ass(self, entries: list[dict], style_block: str = "") -> str:
"""转为 ASS 格式"""
style_block = style_block or self.DEFAULT_ASS_STYLE
header = (
"[Script Info]\n"
"ScriptType: v4.00+\n"
"Title: AudioLine Lyrics\n"
"WrapStyle: 2\n"
"ScaledBorderAndShadow: yes\n"
)
body = ""
for entry in entries:
start_ass = self._sec_to_ass(entry["start"])
end_ass = self._sec_to_ass(entry["end"])
text = entry["text"].replace("\n", "\\N")
section = entry.get("section", "")
if section:
text = f"{{\\i1}}{section}{{\\i0}} - {text}"
body += "Dialogue: 0,{start},{end},Default,,0,0,0,,{text}\n".format(
start=start_ass, end=end_ass, text=text
)
return header + style_block + "\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + body
# ---- TTML ----
def to_ttml(self, entries: list[dict], total_duration: float, title: str = "") -> str:
"""转为 TTML (类似 WebVTT) 格式"""
lines = ["WEBVTT", f"Title: {title}"]
if total_duration:
lines.append(f"Duration: {self._sec_to_srt(total_duration)}")
lines.append("")
for entry in entries:
start_vtt = self._sec_to_srt(entry["start"]).replace(",", ".")
end_vtt = self._sec_to_srt(entry["end"]).replace(",", ".")
lines.append("{:s} --> {:s}".format(start_vtt, end_vtt))
lines.append(entry["text"])
lines.append("")
return "\n".join(lines)
# ---- Plain ----
def to_plain(self, entries: list[dict]) -> str:
"""转为纯文本"""
last_section = ""
lines = []
for entry in entries:
section = entry.get("section", "")
if section and section != last_section:
lines.append("")
lines.append("[{}]".format(section))
last_section = section
lines.append(entry["text"])
return "\n".join(lines).strip()
# ---- 辅助 ----
@staticmethod
def _sec_to_srt(sec: float) -> str:
"""秒 → SRT 时间格式 (HH:MM:SS,mmm)"""
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
ms = int((s - int(s)) * 1000)
return "{:02d}:{:02d}:{:02d},{:03d}".format(int(h), int(m), int(s), ms)
@staticmethod
def _sec_to_ass(sec: float) -> str:
"""秒 → ASS 时间格式 (H:MM:SS.cc)"""
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
cs = int((s - int(s)) * 100)
return "{:d}:{:02d}:{:02d}.{:02d}".format(int(h), int(m), int(s), cs)
@@ -0,0 +1,434 @@
"""
orchestrator.py — 音频创作管线编排控制器
职责:串联所有 6 层模块,按顺序执行 AudioTask。
支持模块选择、dry-run、状态报告、错误恢复。
"""
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
import yaml
# Layer 1 — 输入
from layer1_input.collector import AudioTask, AudioSource, AudioCollector
# Layer 2 — 分析
from layer2_analysis.stem_separator import StemSeparator
from layer2_analysis.beat_detector import BeatDetector, BeatResult
from layer2_analysis.structure_parser import StructureParser, StructureResult
from layer2_analysis.emotion_analyzer import EmotionAnalyzer, EmotionResult
# Layer 3 — 创作
from layer3_creation.module_a_rhythm import RhythmModule, RhythmResult
from layer3_creation.module_b_lyrics import LyricsModule, LyricsResult
from layer3_creation.module_c_melody import MelodyModule, MelodyResult
from layer3_creation.module_d_mix import MixModule, MixResult
# Layer 4 — 对齐
from layer4_alignment.lyric_aligner import LyricAligner, LyricTimeline
from layer4_alignment.stem_aligner import StemAligner, AlignResult
# Layer 5 — 输出
from layer5_output.exporter import Exporter, ExportResult
from layer5_output.lyric_formatter import LyricFormatter, FormattedLyrics
# ---------------------------------------------------------------------------
# 结果数据类
# ---------------------------------------------------------------------------
@dataclass
class AudioLineResult:
"""完整管线输出"""
task: AudioTask = field(default_factory=lambda: AudioTask(
source=AudioSource(path="", source_type="command")
))
# Layer 2 结果
stems: dict[str, str] = field(default_factory=dict)
beat: BeatResult | None = None
structure: StructureResult | None = None
emotion: EmotionResult | None = None
# Layer 3 结果
rhythm: RhythmResult | None = None
lyrics: LyricsResult | None = None
melody: MelodyResult | None = None
mix: MixResult | None = None
# Layer 4 结果
lyric_timeline: LyricTimeline | None = None
stem_alignment: AlignResult | None = None
# Layer 5 结果
export: ExportResult | None = None
formatted_lyrics: FormattedLyrics | None = None
# 状态
modules_run: list[str] = field(default_factory=list)
errors: list[dict] = field(default_factory=list) # [{module, error}]
timing: dict[str, float] = field(default_factory=dict) # {module: elapsed_sec}
success: bool = False
@property
def summary(self) -> str:
"""生成执行摘要"""
lines = [
f"任务: {self.task.operation} | 源: {self.task.source.original_filename or '(指令)'}",
f"模块: {', '.join(self.modules_run) or '(无)'}",
]
if self.beat:
lines.append(f"BPM: {self.beat.bpm:.1f} | 情绪: {self.emotion.mood if self.emotion else 'N/A'}")
if self.mix:
lines.append(f"混音: {self.mix.master_path}")
if self.export:
lines.append(f"导出: {self.export.summary}")
if self.errors:
lines.append(f"错误: {len(self.errors)} 个")
for e in self.errors[:3]:
lines.append(f" ⚠ {e['module']}: {e['error']}")
lines.append(f"成功: {'✅' if self.success else '❌'}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# 编排器
# ---------------------------------------------------------------------------
class AudioLineOrchestrator:
"""
音频创作管线编排器
按 AudioTask.modules 配置依次执行各层。
跳过失败的模块(不阻塞整条管线)。
"""
def __init__(self, config_path: str | Path | None = None):
self.config = self._load_config(config_path)
self.work_dir = Path(self.config.get("paths", {}).get("work_dir", "./work/audio_line"))
self.output_dir = Path(self.config.get("paths", {}).get("output_dir", "./output/audio_line"))
self.work_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents=True, exist_ok=True)
# — 初始化各层模块 —
self.collector = AudioCollector(str(self.work_dir))
sep_cfg = self.config.get("separation", {})
self.stem_sep = StemSeparator(
model=sep_cfg.get("model", "htdemucs_ft"),
device=sep_cfg.get("device", "cpu"),
shifts=sep_cfg.get("shifts", 2),
overlap=sep_cfg.get("overlap", 0.25),
out_dir=str(self.work_dir / "separated"),
)
beat_cfg = self.config.get("beat", {})
self.beat_det = BeatDetector(
min_bpm=beat_cfg.get("min_bpm", 60),
max_bpm=beat_cfg.get("max_bpm", 200),
)
self.structure_parser = StructureParser()
self.emotion_analyzer = EmotionAnalyzer()
self.rhythm_module = RhythmModule(self.config)
self.lyrics_module = LyricsModule(self.config)
self.melody_module = MelodyModule(self.config)
self.mix_module = MixModule(self.config)
align_cfg = self.config.get("alignment", {})
self.lyric_aligner = LyricAligner(
tolerance=align_cfg.get("time_tolerance", 0.1),
)
self.stem_aligner = StemAligner(
method=align_cfg.get("stretch_method", "phase_vocoder"),
crossfade=align_cfg.get("crossfade", 0.05),
)
self.exporter = Exporter(self.config)
self.lyric_formatter = LyricFormatter()
def run(self, task: AudioTask) -> AudioLineResult:
"""
执行管线
参数:
task: AudioTask — 由 AudioCollector 创建的标准化任务
返回:
AudioLineResult
"""
result = AudioLineResult(task=task)
print(f"\n{'='*60}")
print(f"AudioLine 管线启动")
print(f" operation: {task.operation}")
print(f" source: {task.source.path or '(指令模式)'}")
print(f" modules: {', '.join(sorted(task.modules))}")
print(f"{'='*60}\n")
modules_to_run = self._plan_modules(task)
for module_name, callable_fn, ctx_key in modules_to_run:
if module_name not in task.modules:
continue
start = time.time()
print(f"\n--- [{module_name}] ---")
try:
callable_fn(result)
elapsed = time.time() - start
result.modules_run.append(module_name)
result.timing[module_name] = round(elapsed, 2)
print(f" [{module_name}] ✅ 完成 ({elapsed:.1f}s)")
except Exception as e:
elapsed = time.time() - start
result.errors.append({"module": module_name, "error": str(e)})
result.timing[module_name] = round(elapsed, 2)
print(f" [{module_name}] ❌ 失败: {e}")
print(f" [{module_name}] 跳过,继续下一步")
result.success = len(result.errors) == 0
print(f"\n{'='*60}")
print(f"管线结束 | 模块: {len(result.modules_run)}/{len(modules_to_run)}"
f" | 错误: {len(result.errors)} | 耗时: {sum(result.timing.values()):.1f}s")
if result.success:
print(f"状态: ✅ 成功")
else:
print(f"状态: ⚠ 部分成功 ({len(result.errors)} 个错误)")
print(f"{'='*60}\n")
return result
# ---- 模块路由 ----
def _plan_modules(self, task: AudioTask) -> list[tuple[str, Callable, str]]:
"""
规划模块执行顺序
返回: [(module_name, callable_fn, context_key)]
"""
return [
# Layer 2 — 分析
("separation", self._run_stem_separation, "stems"),
("analysis", self._run_analysis, "analysis"),
# Layer 3 — 创作
("rhythm", self._run_rhythm, "rhythm"),
("lyrics", self._run_lyrics, "lyrics"),
("melody", self._run_melody, "melody"),
("mix", self._run_mix, "mix"),
# Layer 4 — 对齐
("alignment", self._run_alignment, "alignment"),
# Layer 5 — 输出
("export", self._run_export, "export"),
]
# ---- Layer 2 ----
def _run_stem_separation(self, result: AudioLineResult):
"""音轨分离"""
task = result.task
if not task.source.path:
print(" [orchestrator] ⚠ 无音频源,跳过音轨分离")
return
result.stems = self.stem_sep.separate(task.source.path)
def _run_analysis(self, result: AudioLineResult):
"""执行所有分析"""
# 用原始音频或 vocals stem 分析
audio_for_analysis = result.stems.get("vocals", "")
if not audio_for_analysis or not Path(audio_for_analysis).exists():
audio_for_analysis = result.task.source.path
if not audio_for_analysis:
print(" [orchestrator] ⚠ 无音频可用于分析")
return
# — BPM/节拍 —
result.beat = self.beat_det.detect(audio_for_analysis)
# — 结构解析 —
result.structure = self.structure_parser.parse(
audio_for_analysis, result.beat.beats
)
# — 情绪分析 —
result.emotion = self.emotion_analyzer.analyze(
audio_for_analysis, result.beat.bpm
)
# ---- Layer 3 ----
def _run_rhythm(self, result: AudioLineResult):
"""节奏重塑"""
task = result.task
bpm = result.beat.bpm if result.beat else 120.0
target_bpm = task.target_bpm or bpm
output_dir = self.output_dir / "rhythm"
output_dir.mkdir(parents=True, exist_ok=True)
result.rhythm = self.rhythm_module.generate(
bpm=target_bpm,
genre=task.style,
duration=task.duration or 30,
output_path=str(output_dir / "drum_track.wav"),
)
def _run_lyrics(self, result: AudioLineResult):
"""歌词创作"""
# 如果有原始歌词,从原曲分析获取
original_lyrics = "" # 默认空,需要时用户提供
output_dir = self.output_dir / "lyrics"
output_dir.mkdir(parents=True, exist_ok=True)
# 仅当有主题时执行改写
if result.task.topic:
result.lyrics = self.lyrics_module.rewrite(
original_lyrics=original_lyrics,
topic=result.task.topic,
style=result.task.style,
output_path=str(output_dir / "rewritten_lyrics.txt"),
)
else:
print(" [orchestrator] ⚠ 无歌词主题,跳过歌词改写")
def _run_melody(self, result: AudioLineResult):
"""旋律编曲"""
audio_path = result.stems.get("vocals", "") or result.task.source.path
if not audio_path:
print(" [orchestrator] ⚠ 无音频源,跳过旋律编曲")
return
output_dir = self.output_dir / "melody"
output_dir.mkdir(parents=True, exist_ok=True)
result.melody = self.melody_module.remix(
audio_path=audio_path,
target_style=result.task.style,
duration=result.task.duration or 0,
output_path=str(output_dir / "remix.wav"),
)
def _run_mix(self, result: AudioLineResult):
"""混音母带"""
if not result.stems:
print(" [orchestrator] ⚠ 无分轨可供混音,跳过")
return
output_dir = self.output_dir / "mix"
output_dir.mkdir(parents=True, exist_ok=True)
# 如果有生成的鼓点轨道,加入混音
stems_to_mix = dict(result.stems)
if result.rhythm and result.rhythm.drum_track_path:
stems_to_mix["drum_track"] = result.rhythm.drum_track_path
result.mix = self.mix_module.mix(
stems=stems_to_mix,
output_path=str(output_dir / "master.wav"),
include_stems=True,
)
# ---- Layer 4 ----
def _run_alignment(self, result: AudioLineResult):
"""对齐处理"""
# — 歌词对齐 —
if result.lyrics and result.beat:
lyrics_text = result.lyrics.rewritten_lyrics or result.lyrics.original_lyrics
sections = result.structure.segments if result.structure else None
result.lyric_timeline = self.lyric_aligner.align(
lyrics=lyrics_text,
beats=result.beat.beats,
sections=sections,
total_duration=result.beat.duration_sec,
)
# — 分轨对齐 —
if result.stems and result.beat:
# 如果有目标 BPM,进行 BPM 对齐
task = result.task
target_bpm = task.target_bpm
if target_bpm and target_bpm != result.beat.bpm:
output_dir = self.output_dir / "aligned"
output_dir.mkdir(parents=True, exist_ok=True)
result.stem_alignment = self.stem_aligner.align_to_bpm(
stems=result.stems,
source_bpm=result.beat.bpm,
target_bpm=target_bpm,
output_dir=str(output_dir),
)
# ---- Layer 5 ----
def _run_export(self, result: AudioLineResult):
"""导出"""
output_dir = self.output_dir / "export"
output_dir.mkdir(parents=True, exist_ok=True)
# — 导出混音 —
master_path = result.mix.master_path if result.mix else ""
if master_path and Path(master_path).exists():
result.export = self.exporter.export_master(
master_path=master_path,
output_dir=str(output_dir),
)
# — 导出带歌词版本 —
if result.lyric_timeline:
result.export = self.exporter.export_with_lyrics(
master_path=master_path,
lyric_timeline=result.lyric_timeline,
output_dir=str(output_dir),
)
# — 歌词格式化 —
if result.lyric_timeline and result.lyric_timeline.entries:
result.formatted_lyrics = self.lyric_formatter.format_all(
entries=result.lyric_timeline.entries,
total_duration=result.lyric_timeline.total_duration,
title=f"AudioLine_{result.task.operation}",
)
result.formatted_lyrics.save_all(
str(output_dir / "lyrics"),
basename="lyrics",
)
# ---- 配置加载 ----
@staticmethod
def _load_config(config_path: str | Path | None) -> dict:
"""加载 YAML 配置"""
if config_path and Path(config_path).exists():
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
# 尝试默认路径
for path in [
"./config.yaml",
"./config/local.yaml",
]:
p = Path(path)
if p.exists():
with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
print("[orchestrator] ⚠ 未找到配置文件,使用默认值")
return {}