Files
xiaoxia c840f37a44
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 33s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m12s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 1m8s
CI/CD Pipeline / Unit Tests (push) Successful in 2m52s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (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
feat: TTS文字转语音配音引擎 (#295)
2026-07-14 10:21:49 +08:00

276 lines
8.3 KiB
Python
Executable File

"""TTS 配音引擎 — 集成到统一渲染管道的配音能力.
负责:
- 根据 TtsConfig 生成配音音频
- 字幕联动:按字幕片段分段合成,自动对齐时间轴
- 整段配音:整段文本生成一条音频
- 失败降级:TTS 失败不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from packages.domain.tts_config import TtsConfig
from packages.ports.tts_service import TtsError, TtsService
logger = logging.getLogger(__name__)
@dataclass
class VoiceoverSegment:
"""配音片段.
Attributes:
text: 文本内容
start_time: 开始时间(秒)
end_time: 结束时间(秒)
audio_path: 合成后的音频文件路径
duration: 音频实际时长
"""
text: str
start_time: float = 0.0
end_time: float = 0.0
audio_path: Path | None = None
duration: float = 0.0
@dataclass
class VoiceoverResult:
"""配音结果.
Attributes:
success: 是否成功
segments: 配音片段列表
total_duration: 总时长
error_message: 错误信息(失败时)
"""
success: bool = False
segments: list[VoiceoverSegment] = field(default_factory=list)
total_duration: float = 0.0
error_message: str = ""
class TtsEngine:
"""TTS 配音引擎.
封装 TtsService 调用,支持:
- 整段配音
- 字幕联动配音
- 失败降级
"""
def __init__(
self,
tts_service: TtsService,
work_dir: Path,
) -> None:
self._tts = tts_service
self._work_dir = work_dir
self._work_dir.mkdir(parents=True, exist_ok=True)
def generate_full_voiceover(
self,
config: TtsConfig,
*,
total_duration: float = 0.0,
) -> VoiceoverResult:
"""生成整段配音.
Args:
config: TTS 配置
total_duration: 视频总时长(用于调整配音速度适配)
Returns:
配音结果
"""
if not config.enabled or not config.text.strip():
return VoiceoverResult(success=False, error_message="配音未启用或文本为空")
try:
output_path = self._work_dir / "voiceover_full.wav"
audio_path = self._tts.synthesize(
text=config.text,
voice_id=config.voice_id,
speed=config.speed,
pitch=config.pitch,
output_path=output_path,
)
# 探测实际时长
duration = self._probe_duration(audio_path)
segment = VoiceoverSegment(
text=config.text,
start_time=0.0,
end_time=duration,
audio_path=audio_path,
duration=duration,
)
return VoiceoverResult(
success=True,
segments=[segment],
total_duration=duration,
)
except TtsError as e:
logger.warning("TTS 整段配音失败,降级跳过: %s", e)
return VoiceoverResult(success=False, error_message=str(e))
except Exception as e:
logger.warning("TTS 整段配音异常,降级跳过: %s", e)
return VoiceoverResult(success=False, error_message=str(e))
def generate_subtitle_voiceover(
self,
config: TtsConfig,
subtitles: list[dict[str, Any]],
) -> VoiceoverResult:
"""根据字幕生成配音(字幕联动).
每个字幕片段独立合成,按字幕时间轴对齐。
Args:
config: TTS 配置
subtitles: 字幕列表,每项含 text/start_time/end_time
Returns:
配音结果
"""
if not config.enabled:
return VoiceoverResult(success=False, error_message="配音未启用")
if not subtitles:
return VoiceoverResult(success=False, error_message="字幕为空")
segments: list[VoiceoverSegment] = []
total_duration = 0.0
for i, sub in enumerate(subtitles):
text = sub.get("text", "").strip()
if not text:
continue
start_time = float(sub.get("start_time", 0))
end_time = float(sub.get("end_time", 0))
target_duration = max(0.1, end_time - start_time)
try:
# 计算适配时长所需语速:让配音时长 ≈ 字幕时长
estimated = self._tts.estimate_duration(text, speed=config.speed)
adjusted_speed = config.speed
if estimated > 0 and target_duration > 0:
# 按目标时长调整语速,限制在 0.5~2.0 范围内
speed_factor = estimated / target_duration
adjusted_speed = max(0.5, min(2.0, config.speed * speed_factor))
output_path = self._work_dir / f"voiceover_seg_{i:03d}.wav"
audio_path = self._tts.synthesize(
text=text,
voice_id=config.voice_id,
speed=adjusted_speed,
pitch=config.pitch,
output_path=output_path,
)
actual_duration = self._probe_duration(audio_path)
segment = VoiceoverSegment(
text=text,
start_time=start_time,
end_time=start_time + actual_duration,
audio_path=audio_path,
duration=actual_duration,
)
segments.append(segment)
total_duration = max(total_duration, start_time + actual_duration)
except TtsError as e:
logger.warning("TTS 字幕片段 %d 合成失败,跳过: %s", i, e)
continue
except Exception as e:
logger.warning("TTS 字幕片段 %d 异常,跳过: %s", i, e)
continue
if not segments:
return VoiceoverResult(success=False, error_message="所有字幕片段合成失败")
return VoiceoverResult(
success=True,
segments=segments,
total_duration=total_duration,
)
def build_audio_mix_filter(
self,
result: VoiceoverResult,
*,
video_duration: float,
base_label: str = "0:a",
) -> tuple[str, list[Path]]:
"""构建配音混音滤镜.
将配音片段按时间轴排列,生成 amix 混入。
Args:
result: 配音结果
video_duration: 视频总时长
base_label: 基础音轨标签
Returns:
(filter_complex 字符串, 配音音频文件列表)
"""
if not result.success or not result.segments:
return "", []
filter_parts: list[str] = []
audio_files: list[Path] = []
delay_labels: list[str] = []
for i, seg in enumerate(result.segments):
if seg.audio_path is None or not seg.audio_path.exists():
continue
audio_files.append(seg.audio_path)
seg_label = f"v{i}"
# 音量调整
# 用 adelay 延迟到字幕开始时间
delay_ms = int(max(0, int(seg.start_time * 1000)))
filter_parts.append(f"[{i}:a]adelay={delay_ms}:all=1,volume=0.8[{seg_label}]")
delay_labels.append(f"[{seg_label}]")
if not delay_labels:
return "", []
# 所有片段 concat 成一条配音音轨(用 amix 叠加多个延时后的片段
mix_inputs = "".join(delay_labels)
n_inputs = len(delay_labels)
tts_label = "tts_mixed"
if n_inputs == 1:
# 单个片段直接用
filter_parts.append(f"{delay_labels[0]}[{tts_label}]")
else:
# 多个片段 amix 叠加
filter_parts.append(f"{mix_inputs}amix=inputs={n_inputs}:duration=longest[{tts_label}]")
return ";".join(filter_parts), audio_files
def _probe_duration(self, audio_path: Path) -> float:
"""探测音频时长."""
try:
from video_processing.ffmpeg_utils import probe_duration
return probe_duration(audio_path)
except Exception:
# 探测失败,按文件名估算
return 0.0