"""VoiceCloneProfile 领域模型 — Phase 3 CosyVoice 集成. 音色克隆档案,用于管理用户的自定义音色。 状态机: pending → processing → ready ↘ failed → pending (重试) ↘ disabled """ 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 VoiceCloneStatus(StrEnum): """音色克隆状态枚举。""" PENDING = "pending" """待处理(音频已上传,等待克隆)""" PROCESSING = "processing" """处理中(正在调用 CosyVoice API 克隆)""" READY = "ready" """就绪(克隆完成,可用于 TTS)""" FAILED = "failed" """失败(克隆失败)""" DISABLED = "disabled" """已禁用(用户手动禁用或违规)""" # 终态集合 TERMINAL_STATUSES = frozenset({VoiceCloneStatus.READY, VoiceCloneStatus.FAILED, VoiceCloneStatus.DISABLED}) # 合法状态转换 _VALID_TRANSITIONS: dict[VoiceCloneStatus, set[VoiceCloneStatus]] = { VoiceCloneStatus.PENDING: {VoiceCloneStatus.PROCESSING, VoiceCloneStatus.FAILED, VoiceCloneStatus.DISABLED}, VoiceCloneStatus.PROCESSING: {VoiceCloneStatus.READY, VoiceCloneStatus.FAILED, VoiceCloneStatus.DISABLED}, VoiceCloneStatus.FAILED: {VoiceCloneStatus.PENDING}, # 重试回到 pending VoiceCloneStatus.READY: {VoiceCloneStatus.DISABLED}, # 可以禁用已就绪的音色 } @dataclass(slots=True) class VoiceCloneProfile: """音色克隆档案实体。 Attributes: id: 档案唯一标识 user_id: 所属用户 name: 音色名称(用户可见) description: 音色描述 status: 当前状态 source_audio_url: 源音频文件 URL(用户上传的用于克隆的音频) voice_id: CosyVoice 返回的音色 ID(克隆成功后填充) voice_model: 使用的模型名称 language: 语言代码(如 zh-CN, en-US) gender: 性别(male/female/unknown) error_message: 错误信息 retry_count: 已重试次数 max_retries: 最大重试次数 metadata: 扩展元数据(JSON) created_at: 创建时间 updated_at: 最后更新时间 """ id: str user_id: str name: str description: str = "" status: VoiceCloneStatus = VoiceCloneStatus.PENDING source_audio_url: str = "" voice_id: str = "" voice_model: str = "" language: str = "zh-CN" gender: str = "unknown" error_message: str = "" retry_count: int = 0 max_retries: int = 3 metadata: dict = field(default_factory=dict) 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, name: str, *, description: str = "", source_audio_url: str = "", voice_model: str = "", language: str = "zh-CN", gender: str = "unknown", max_retries: int = 3, metadata: dict | None = None, ) -> VoiceCloneProfile: """创建音色克隆档案。 Args: user_id: 用户 ID name: 音色名称 description: 音色描述 source_audio_url: 源音频文件 URL voice_model: 使用的模型名称 language: 语言代码 gender: 性别 max_retries: 最大重试次数 metadata: 扩展元数据 Returns: 新建的 VoiceCloneProfile 实例 Raises: ValueError: 参数校验失败 """ if not user_id.strip(): raise ValueError("user_id 不能为空") if not name.strip(): raise ValueError("name 不能为空") if len(name.strip()) > 100: raise ValueError("name 长度不能超过 100 字符") return cls( id=uuid4().hex, user_id=user_id.strip(), name=name.strip(), description=description.strip(), source_audio_url=source_audio_url.strip(), voice_model=voice_model.strip(), language=language.strip(), gender=gender.strip().lower(), 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 == VoiceCloneStatus.FAILED and self.retry_count < self.max_retries @property def is_ready(self) -> bool: """是否就绪(可用于 TTS)。""" return self.status == VoiceCloneStatus.READY and bool(self.voice_id) def transition_to(self, new_status: VoiceCloneStatus | str) -> None: """执行状态转换。 Args: new_status: 目标状态 Raises: ValueError: 非法状态转换 """ if isinstance(new_status, str): try: new_status = VoiceCloneStatus(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(VoiceCloneStatus.PROCESSING) self.error_message = "" def mark_ready(self, voice_id: str) -> None: """标记为就绪。 Args: voice_id: CosyVoice 返回的音色 ID """ if not voice_id.strip(): raise ValueError("voice_id 不能为空") self.transition_to(VoiceCloneStatus.READY) self.voice_id = voice_id.strip() self.error_message = "" def mark_failed(self, error_message: str) -> None: """标记为失败。 Args: error_message: 错误信息 """ self.transition_to(VoiceCloneStatus.FAILED) self.error_message = error_message def mark_disabled(self) -> None: """标记为禁用。""" self.transition_to(VoiceCloneStatus.DISABLED) def prepare_retry(self) -> None: """准备重试:重置状态为 pending。 Raises: ValueError: 不可重试 """ if not self.is_retryable: raise ValueError( f"音色克隆不可重试: status={self.status.value}, " f"retry_count={self.retry_count}, max_retries={self.max_retries}" ) self.retry_count += 1 self.transition_to(VoiceCloneStatus.PENDING) self.error_message = "" self.voice_id = "" def to_dict(self) -> dict: """序列化为字典。""" return { "id": self.id, "user_id": self.user_id, "name": self.name, "description": self.description, "status": self.status.value, "source_audio_url": self.source_audio_url, "voice_id": self.voice_id, "voice_model": self.voice_model, "language": self.language, "gender": self.gender, "error_message": self.error_message, "retry_count": self.retry_count, "max_retries": self.max_retries, "is_retryable": self.is_retryable, "is_ready": self.is_ready, "metadata": self.metadata, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, }