"""TTSJob 领域模型 — Phase 3 CosyVoice 集成. TTS 任务,用于管理文本转语音的合成请求。 状态机: pending → processing → completed ↘ failed → pending (重试) ↘ cancelled """ from __future__ import annotations import sys from dataclasses import dataclass, field from datetime import datetime, timezone if sys.version_info >= (3, 11): from enum import StrEnum else: from enum import Enum class StrEnum(str, Enum): pass from uuid import uuid4 class TTSJobStatus(StrEnum): """TTS 任务状态枚举。""" PENDING = "pending" """待处理(任务已创建,等待执行)""" PROCESSING = "processing" """处理中(正在调用 CosyVoice API 合成)""" COMPLETED = "completed" """已完成(音频合成成功)""" FAILED = "failed" """失败(合成失败)""" CANCELLED = "cancelled" """已取消(用户取消或系统取消)""" # 终态集合 TERMINAL_STATUSES = frozenset({TTSJobStatus.COMPLETED, TTSJobStatus.FAILED, TTSJobStatus.CANCELLED}) # 合法状态转换 _VALID_TRANSITIONS: dict[TTSJobStatus, set[TTSJobStatus]] = { TTSJobStatus.PENDING: {TTSJobStatus.PROCESSING, TTSJobStatus.FAILED, TTSJobStatus.CANCELLED}, TTSJobStatus.PROCESSING: {TTSJobStatus.COMPLETED, TTSJobStatus.FAILED, TTSJobStatus.CANCELLED}, TTSJobStatus.FAILED: {TTSJobStatus.PENDING}, # 重试回到 pending } @dataclass(slots=True) class TTSJob: """TTS 任务实体。 Attributes: id: 任务唯一标识 user_id: 所属用户 project_id: 所属项目(可选) voice_clone_profile_id: 关联的音色克隆档案 ID(可选,使用自定义音色时必填) status: 当前状态 input_text: 输入文本 voice_id: 使用的音色 ID(CosyVoice 内置音色或克隆音色 ID) voice_model: 使用的模型名称 output_audio_url: 输出音频文件 URL output_audio_key: 输出音频 OSS key duration: 音频时长(秒) file_size: 文件大小(字节) sample_rate: 采样率 format: 输出格式(mp3/wav/pcm) error_message: 错误信息 retry_count: 已重试次数 max_retries: 最大重试次数 metadata: 扩展元数据(JSON) started_at: 开始处理时间 completed_at: 完成时间 created_at: 创建时间 updated_at: 最后更新时间 """ id: str user_id: str input_text: str voice_id: str = "" voice_model: str = "" project_id: str = "" voice_clone_profile_id: str = "" status: TTSJobStatus = TTSJobStatus.PENDING output_audio_url: str = "" output_audio_key: str = "" duration: float = 0.0 file_size: int = 0 sample_rate: int = 22050 format: str = "mp3" error_message: str = "" retry_count: int = 0 max_retries: int = 3 metadata: dict = field(default_factory=dict) started_at: datetime | None = None completed_at: datetime | None = None created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @classmethod def create( cls, user_id: str, input_text: str, *, voice_id: str = "", voice_model: str = "", project_id: str = "", voice_clone_profile_id: str = "", sample_rate: int = 22050, format: str = "mp3", max_retries: int = 3, metadata: dict | None = None, ) -> TTSJob: """创建 TTS 任务。 Args: user_id: 用户 ID input_text: 输入文本 voice_id: 使用的音色 ID voice_model: 使用的模型名称 project_id: 项目 ID voice_clone_profile_id: 关联的音色克隆档案 ID sample_rate: 采样率 format: 输出格式 max_retries: 最大重试次数 metadata: 扩展元数据 Returns: 新建的 TTSJob 实例 Raises: ValueError: 参数校验失败 """ if not user_id.strip(): raise ValueError("user_id 不能为空") if not input_text.strip(): raise ValueError("input_text 不能为空") if len(input_text.strip()) > 10000: raise ValueError("input_text 长度不能超过 10000 字符") if format not in ("mp3", "wav", "pcm"): raise ValueError(f"不支持的输出格式: {format},支持: mp3/wav/pcm") return cls( id=uuid4().hex, user_id=user_id.strip(), input_text=input_text.strip(), voice_id=voice_id.strip(), voice_model=voice_model.strip(), project_id=project_id.strip(), voice_clone_profile_id=voice_clone_profile_id.strip(), sample_rate=sample_rate, format=format.strip(), max_retries=max_retries, metadata=metadata or {}, ) @property def is_terminal(self) -> bool: """是否处于终态。""" return self.status in TERMINAL_STATUSES @property def is_retryable(self) -> bool: """是否可重试(失败且未超过重试上限)。""" return self.status == TTSJobStatus.FAILED and self.retry_count < self.max_retries @property def is_completed(self) -> bool: """是否已完成。""" return self.status == TTSJobStatus.COMPLETED and bool(self.output_audio_url) def transition_to(self, new_status: TTSJobStatus | str) -> None: """执行状态转换。 Args: new_status: 目标状态 Raises: ValueError: 非法状态转换 """ if isinstance(new_status, str): try: new_status = TTSJobStatus(new_status) except ValueError: raise ValueError(f"无效状态: {new_status}") allowed = _VALID_TRANSITIONS.get(self.status, set()) if new_status not in allowed: raise ValueError( f"非法状态转换: {self.status.value} → {new_status.value}," f"允许: {{{', '.join(s.value for s in allowed)}}}" ) now = datetime.now(timezone.utc) self.status = new_status self.updated_at = now def mark_processing(self) -> None: """标记为处理中。""" self.transition_to(TTSJobStatus.PROCESSING) self.started_at = datetime.now(timezone.utc) self.error_message = "" def mark_completed( self, output_audio_url: str, *, output_audio_key: str = "", duration: float = 0.0, file_size: int = 0, ) -> None: """标记为已完成。 Args: output_audio_url: 输出音频 URL output_audio_key: 输出音频 OSS key duration: 音频时长 file_size: 文件大小 """ if not output_audio_url.strip(): raise ValueError("output_audio_url 不能为空") self.transition_to(TTSJobStatus.COMPLETED) self.output_audio_url = output_audio_url.strip() self.output_audio_key = output_audio_key.strip() self.duration = duration self.file_size = file_size self.completed_at = datetime.now(timezone.utc) self.error_message = "" def mark_failed(self, error_message: str) -> None: """标记为失败。 Args: error_message: 错误信息 """ self.transition_to(TTSJobStatus.FAILED) self.error_message = error_message def mark_cancelled(self) -> None: """标记为已取消。""" self.transition_to(TTSJobStatus.CANCELLED) def prepare_retry(self) -> None: """准备重试:重置状态为 pending。 Raises: ValueError: 不可重试 """ if not self.is_retryable: raise ValueError( f"TTS 任务不可重试: status={self.status.value}, " f"retry_count={self.retry_count}, max_retries={self.max_retries}" ) self.retry_count += 1 self.transition_to(TTSJobStatus.PENDING) self.error_message = "" self.started_at = None self.completed_at = None def to_dict(self) -> dict: """序列化为字典。""" return { "id": self.id, "user_id": self.user_id, "project_id": self.project_id, "voice_clone_profile_id": self.voice_clone_profile_id, "status": self.status.value, "input_text": self.input_text, "voice_id": self.voice_id, "voice_model": self.voice_model, "output_audio_url": self.output_audio_url, "output_audio_key": self.output_audio_key, "duration": self.duration, "file_size": self.file_size, "sample_rate": self.sample_rate, "format": self.format, "error_message": self.error_message, "retry_count": self.retry_count, "max_retries": self.max_retries, "is_retryable": self.is_retryable, "is_completed": self.is_completed, "metadata": self.metadata, "started_at": self.started_at.isoformat() if self.started_at else None, "completed_at": self.completed_at.isoformat() if self.completed_at else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, }