feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,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"
|
||||
+180
@@ -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
|
||||
+166
@@ -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",
|
||||
}
|
||||
+98
@@ -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
|
||||
+207
@@ -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",
|
||||
]
|
||||
+227
@@ -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
|
||||
+174
@@ -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)
|
||||
+135
@@ -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",
|
||||
]
|
||||
+309
@@ -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,
|
||||
)
|
||||
+392
@@ -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
|
||||
+226
@@ -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 {}
|
||||
Reference in New Issue
Block a user