Files
xiaoxia-saas/packages/domain/bgm_pool.py
xiaoxia 98cf571ab3
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
feat: #1767 BGM 池差异化分配,打破变体间音频指纹一致性 (#1785)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:12:52 +08:00

315 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BGM 池差异化分配 — 打破变体间音频指纹一致性 (Issue #1767).
三层递进策略:
1. **BGM 池分配(核心)**:维护风格匹配的 BGM 池,每个变体基于 variant_seed
随机分配一首不同 BGM,保证变体间音频指纹不同。
2. **段落差异化(池不够时的补充)**:同一首 BGM 做差异化裁剪,不同变体使用
不同起始点/段落,进一步降低音频相似度。
3. **音量微调**:不同变体 BGM 音量 ±3dB 微调,混音比例有微小差异。
约束:
- 不破坏现有单视频 BGM 选择逻辑(单视频不走池分配)
- BGM 情绪/风格与视频内容匹配(基于源 plan 的 BGM style 做风格筛选)
- 分配可复现(同 seed 同结果)
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
# ── BGM 池条目 ──────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class BGMPoolEntry:
"""BGM 池条目"""
id: str
preset_id: str # 关联 PRESET_BGM_LIBRARY 中的 ID(用于渲染侧解析音频路径)
mood: str # 情绪/风格:upbeat / relax / tech / commerce / emotional / cinematic
duration: float # 时长(秒)
audio_url: str = "" # CDN/OSS 直链(优先级高于 preset_id
tags: list[str] = field(default_factory=list)
# ── BGM 池(10 首,覆盖 6 种风格) ──────────────────────────────────────────
BGM_POOL: list[BGMPoolEntry] = [
# upbeat (轻快)
BGMPoolEntry(
id="pool_upbeat_001", preset_id="bgm_upbeat_001", mood="upbeat", duration=120.0, tags=["轻快", "阳光", "vlog"]
),
BGMPoolEntry(
id="pool_upbeat_002", preset_id="bgm_upbeat_002", mood="upbeat", duration=95.0, tags=["轻快", "电子", "运动"]
),
BGMPoolEntry(
id="pool_upbeat_003", preset_id="bgm_upbeat_003", mood="upbeat", duration=110.0, tags=["轻快", "夏日", "旅行"]
),
# relax (治愈)
BGMPoolEntry(
id="pool_relax_001", preset_id="bgm_relax_001", mood="relax", duration=180.0, tags=["治愈", "钢琴", "冥想"]
),
BGMPoolEntry(
id="pool_relax_002", preset_id="bgm_relax_002", mood="relax", duration=150.0, tags=["治愈", "自然", "放松"]
),
BGMPoolEntry(
id="pool_relax_003", preset_id="bgm_relax_003", mood="relax", duration=200.0, tags=["治愈", "古典", "钢琴"]
),
# tech (科技)
BGMPoolEntry(
id="pool_tech_001", preset_id="bgm_tech_001", mood="tech", duration=85.0, tags=["科技", "电子", "数码"]
),
BGMPoolEntry(
id="pool_tech_002", preset_id="bgm_tech_002", mood="tech", duration=100.0, tags=["科技", "极简", "AI"]
),
# commerce (电商)
BGMPoolEntry(
id="pool_commerce_001",
preset_id="bgm_commerce_001",
mood="commerce",
duration=75.0,
tags=["电商", "时尚", "带货"],
),
BGMPoolEntry(
id="pool_commerce_002",
preset_id="bgm_commerce_002",
mood="commerce",
duration=90.0,
tags=["电商", "品牌", "品质"],
),
]
# ── 风格 → 情绪映射 ────────────────────────────────────────────────────────
# preset_bgm.py 中 style 字段 → bgm_pool.py 中 mood 字段
STYLE_TO_MOOD: dict[str, str] = {
"upbeat": "upbeat",
"relax": "relax",
"tech": "tech",
"commerce": "commerce",
"emotional": "emotional",
"cinematic": "cinematic",
}
# ── 策略一:BGM 池分配 ─────────────────────────────────────────────────────
def get_bgm_pool_candidates(source_mood: str | None = None) -> list[BGMPoolEntry]:
"""获取 BGM 池候选列表。
如果指定了 source_mood,优先返回同 mood 的条目;
如果同 mood 条目不足 2 个,降级返回全池(保证有足够候选)。
Args:
source_mood: 源 BGM 的情绪/风格(来自 preset_bgm.py 的 style 字段)
Returns:
候选 BGM 列表(至少 2 个条目)
"""
if not source_mood:
return list(BGM_POOL)
mood = STYLE_TO_MOOD.get(source_mood, source_mood)
matched = [e for e in BGM_POOL if e.mood == mood]
# 同 mood 至少要有 2 首,否则无法"差异化",降级全池
if len(matched) >= 2:
return matched
return list(BGM_POOL)
def select_bgm_from_pool(
variant_seed: int,
candidates: list[BGMPoolEntry] | None = None,
) -> BGMPoolEntry:
"""基于 variant_seed 从候选池中选一首 BGM(可复现)。
Args:
variant_seed: 变体随机种子
candidates: 候选池(None 时使用全池)
Returns:
选中的 BGM 条目
"""
pool = candidates if candidates is not None else list(BGM_POOL)
if not pool:
pool = list(BGM_POOL)
rng = random.Random(variant_seed)
return rng.choice(pool)
# ── 策略二:段落差异化 ─────────────────────────────────────────────────────
def generate_bgm_segment_offset(variant_seed: int, bgm_duration: float) -> float:
"""为变体生成 BGM 段落起始偏移(策略二)。
不同变体从同一首 BGM 的不同位置开始播放,进一步降低音频指纹相似度。
偏移范围 [0, max_offset]max_offset = min(30s, bgm_duration * 0.3)。
量化到 5 秒整数倍,便于复现和调试。
Args:
variant_seed: 变体随机种子
bgm_duration: BGM 总时长(秒)
Returns:
起始偏移(秒),0 ~ max_offset 之间,5s 步长
"""
rng = random.Random(variant_seed + 7919) # 加素数偏移,避免与 BGM 选择 seed 序列重合
max_offset = min(30.0, bgm_duration * 0.3)
steps = int(max_offset // 5.0)
if steps <= 0:
return 0.0
return float(rng.randint(0, steps) * 5)
# ── 策略三:音量微调 ─────────────────────────────────────────────────────
def generate_bgm_volume_adjust(variant_seed: int) -> float:
"""为变体生成 BGM 音量微调值(策略三)。
±3dB 微调,让不同变体的 BGM/配音混音比例有微小差异。
离散步长:-3, -2, -1, 0, 1, 2, 3 dB。
Args:
variant_seed: 变体随机种子
Returns:
音量调整值(dB),-3.0 ~ 3.0
"""
rng = random.Random(variant_seed + 104729) # 另一个素数偏移
return float(rng.choice([-3, -2, -1, 0, 1, 2, 3]))
# ── 批量分配入口 ──────────────────────────────────────────────────────────
def allocate_bgm_pool_for_variants(
source_bgm_config: dict,
variant_seeds: list[int],
) -> list[dict]:
"""为批量变体分配不同的 BGM 池配置。
整合三层策略:池分配 + 段落偏移 + 音量微调。
每个变体得到一个 dict,可直接合并到 plan.config["bgm"] 中。
Args:
source_bgm_config: 源 plan 的 BGM 配置(用于风格匹配)
variant_seeds: 每个变体的随机种子列表
Returns:
每个变体的 BGM 池配置 dict 列表(与 variant_seeds 等长),
每项包含 preset_id / audio_url / audio_offset / volume_adjust_db。
如果源 BGM 未启用,返回空列表。
"""
if not source_bgm_config or not source_bgm_config.get("enabled", False):
return []
if not variant_seeds:
return []
# 从源 BGM 配置中推断风格
source_mood = _infer_source_mood(source_bgm_config)
# 策略一:获取候选池
candidates = get_bgm_pool_candidates(source_mood)
# 为每个变体分配不同的 BGM(尽量不重复)
assignments = _assign_unique_bgm(candidates, variant_seeds)
results = []
for i, (entry, seed) in enumerate(zip(assignments, variant_seeds, strict=False)):
# 策略二:段落偏移
offset = generate_bgm_segment_offset(seed, entry.duration)
# 策略三:音量微调
volume_adj = generate_bgm_volume_adjust(seed)
result = {
"preset_id": entry.preset_id,
"audio_url": entry.audio_url,
"audio_offset": offset,
"volume_adjust_db": volume_adj,
"bgm_pool_entry_id": entry.id,
"bgm_pool_mood": entry.mood,
}
results.append(result)
logger.info(
"变体 %d BGM 池分配: seed=%d bgm=%s mood=%s offset=%.1fs vol_adj=%+.0fdB",
i,
seed,
entry.id,
entry.mood,
offset,
volume_adj,
)
return results
def _infer_source_mood(source_bgm_config: dict) -> str | None:
"""从源 BGM 配置推断风格/情绪。
优先级:
1. preset_id → 查 preset_bgm 库获取 style
2. bgm_pool_mood → 上游已设置过(二次分配场景)
3. 无法推断 → None(返回全池候选)
"""
preset_id = source_bgm_config.get("preset_id", "")
if preset_id:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset:
return STYLE_TO_MOOD.get(preset.style, preset.style)
# 如果之前已经分配过 BGM 池,直接用 mood
pool_mood = source_bgm_config.get("bgm_pool_mood", "")
if pool_mood:
return pool_mood
return None
def _assign_unique_bgm(
candidates: list[BGMPoolEntry],
variant_seeds: list[int],
) -> list[BGMPoolEntry]:
"""尽量让每个变体选到不同的 BGM。
策略:用 seed 选 BGM,如果与前面变体重复,用递增 seed 重试。
如果候选池大小 < 变体数,允许重复但不连续。
"""
if not candidates or not variant_seeds:
return []
assignments: list[BGMPoolEntry] = []
used_ids: set[str] = set()
for i, seed in enumerate(variant_seeds):
rng = random.Random(seed)
# 先尝试选一个没用过的
chosen = None
for _attempt in range(len(candidates)):
candidate = rng.choice(candidates)
if candidate.id not in used_ids:
chosen = candidate
break
if chosen is None:
# 候选池已用完,允许重复但取下一个(循环)
idx = i % len(candidates)
chosen = candidates[idx]
assignments.append(chosen)
used_ids.add(chosen.id)
return assignments