novelai-python 0.4.4__tar.gz → 0.4.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {novelai_python-0.4.4 → novelai_python-0.4.5}/PKG-INFO +2 -2
  2. {novelai_python-0.4.4 → novelai_python-0.4.5}/README.md +1 -1
  3. {novelai_python-0.4.4 → novelai_python-0.4.5}/pyproject.toml +1 -1
  4. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/__init__.py +4 -0
  5. novelai_python-0.4.5/src/novelai_python/_response/ai/generate_voice.py +7 -0
  6. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/__init__.py +1 -0
  7. novelai_python-0.4.5/src/novelai_python/sdk/ai/_enum.py +54 -0
  8. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/ai/generate_image/__init__.py +27 -42
  9. novelai_python-0.4.5/src/novelai_python/sdk/ai/generate_voice.py +201 -0
  10. novelai_python-0.4.5/tests/test_generate_voice.py +34 -0
  11. {novelai_python-0.4.4 → novelai_python-0.4.5}/LICENSE +0 -0
  12. {novelai_python-0.4.4 → novelai_python-0.4.5}/NOTICE.MD +0 -0
  13. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_exceptions.py +0 -0
  14. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/__init__.py +0 -0
  15. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/ai/__init__.py +0 -0
  16. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/ai/generate_image.py +0 -0
  17. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/ai/upscale.py +0 -0
  18. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/schema.py +0 -0
  19. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/user/__init__.py +0 -0
  20. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/user/information.py +0 -0
  21. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/user/login.py +0 -0
  22. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/_response/user/subscription.py +0 -0
  23. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/credential/ApiToken.py +0 -0
  24. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/credential/JwtToken.py +0 -0
  25. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/credential/UserAuth.py +0 -0
  26. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/credential/__init__.py +0 -0
  27. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/credential/_base.py +0 -0
  28. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/ai/LICENSE +0 -0
  29. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/ai/__init__.py +0 -0
  30. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/ai/generate_image/_const.py +0 -0
  31. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/ai/generate_image/_enum.py +0 -0
  32. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/ai/generate_image/suggest_tags.py +0 -0
  33. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/ai/upscale.py +0 -0
  34. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/schema.py +0 -0
  35. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/user/__init__.py +0 -0
  36. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/user/information.py +0 -0
  37. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/user/login.py +0 -0
  38. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/sdk/user/subscription.py +0 -0
  39. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/server.py +0 -0
  40. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tokenizer/__init__.py +0 -0
  41. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tokenizer/novelai.model +0 -0
  42. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tokenizer/novelai_v2.model +0 -0
  43. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/__init__.py +0 -0
  44. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/image_metadata/__init__.py +0 -0
  45. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/image_metadata/lsb_extractor.py +0 -0
  46. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/image_metadata/lsb_injector.py +0 -0
  47. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/paint_mask/__init__.py +0 -0
  48. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/random_prompt/__init__.py +0 -0
  49. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/random_prompt/tag.py +0 -0
  50. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/random_prompt/tag_character.py +0 -0
  51. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/tool/random_prompt/tag_nsfw.py +0 -0
  52. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/utils/__init__.py +0 -0
  53. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/utils/encode.py +0 -0
  54. {novelai_python-0.4.4 → novelai_python-0.4.5}/src/novelai_python/utils/useful.py +0 -0
  55. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/__init__.py +0 -0
  56. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/test_random_prompt.py +0 -0
  57. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/test_server.py +0 -0
  58. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/test_server_run.py +0 -0
  59. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/test_upscale.py +0 -0
  60. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/test_user_information.py +0 -0
  61. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/test_user_login.py +0 -0
  62. {novelai_python-0.4.4 → novelai_python-0.4.5}/tests/test_user_subscription.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: novelai-python
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: NovelAI Python Binding With Pydantic
5
5
  Author-Email: sudoskys <coldlando@hotmail.com>
6
6
  License: Apache License 2.0
@@ -51,11 +51,11 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
51
51
  - [x] /user/information
52
52
  - [x] /ai/upscale
53
53
  - [x] /ai/generate-image/suggest-tags
54
+ - [x] /ai/generate-voice
54
55
  - [ ] /ai/annotate-image
55
56
  - [ ] /ai/classify
56
57
  - [ ] /ai/generate-prompt
57
58
  - [ ] /ai/generate
58
- - [ ] /ai/generate-voice
59
59
 
60
60
  > GenerateImageInfer.calculate_cost is correct in most cases, but please request account information to get accurate
61
61
  > consumption information.
@@ -20,11 +20,11 @@ The goal of this repository is to use Pydantic to build legitimate requests to a
20
20
  - [x] /user/information
21
21
  - [x] /ai/upscale
22
22
  - [x] /ai/generate-image/suggest-tags
23
+ - [x] /ai/generate-voice
23
24
  - [ ] /ai/annotate-image
24
25
  - [ ] /ai/classify
25
26
  - [ ] /ai/generate-prompt
26
27
  - [ ] /ai/generate
27
- - [ ] /ai/generate-voice
28
28
 
29
29
  > GenerateImageInfer.calculate_cost is correct in most cases, but please request account information to get accurate
30
30
  > consumption information.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "novelai-python"
3
- version = "0.4.4"
3
+ version = "0.4.5"
4
4
  description = "NovelAI Python Binding With Pydantic"
5
5
  authors = [
6
6
  { name = "sudoskys", email = "coldlando@hotmail.com" },
@@ -12,6 +12,7 @@ from ._exceptions import (
12
12
  )
13
13
  from .credential import JwtCredential, LoginCredential, ApiCredential
14
14
  from .sdk import GenerateImageInfer, ImageGenerateResp
15
+ from .sdk import VoiceGenerate, VoiceResponse
15
16
  from .sdk import SuggestTags, SuggestTagsResp
16
17
  from .sdk import Information, InformationResp
17
18
  from .sdk import Login, LoginResp
@@ -22,6 +23,9 @@ __all__ = [
22
23
  "GenerateImageInfer",
23
24
  "ImageGenerateResp",
24
25
 
26
+ "VoiceGenerate",
27
+ "VoiceResponse",
28
+
25
29
  "Upscale",
26
30
  "UpscaleResp",
27
31
 
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class VoiceResponse(BaseModel):
5
+ meta: dict
6
+ audio: bytes
7
+ model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True)
@@ -6,6 +6,7 @@
6
6
 
7
7
  from .ai.generate_image import GenerateImageInfer, ImageGenerateResp # noqa 401
8
8
  from .ai.generate_image.suggest_tags import SuggestTags, SuggestTagsResp # noqa 401
9
+ from .ai.generate_voice import VoiceGenerate, VoiceResponse # noqa 401
9
10
  from .ai.upscale import Upscale, UpscaleResp # noqa 401
10
11
  from .user.information import Information, InformationResp # noqa 401
11
12
  from .user.login import Login, LoginResp # noqa 401
@@ -0,0 +1,54 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel
4
+ from enum import Enum
5
+
6
+
7
+ class Speaker(BaseModel):
8
+ """
9
+ Speaker for /ai/generated_voice
10
+ """
11
+ sid: int = -1
12
+ seed: Optional[str] = None
13
+ name: str
14
+ category: str
15
+
16
+ @property
17
+ def version(self):
18
+ return "v2" if self.sid is None else "v1"
19
+
20
+
21
+ class VoiceSpeakerV1(Enum):
22
+ """
23
+ Speaker for /ai/generated_voice
24
+ """
25
+ Cyllene = Speaker(sid=17, name="Cyllene", category="female")
26
+ Leucosia = Speaker(sid=95, name="Leucosia", category="female")
27
+ Crina = Speaker(sid=44, name="Crina", category="female")
28
+ Hespe = Speaker(sid=80, name="Hespe", category="female")
29
+ Ida = Speaker(sid=106, name="Ida", category="female")
30
+ Alseid = Speaker(sid=6, name="Alseid", category="male")
31
+ Daphnis = Speaker(sid=10, name="Daphnis", category="male")
32
+ Echo = Speaker(sid=16, name="Echo", category="male")
33
+ Thel = Speaker(sid=41, name="Thel", category="male")
34
+ Nomios = Speaker(sid=77, name="Nomios", category="male")
35
+ # SeedInput = Speaker(sid=-1, name="Seed Input", category="custom")
36
+
37
+
38
+ class VoiceSpeakerV2(Enum):
39
+ """
40
+ Speaker for /ai/generated_voice
41
+ """
42
+ Ligeia = Speaker(name="Ligeia", category="unisex", seed="Anananan")
43
+ Aini = Speaker(name="Aini", category="female", seed="Aini")
44
+ Orea = Speaker(name="Orea", category="female", seed="Orea")
45
+ Claea = Speaker(name="Claea", category="female", seed="Claea")
46
+ Lim = Speaker(name="Lim", category="female", seed="Lim")
47
+ Aurae = Speaker(name="Aurae", category="female", seed="Aurae")
48
+ Naia = Speaker(name="Naia", category="female", seed="Naia")
49
+ Aulon = Speaker(name="Aulon", category="male", seed="Aulon")
50
+ Elei = Speaker(name="Elei", category="male", seed="Elei")
51
+ Ogma = Speaker(name="Ogma", category="male", seed="Ogma")
52
+ Raid = Speaker(name="Raid", category="male", seed="Raid")
53
+ Pega = Speaker(name="Pega", category="male", seed="Pega")
54
+ Lam = Speaker(name="Lam", category="male", seed="Lam")
@@ -9,7 +9,7 @@ import math
9
9
  import random
10
10
  from copy import deepcopy
11
11
  from io import BytesIO
12
- from typing import Optional, Union
12
+ from typing import Optional, Union, Tuple
13
13
  from urllib.parse import urlparse
14
14
  from zipfile import ZipFile
15
15
 
@@ -17,6 +17,7 @@ import curl_cffi
17
17
  import cv2
18
18
  import httpx
19
19
  import numpy as np
20
+ from PIL import Image
20
21
  from curl_cffi.requests import AsyncSession
21
22
  from loguru import logger
22
23
  from pydantic import BaseModel, ConfigDict, PrivateAttr, field_validator, model_validator, Field
@@ -30,7 +31,6 @@ from ...._exceptions import APIError, AuthError, ConcurrentGenerationError, Sess
30
31
  from ...._response.ai.generate_image import ImageGenerateResp
31
32
  from ....credential import CredentialBase
32
33
  from ....utils import try_jsonfy
33
- from PIL import Image, ImageEnhance
34
34
 
35
35
 
36
36
  class GenerateImageInfer(ApiBaseModel):
@@ -159,58 +159,43 @@ class GenerateImageInfer(ApiBaseModel):
159
159
  @staticmethod
160
160
  def add_image_to_black_background(
161
161
  image: Union[str, bytes],
162
- width: int = 448,
163
- height: int = 448,
164
- transparency: bool = True
162
+ target_size: Tuple[int, int] = (448, 448),
163
+ transparency: bool = False
165
164
  ):
166
165
 
167
166
  """
168
167
  缩放图像到指定的黑色透明背景上,使其尽可能大且保持比例。
169
168
  :param transparency: 是否透明
170
169
  :param image: 图像
171
- :param width: 宽
172
- :param height: 高
170
+ :param target_size: 目标尺寸
173
171
  :return: 新图像
174
172
  """
175
173
 
176
174
  if isinstance(image, str):
177
175
  image = base64.b64decode(image)
178
-
179
- # Decode the image from the base64 string
176
+ # Decode the image from the base64 string
180
177
  npimg = np.frombuffer(image, np.uint8)
181
-
182
- # Read the image using OpenCV
183
- open_image = cv2.imdecode(npimg, cv2.IMREAD_UNCHANGED)
184
-
185
- # Calculate the ratio for scaling
186
- width_ratio = width / open_image.shape[1]
187
- height_ratio = height / open_image.shape[0]
188
- ratio = min(width_ratio, height_ratio)
189
-
190
- # Create new image size
191
- new_image_size = (int(open_image.shape[1] * ratio), int(open_image.shape[0] * ratio))
192
-
178
+ # Load the image with OpenCV
179
+ img = cv2.imdecode(npimg, cv2.IMREAD_UNCHANGED)
180
+ # Calculate the aspect ratio (keeping aspect ratio)
181
+ ratio = min(target_size[0] / img.shape[1], target_size[1] / img.shape[0])
182
+ new_image_size = (int(img.shape[1] * ratio), int(img.shape[0] * ratio))
193
183
  # Resize the image
194
- open_image = cv2.resize(open_image, new_image_size, interpolation=cv2.INTER_LINEAR)
195
-
196
- # Create black background
197
- if open_image.shape[2] == 3: # no alpha channel, add one
198
- open_image = cv2.cvtColor(open_image, cv2.COLOR_BGR2BGRA)
199
- if transparency:
200
- bg_color = [0, 0, 0, 0]
201
- else:
202
- bg_color = [0, 0, 0, 255]
203
- new_image = np.full((height, width, 4), bg_color, dtype=np.uint8)
204
-
205
- # Compute coords to place image on black background
206
- position = ((width - open_image.shape[1]) // 2, (height - open_image.shape[0]) // 2)
207
-
208
- # Place the image on black background at the calculated position
209
- new_image[position[1]:position[1] + open_image.shape[0],
210
- position[0]:position[0] + open_image.shape[1]] = open_image
211
-
212
- # Encode image to base64 string
213
- _, buffer = cv2.imencode('.png', new_image)
184
+ img = cv2.resize(img, new_image_size, interpolation=cv2.INTER_LINEAR)
185
+ # Check if the image has alpha channel (transparency)
186
+ if img.shape[2] == 3: # no alpha channel, add one
187
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
188
+ # Create new image with black background
189
+ delta_w = target_size[0] - img.shape[1]
190
+ delta_h = target_size[1] - img.shape[0]
191
+ top, bottom = delta_h // 2, delta_h - (delta_h // 2)
192
+ left, right = delta_w // 2, delta_w - (delta_w // 2)
193
+ # If the transparency flag is set the background is transparent, otherwise it is black
194
+ color = [0, 0, 0, 0] if transparency else [0, 0, 0, 255]
195
+ # Add padding to the image
196
+ new_img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)
197
+ # Convert the image to base64
198
+ _, buffer = cv2.imencode(".png", new_img)
214
199
  return base64.b64encode(buffer).decode("utf-8")
215
200
 
216
201
  # Validators
@@ -289,7 +274,7 @@ class GenerateImageInfer(ApiBaseModel):
289
274
  "lowres, {bad}, error, fewer, extra, missing, worst quality, jpeg artifacts, bad quality, "
290
275
  "watermark, unfinished, displeasing, chromatic aberration, signature, extra digits, artistic error, "
291
276
  "username, scan, [abstract], bad anatomy, bad hands, @_@, mismatched pupils, heart-shaped pupils, "
292
- "glowing eyes, nsfw, lowres, bad anatomy, bad hands, text, error, missing fingers, "
277
+ "glowing eyes, lowres, bad anatomy, bad hands, text, error, missing fingers, "
293
278
  "extra digit, fewer digits, cropped, worst quality, low quality, normal quality, "
294
279
  f"jpeg artifacts, signature, watermark, username, blurry "
295
280
  f",{self.parameters.negative_prompt}"
@@ -0,0 +1,201 @@
1
+ # -*- coding: utf-8 -*-
2
+ # @Author : sudoskys
3
+ import base64
4
+ import json
5
+ from io import BytesIO
6
+ from typing import Optional, Union, Literal
7
+ from urllib.parse import urlparse
8
+ from zipfile import ZipFile
9
+
10
+ import curl_cffi
11
+ import httpx
12
+ import pydantic
13
+ from curl_cffi.requests import AsyncSession
14
+ from loguru import logger
15
+ from pydantic import ConfigDict, PrivateAttr, Field, model_validator
16
+ from tenacity import wait_random, retry, stop_after_attempt, retry_if_exception
17
+
18
+ from ..schema import ApiBaseModel
19
+ from ..._exceptions import APIError, AuthError, SessionHttpError
20
+ from ..._response.ai.generate_voice import VoiceResponse
21
+ from ...credential import CredentialBase
22
+ from ...utils import try_jsonfy
23
+ from ._enum import VoiceSpeakerV1, VoiceSpeakerV2
24
+
25
+
26
+ class VoiceGenerate(ApiBaseModel):
27
+ """
28
+ Voice Generate for /ai/generate-voice
29
+ """
30
+ _endpoint: str = PrivateAttr("https://api.novelai.net")
31
+ text: str = Field(..., description="Text to generate voice", max_length=1000)
32
+ """Limit 1000 characters"""
33
+ voice: int = Field(default=-1, description="Seed", ge=-1)
34
+ """Voice engine sid"""
35
+ seed: Optional[str] = Field(None, description="seed_seed", max_length=50)
36
+ """
37
+ Starting with a common first name will have a relevant influence on pitch and intonation.
38
+ The voice for any given seed is liable to change in the future as we continue to develop the TTS.
39
+ """
40
+ opus: bool = False
41
+ version: Union[Literal["v2", "v1"], str] = "v2"
42
+
43
+ model_config = ConfigDict(extra="ignore")
44
+
45
+ @model_validator(mode="after")
46
+ def validate_model(self):
47
+ # Validate seed
48
+ if self.voice == -1 and self.seed is None:
49
+ raise pydantic.ValidationError("Seed must be set when voice is -1")
50
+ return self
51
+
52
+ @property
53
+ def base_url(self):
54
+ return f"{self.endpoint.strip('/')}/ai/generate-voice"
55
+
56
+ @property
57
+ def endpoint(self):
58
+ return self._endpoint
59
+
60
+ @endpoint.setter
61
+ def endpoint(self, value):
62
+ self._endpoint = value
63
+
64
+ async def necessary_headers(self, request_data) -> dict:
65
+ """
66
+ :param request_data: dict
67
+ :return: dict
68
+ "Sec-Ch-Ua": '"Edge";v="123", "Not:A-Brand";v="8"',
69
+ "Sec-Ch-Ua-Mobile": "?0",
70
+ "Sec-Ch-Ua-Platform": '"Windows"',
71
+ "Sec-Fetch-Dest": "empty",
72
+ "Sec-Fetch-Mode": "cors",
73
+ "Sec-Fetch-Site": "same-site",
74
+ """
75
+ return {
76
+ "Host": urlparse(self.endpoint).netloc,
77
+ "Accept": "*/*",
78
+ "Accept-Language": "zh-CN,zh;q=0.9",
79
+ "Cache-Control": "no-cache",
80
+ "Origin": "https://novelai.net",
81
+ "Pragma": "no-cache",
82
+ "Referer": "https://novelai.net/",
83
+ }
84
+
85
+ @classmethod
86
+ def build(cls,
87
+ text: str,
88
+ voice_engine: Union[VoiceSpeakerV1, VoiceSpeakerV2, str],
89
+ *,
90
+ opus: bool = False
91
+ ) -> "VoiceGenerate":
92
+ """
93
+ 生成图片
94
+ :param opus: unknown
95
+ :param text: str
96
+ :param voice_engine: VoiceSpeakerV1 or VoiceSpeakerV2 or str
97
+ :return: VoiceGenerate instance
98
+ :raises: ValueError
99
+ """
100
+ if isinstance(voice_engine, str):
101
+ return cls(
102
+ text=text,
103
+ voice=-1,
104
+ seed=voice_engine,
105
+ opus=opus,
106
+ version="v2"
107
+ )
108
+ if isinstance(voice_engine, VoiceSpeakerV2):
109
+ return cls(
110
+ text=text,
111
+ seed=voice_engine.value.seed,
112
+ voice=voice_engine.value.sid,
113
+ opus=opus,
114
+ version="v2"
115
+ )
116
+ if isinstance(voice_engine, VoiceSpeakerV1):
117
+ return cls(
118
+ text=text,
119
+ seed=voice_engine.value.seed,
120
+ voice=voice_engine.value.sid,
121
+ opus=opus,
122
+ version="v1"
123
+ )
124
+ raise ValueError("Invalid voice engine")
125
+
126
+ @retry(
127
+ wait=wait_random(min=1, max=3),
128
+ stop=stop_after_attempt(3),
129
+ retry=retry_if_exception(lambda e: hasattr(e, "code") and str(e.code) == "500"),
130
+ reraise=True
131
+ )
132
+ async def request(self,
133
+ session: Union[AsyncSession, "CredentialBase"],
134
+ *,
135
+ override_headers: Optional[dict] = None
136
+ ) -> VoiceResponse:
137
+ """
138
+ 生成图片
139
+ :param override_headers:
140
+ :param session: session
141
+ :return: VoiceResponse
142
+ """
143
+ # Data Build
144
+ request_data = self.model_dump(mode="json", exclude_none=True)
145
+ # Header
146
+ if isinstance(session, AsyncSession):
147
+ session.headers.update(await self.necessary_headers(request_data))
148
+ elif isinstance(session, CredentialBase):
149
+ update_header = await self.necessary_headers(request_data)
150
+ session = await session.get_session(update_headers=update_header)
151
+ if override_headers:
152
+ session.headers.clear()
153
+ session.headers.update(override_headers)
154
+ # Log
155
+ logger.debug(f"Voice request data: {request_data}")
156
+ # Request
157
+ try:
158
+ assert hasattr(session, "get"), "session must have get method."
159
+ response = await session.get(
160
+ self.base_url,
161
+ params=request_data
162
+ )
163
+ header_type = response.headers.get('Content-Type')
164
+ if header_type not in ['audio/mpeg', 'audio/ogg', 'audio/opus']:
165
+ logger.warning(
166
+ f"Error with content type: {header_type} and code: {response.status_code}"
167
+ )
168
+ try:
169
+ _msg = response.json()
170
+ except Exception as e:
171
+ logger.warning(e)
172
+ if not isinstance(response.content, str) and len(response.content) < 50:
173
+ raise APIError(
174
+ message=f"Unexpected content: {header_type} with code: {response.status_code}",
175
+ request=request_data,
176
+ code=response.status_code,
177
+ response="UnJsoned content"
178
+ )
179
+ else:
180
+ _msg = {"statusCode": response.status_code, "message": response.content}
181
+ status_code = _msg.get("statusCode", response.status_code)
182
+ message = _msg.get("message", "Unknown error")
183
+ if status_code in [400]:
184
+ # Validation tts version error
185
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
186
+ raise APIError(message, request=request_data, code=status_code, response=_msg)
187
+ return VoiceResponse(
188
+ meta=request_data,
189
+ audio=response.content,
190
+ )
191
+ except curl_cffi.requests.errors.RequestsError as exc:
192
+ logger.exception(exc)
193
+ raise SessionHttpError("An AsyncSession RequestsError occurred, maybe SSL error. Try again later!")
194
+ except httpx.HTTPError as exc:
195
+ logger.exception(exc)
196
+ raise SessionHttpError("An HTTPError occurred, maybe SSL error. Try again later!")
197
+ except APIError as e:
198
+ raise e
199
+ except Exception as e:
200
+ logger.opt(exception=e).exception("An Unexpected error occurred")
201
+ raise e
@@ -0,0 +1,34 @@
1
+ import pytest
2
+ from unittest import mock
3
+
4
+ from curl_cffi.requests import AsyncSession
5
+
6
+ from src.novelai_python.sdk.ai.generate_voice import VoiceGenerate, VoiceResponse
7
+
8
+
9
+ @pytest.mark.asyncio
10
+ async def test_request():
11
+ # Arrange
12
+ mock_response = mock.MagicMock()
13
+ mock_response.content = b'audio_content'
14
+ mock_response.headers = {'Content-Type': 'audio/mpeg'}
15
+ session = mock.MagicMock(spec=AsyncSession)
16
+ session.get = mock.AsyncMock(return_value=mock_response)
17
+ session.headers = {}
18
+
19
+ voice_generate = VoiceGenerate(
20
+ text="Hello, world!",
21
+ voice=-1,
22
+ seed="seed",
23
+ opus=False,
24
+ version="v2"
25
+ )
26
+
27
+ # Act
28
+ result = await voice_generate.request(session=session)
29
+
30
+ # Assert
31
+ session.get.assert_called_once()
32
+ assert isinstance(result, VoiceResponse)
33
+ assert result.audio == b'audio_content'
34
+
File without changes
File without changes