diff --git a/examples/voice_clone.py b/examples/voice_clone.py new file mode 100644 index 0000000..ac253b1 --- /dev/null +++ b/examples/voice_clone.py @@ -0,0 +1,69 @@ +from zai import ZaiClient, ZhipuAiClient +import time +import os + +def voice_clone(): + # Initialize client + client = ZhipuAiClient() + + # Step 1: Upload the voice input audio file + # First, we need to upload the voice sample audio file to get a file ID + voice_input_file_path = "tests/integration_tests/voice_clone_input.mp3" + + try: + with open(voice_input_file_path, 'rb') as f: + upload_response = client.files.create( + file=f, + purpose='voice-clone-input', + ) + + print(f"Voice input file uploaded successfully with ID: {upload_response.id}") + file_id = upload_response.id + + except FileNotFoundError: + print(f"File not found: {voice_input_file_path}") + return + except Exception as e: + print(f"File upload failed: {e}") + return + + # Step 2: Clone voice using the uploaded file ID + response = client.voice.clone( + voice_name="My Test Voice!", + text="This is sample text for voice cloning training", + input="This is target text for voice preview generation", + file_id=file_id, + request_id=f"voice_clone_request_{int(time.time() * 1000)}", + model="cogtts-clone" + ) + print(f"Voice clone response: {response}") + +def voice_delete(): + # Initialize client + client = ZhipuAiClient() + + # Delete voice + response = client.voice.delete( + voice="Your voice", + request_id=f"voice_delete_request_{int(time.time() * 1000)}" + ) + print(response) + +def voice_list(): + # Initialize client + client = ZhipuAiClient() + + # List voices with filter + response = client.voice.list( + voice_type="PRIVATE", + voice_name="Test", + request_id=f"voice_list_request_{int(time.time() * 1000)}" + ) + print(response) + +if __name__ == "__main__": + # voice_clone() + + # voice_delete() + + voice_list() \ No newline at end of file diff --git a/src/zai/_client.py b/src/zai/_client.py index f7fedf4..93e3c8c 100644 --- a/src/zai/_client.py +++ b/src/zai/_client.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from zai.api_resource.moderations import Moderations from zai.api_resource.tools import Tools from zai.api_resource.videos import Videos + from zai.api_resource.voice import Voice from zai.api_resource.web_search import WebSearchApi from .core import ( @@ -180,6 +181,12 @@ class BaseClient(HttpClient): return Moderations(self) + @cached_property + def voice(self) -> Voice: + from zai.api_resource.voice import Voice + + return Voice(self) + @property @override def auth_headers(self) -> dict[str, str]: diff --git a/src/zai/api_resource/files/files.py b/src/zai/api_resource/files/files.py index c613f73..1fbe6d6 100644 --- a/src/zai/api_resource/files/files.py +++ b/src/zai/api_resource/files/files.py @@ -40,7 +40,7 @@ class Files(BaseAPI): *, file: FileTypes = None, upload_detail: List[UploadDetail] = None, - purpose: Literal['fine-tune', 'retrieval', 'batch'], + purpose: Literal['fine-tune', 'retrieval', 'batch', 'voice-clone-input'], knowledge_id: str = None, sentence_size: int = None, extra_headers: Headers | None = None, diff --git a/src/zai/api_resource/voice/__init__.py b/src/zai/api_resource/voice/__init__.py new file mode 100644 index 0000000..16b5e9f --- /dev/null +++ b/src/zai/api_resource/voice/__init__.py @@ -0,0 +1,3 @@ +from .voice import Voice + +__all__ = ['Voice'] \ No newline at end of file diff --git a/src/zai/api_resource/voice/voice.py b/src/zai/api_resource/voice/voice.py new file mode 100644 index 0000000..d2232f2 --- /dev/null +++ b/src/zai/api_resource/voice/voice.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +import httpx +from httpx import stream + +from zai.core import ( + NOT_GIVEN, + BaseAPI, + Body, + Headers, + NotGiven, + make_request_options, + maybe_transform, +) +from zai.types.voiceclone import ( + VoiceCloneParams, + VoiceCloneResult, + VoiceDeleteParams, + VoiceDeleteResult, + VoiceListParams, + VoiceListResult, +) + +if TYPE_CHECKING: + from zai._client import ZaiClient + + +class Voice(BaseAPI): + """ + Voice API resource for handling voice cloning operations + """ + + def __init__(self, client: ZaiClient) -> None: + super().__init__(client) + + def clone( + self, + *, + voice_name: str, + text: str, + input: str, + file_id: str, + request_id: Optional[str] = None, + model: str, + extra_headers: Headers | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> VoiceCloneResult: + """ + Clone a voice with the provided audio sample and parameters + + Args: + voice_name: Name for the cloned voice + text: Text content corresponding to the sample audio + input: Target text for preview audio + file_id: File ID of the uploaded audio file + request_id: Optional request ID for tracking + model: Model + extra_headers: Additional headers to include in the request + extra_body: Additional body parameters + timeout: Request timeout + + Returns: + Voice clone response + """ + + return self._post( + "/voice/clone", + body=maybe_transform( + { + "voice_name": voice_name, + "text": text, + "input": input, + "file_id": file_id, + "request_id": request_id, + "model": model, + }, + VoiceCloneParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ), + cast_type=VoiceCloneResult, + stream=False, + ) + + def delete( + self, + *, + voice: str, + request_id: Optional[str] = None, + extra_headers: Headers | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> VoiceDeleteResult: + """ + Delete a cloned voice by voice ID + + Args: + voice: The voice to delete + request_id: Optional request ID for tracking + extra_headers: Additional headers to include in the request + extra_body: Additional body parameters + timeout: Request timeout + + Returns: + Voice deletion response + """ + return self._post( + "/voice/delete", + body=maybe_transform( + { + "voice": voice, + "request_id": request_id, + }, + VoiceDeleteParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ), + cast_type=VoiceDeleteResult, + stream=False, + ) + + def list( + self, + *, + voice_type: Optional[str] = None, + voice_name: Optional[str] = None, + request_id: Optional[str] = None, + extra_headers: Headers | None = None, + timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + ) -> VoiceListResult: + """ + List voices with optional filtering + + Args: + voice_type: Type of voice to filter by + voice_name: Name of voice to filter by + request_id: Optional request ID for tracking + extra_headers: Additional headers to include in the request + timeout: Request timeout + + Returns: + List of voices response + """ + return self._get( + "/voice/list", + options=make_request_options( + extra_headers={ + **({} if request_id is None else {"Request-Id": request_id}), + **(extra_headers or {}), + }, + extra_query=maybe_transform( + { + "voiceType": voice_type, + "voiceName": voice_name, + "request_id": request_id, + }, + VoiceListParams, + ), + timeout=timeout, + ), + cast_type=VoiceListResult, + ) \ No newline at end of file diff --git a/src/zai/types/voiceclone/__init__.py b/src/zai/types/voiceclone/__init__.py new file mode 100644 index 0000000..040b5a1 --- /dev/null +++ b/src/zai/types/voiceclone/__init__.py @@ -0,0 +1,19 @@ +from .voice_clone_params import VoiceCloneParams +from .voice_delete_params import VoiceDeleteParams +from .voice_list_params import VoiceListParams +from .voice_object import ( + VoiceCloneResult, + VoiceDeleteResult, + VoiceData, + VoiceListResult, +) + +__all__ = [ + 'VoiceCloneParams', + 'VoiceDeleteParams', + 'VoiceListParams', + 'VoiceCloneResult', + 'VoiceDeleteResult', + 'VoiceData', + 'VoiceListResult', +] \ No newline at end of file diff --git a/src/zai/types/voiceclone/voice_clone_params.py b/src/zai/types/voiceclone/voice_clone_params.py new file mode 100644 index 0000000..e82eae3 --- /dev/null +++ b/src/zai/types/voiceclone/voice_clone_params.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Optional + +from typing_extensions import Required, TypedDict + + +class VoiceCloneParams(TypedDict, total=False): + """ + Parameters for voice cloning + + Attributes: + voice_name (str): Name for the cloned voice + voice_text_input (str): Text content corresponding to the sample audio + voice_text_output (str): Target text for preview audio + file_id (str): File ID of the uploaded audio file + request_id (Optional[str]): Optional request ID for tracking + """ + + voice_name: Required[str] + voice_text_input: Required[str] + voice_text_output: Required[str] + file_id: Required[str] + request_id: Optional[str] \ No newline at end of file diff --git a/src/zai/types/voiceclone/voice_delete_params.py b/src/zai/types/voiceclone/voice_delete_params.py new file mode 100644 index 0000000..5141db2 --- /dev/null +++ b/src/zai/types/voiceclone/voice_delete_params.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Optional + +from typing_extensions import Required, TypedDict + + +class VoiceDeleteParams(TypedDict, total=False): + """ + Parameters for voice deletion + + Attributes: + voice (str): The voice to delete + request_id (Optional[str]): Optional request ID for tracking + """ + + voice: Required[str] + request_id: Optional[str] \ No newline at end of file diff --git a/src/zai/types/voiceclone/voice_list_params.py b/src/zai/types/voiceclone/voice_list_params.py new file mode 100644 index 0000000..3550b4f --- /dev/null +++ b/src/zai/types/voiceclone/voice_list_params.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Optional + +from typing_extensions import TypedDict + + +class VoiceListParams(TypedDict, total=False): + """ + Parameters for listing voices + + Attributes: + voice_type (Optional[str]): Type of voice to filter by + voice_name (Optional[str]): Name of voice to filter by + request_id (Optional[str]): Optional request ID for tracking + """ + + voice_type: Optional[str] + voice_name: Optional[str] + request_id: Optional[str] \ No newline at end of file diff --git a/src/zai/types/voiceclone/voice_object.py b/src/zai/types/voiceclone/voice_object.py new file mode 100644 index 0000000..399b298 --- /dev/null +++ b/src/zai/types/voiceclone/voice_object.py @@ -0,0 +1,61 @@ +from typing import List + +from zai.core import BaseModel + + +class VoiceCloneResult(BaseModel): + """ + Voice cloning result + + Attributes: + voice (str): Voice + file_id (str): Audio preview file ID + file_purpose (str): File purpose + """ + + voice: str + file_id: str + file_purpose: str + + +class VoiceDeleteResult(BaseModel): + """ + Voice deletion result + + Attributes: + voice (str): Voice + update_time (str): Delete time (format: yyyy-MM-dd HH:mm:ss) + """ + + voice: str + update_time: str + + +class VoiceData(BaseModel): + """ + Voice data information + + Attributes: + voice (str): Voice + voice_name (str): Voice name + voice_type (str): Voice type + download_url (str): Download URL + create_time (str): Create time (format: yyyy-MM-dd HH:mm:ss) + """ + + voice: str + voice_name: str + voice_type: str + download_url: str + create_time: str + + +class VoiceListResult(BaseModel): + """ + Voice list result + + Attributes: + voice_list (List[VoiceData]): List of voices + """ + + voice_list: List[VoiceData] \ No newline at end of file diff --git a/tests/integration_tests/test_voice_clone.py b/tests/integration_tests/test_voice_clone.py new file mode 100644 index 0000000..2bf1144 --- /dev/null +++ b/tests/integration_tests/test_voice_clone.py @@ -0,0 +1,86 @@ +import logging +import logging.config +import time +from pathlib import Path + +import zai +from zai import ZaiClient + +def test_voice_clone(logging_conf): + """Test voice cloning with existing file ID""" + logging.config.dictConfig(logging_conf) # type: ignore + client = ZaiClient() # Fill in your own API Key + + try: + # Upload file first + voice_input_file_path = Path(__file__).parent / 'voice_clone_input.mp3' + with open(voice_input_file_path, 'rb') as f: + upload_response = client.files.create( + file=f, + purpose='voice-clone-input', + ) + + # Clone voice + request_id = f"voice_clone_only_test_{int(time.time() * 1000)}" + response = client.voice.clone( + voice_name="Test Voice Clone Only", + text="This is sample text for voice cloning training", + input="This is target text for voice preview generation", + file_id=upload_response.id, + request_id=request_id, + model="cogtts-clone" + ) + print(f"Voice clone response: {response}") + + except FileNotFoundError: + print("Voice input file not found: voice_clone_input.mp3") + except zai.core._errors.APIRequestFailedError as err: + print(f"API Request Failed: {err}") + except zai.core._errors.APIInternalError as err: + print(f"API Internal Error: {err}") + except zai.core._errors.APIStatusError as err: + print(f"API Status Error: {err}") + + +def test_voice_list(logging_conf): + """Test voice listing functionality""" + logging.config.dictConfig(logging_conf) # type: ignore + client = ZaiClient() # Fill in your own API Key + + try: + request_id = f"voice_list_test_{int(time.time() * 1000)}" + response = client.voice.list( + voice_type="PRIVATE", + request_id=request_id + ) + print(f"Voice list response: {response}") + + except zai.core._errors.APIRequestFailedError as err: + print(f"API Request Failed: {err}") + except zai.core._errors.APIInternalError as err: + print(f"API Internal Error: {err}") + except zai.core._errors.APIStatusError as err: + print(f"API Status Error: {err}") + + +def test_voice_delete(logging_conf): + """Test voice deletion functionality""" + logging.config.dictConfig(logging_conf) # type: ignore + client = ZaiClient() # Fill in your own API Key + + try: + # Note: Replace with actual voice from a previous clone operation + voice = "test_voice_placeholder" + request_id = f"voice_delete_test_{int(time.time() * 1000)}" + response = client.voice.delete( + voice=voice, + request_id=request_id + ) + print(f"Voice delete response: {response}") + + except zai.core._errors.APIRequestFailedError as err: + print(f"API Request Failed: {err}") + except zai.core._errors.APIInternalError as err: + print(f"API Internal Error: {err}") + except zai.core._errors.APIStatusError as err: + print(f"API Status Error: {err}") \ No newline at end of file diff --git a/tests/integration_tests/voice_clone_input.mp3 b/tests/integration_tests/voice_clone_input.mp3 new file mode 100644 index 0000000..0eb5ef7 Binary files /dev/null and b/tests/integration_tests/voice_clone_input.mp3 differ