Skip to content

Commit 3f023be

Browse files
jaebong-humanclaude
andcommitted
feat: add Typecast TTS provider
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e34d950 commit 3f023be

File tree

2 files changed

+440
-0
lines changed

2 files changed

+440
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import json
2+
import os
3+
import uuid
4+
5+
from httpx import AsyncClient
6+
7+
from astrbot import logger
8+
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
9+
10+
from ..entities import ProviderType
11+
from ..provider import TTSProvider
12+
from ..register import register_provider_adapter
13+
14+
15+
def _safe_cast(value, type_func, default):
16+
try:
17+
return type_func(value)
18+
except (TypeError, ValueError):
19+
return default
20+
21+
22+
@register_provider_adapter(
23+
"typecast_tts",
24+
"Typecast TTS",
25+
provider_type=ProviderType.TEXT_TO_SPEECH,
26+
)
27+
class ProviderTypecastTTS(TTSProvider):
28+
API_URL = "https://api.typecast.ai/v1/text-to-speech"
29+
30+
def __init__(
31+
self,
32+
provider_config: dict,
33+
provider_settings: dict,
34+
) -> None:
35+
super().__init__(provider_config, provider_settings)
36+
37+
self.api_key: str = provider_config.get("api_key", "")
38+
if not self.api_key:
39+
raise ValueError("[Typecast TTS] api_key is required")
40+
self.voice_id: str = provider_config.get("typecast-voice-id", "")
41+
if not self.voice_id:
42+
raise ValueError("[Typecast TTS] typecast-voice-id is required")
43+
self.language: str = provider_config.get("language", "kor")
44+
VALID_EMOTION_PRESETS = {
45+
"normal", "happy", "sad", "angry", "whisper", "toneup", "tonedown",
46+
}
47+
self.emotion_preset: str = provider_config.get(
48+
"typecast-emotion-preset", "normal"
49+
)
50+
if self.emotion_preset not in VALID_EMOTION_PRESETS:
51+
logger.warning(
52+
f"[Typecast TTS] Unknown emotion preset '{self.emotion_preset}', "
53+
f"falling back to 'normal'. Valid values: {sorted(VALID_EMOTION_PRESETS)}"
54+
)
55+
self.emotion_preset = "normal"
56+
self.emotion_intensity: float = _safe_cast(
57+
provider_config.get("typecast-emotion-intensity", 1.0), float, 1.0
58+
)
59+
self.volume: int = _safe_cast(
60+
provider_config.get("typecast-volume", 100), int, 100
61+
)
62+
self.pitch: int = _safe_cast(
63+
provider_config.get("typecast-pitch", 0), int, 0
64+
)
65+
self.tempo: float = _safe_cast(
66+
provider_config.get("typecast-tempo", 1.0), float, 1.0
67+
)
68+
self.timeout: int = _safe_cast(
69+
provider_config.get("timeout", 30), int, 30
70+
)
71+
self.proxy: str = provider_config.get("proxy", "")
72+
73+
if self.proxy:
74+
logger.info(f"[Typecast TTS] Using proxy: {self.proxy}")
75+
76+
self.set_model(provider_config.get("model", "ssfm-v30"))
77+
78+
def _build_request_body(self, text: str) -> dict:
79+
return {
80+
"voice_id": self.voice_id,
81+
"text": text,
82+
"model": self.model_name,
83+
"language": self.language,
84+
"prompt": {
85+
"emotion_type": "preset",
86+
"emotion_preset": self.emotion_preset,
87+
"emotion_intensity": self.emotion_intensity,
88+
},
89+
"output": {
90+
"volume": self.volume,
91+
"audio_pitch": self.pitch,
92+
"audio_tempo": self.tempo,
93+
"audio_format": "wav",
94+
},
95+
}
96+
97+
async def get_audio(self, text: str) -> str:
98+
if not text or not text.strip():
99+
raise ValueError("[Typecast TTS] text must not be empty")
100+
if len(text) > 2000:
101+
raise ValueError(
102+
f"[Typecast TTS] text length {len(text)} exceeds maximum of 2000 characters"
103+
)
104+
105+
temp_dir = get_astrbot_temp_path()
106+
os.makedirs(temp_dir, exist_ok=True)
107+
path = os.path.join(temp_dir, f"typecast_tts_{uuid.uuid4()}.wav")
108+
109+
headers = {
110+
"Content-Type": "application/json",
111+
"X-API-KEY": self.api_key,
112+
}
113+
body = self._build_request_body(text)
114+
115+
async with AsyncClient(
116+
timeout=self.timeout,
117+
proxy=self.proxy if self.proxy else None,
118+
) as client, client.stream(
119+
"POST",
120+
self.API_URL,
121+
headers=headers,
122+
json=body,
123+
) as response:
124+
if response.status_code == 200 and response.headers.get(
125+
"content-type", ""
126+
).lower().startswith("audio/"):
127+
with open(path, "wb") as f:
128+
async for chunk in response.aiter_bytes():
129+
f.write(chunk)
130+
return path
131+
132+
error_bytes = await response.aread()
133+
error_text = error_bytes.decode("utf-8", errors="replace")[:1024]
134+
try:
135+
error_detail = json.loads(error_text).get("detail", error_text)
136+
except (json.JSONDecodeError, AttributeError):
137+
error_detail = error_text
138+
raise RuntimeError(
139+
f"Typecast API request failed: status {response.status_code}, "
140+
f"response: {error_detail}"
141+
)

0 commit comments

Comments
 (0)