Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions astrbot/core/provider/sources/typecast_tts_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import json
import os
import uuid

from httpx import AsyncClient

from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path

from ..entities import ProviderType
from ..provider import TTSProvider
from ..register import register_provider_adapter


def _safe_cast(value, type_func, default):
try:
return type_func(value)
except (TypeError, ValueError):
return default


@register_provider_adapter(
"typecast_tts",
"Typecast TTS",
provider_type=ProviderType.TEXT_TO_SPEECH,
)
class ProviderTypecastTTS(TTSProvider):
API_URL = "https://api.typecast.ai/v1/text-to-speech"

def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)

self.api_key: str = provider_config.get("api_key", "")
if not self.api_key:
raise ValueError("[Typecast TTS] api_key is required")
self.voice_id: str = provider_config.get("typecast-voice-id", "")
if not self.voice_id:
raise ValueError("[Typecast TTS] typecast-voice-id is required")
self.language: str = provider_config.get("language", "kor")
VALID_EMOTION_PRESETS = {
"normal", "happy", "sad", "angry", "whisper", "toneup", "tonedown",
}
self.emotion_preset: str = provider_config.get(
"typecast-emotion-preset", "normal"
)
if self.emotion_preset not in VALID_EMOTION_PRESETS:
logger.warning(
f"[Typecast TTS] Unknown emotion preset '{self.emotion_preset}', "
f"falling back to 'normal'. Valid values: {sorted(VALID_EMOTION_PRESETS)}"
)
self.emotion_preset = "normal"
self.emotion_intensity: float = _safe_cast(
provider_config.get("typecast-emotion-intensity", 1.0), float, 1.0
)
self.volume: int = _safe_cast(
provider_config.get("typecast-volume", 100), int, 100
)
self.pitch: int = _safe_cast(
provider_config.get("typecast-pitch", 0), int, 0
)
self.tempo: float = _safe_cast(
provider_config.get("typecast-tempo", 1.0), float, 1.0
)
self.timeout: int = _safe_cast(
provider_config.get("timeout", 30), int, 30
)
self.proxy: str = provider_config.get("proxy", "")

if self.proxy:
logger.info(f"[Typecast TTS] Using proxy: {self.proxy}")

self.set_model(provider_config.get("model", "ssfm-v30"))

def _build_request_body(self, text: str) -> dict:
return {
"voice_id": self.voice_id,
"text": text,
"model": self.model_name,
"language": self.language,
"prompt": {
"emotion_type": "preset",
"emotion_preset": self.emotion_preset,
"emotion_intensity": self.emotion_intensity,
},
"output": {
"volume": self.volume,
"audio_pitch": self.pitch,
"audio_tempo": self.tempo,
"audio_format": "wav",
},
}

async def get_audio(self, text: str) -> str:
if not text or not text.strip():
raise ValueError("[Typecast TTS] text must not be empty")
if len(text) > 2000:
raise ValueError(
f"[Typecast TTS] text length {len(text)} exceeds maximum of 2000 characters"
)

temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
path = os.path.join(temp_dir, f"typecast_tts_{uuid.uuid4()}.wav")
Comment on lines +105 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It is safer to ensure that the temporary directory exists before attempting to create a file within it, as get_astrbot_temp_path() might return a path that hasn't been created yet.

Suggested change
temp_dir = get_astrbot_temp_path()
path = os.path.join(temp_dir, f"typecast_tts_{uuid.uuid4()}.wav")
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
path = os.path.join(temp_dir, f"typecast_tts_{uuid.uuid4()}.wav")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added os.makedirs(temp_dir, exist_ok=True).


headers = {
"Content-Type": "application/json",
"X-API-KEY": self.api_key,
}
body = self._build_request_body(text)

async with AsyncClient(
timeout=self.timeout,
proxy=self.proxy if self.proxy else None,
) as client, client.stream(
"POST",
self.API_URL,
headers=headers,
json=body,
) as response:
Comment on lines +115 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The AsyncClient is instantiated but never closed, which will lead to resource leaks (unclosed connections). In httpx, the client should be used as a context manager or closed explicitly. Using the async with ... as client, client.stream(...) syntax fixes this while preserving the current indentation.

Suggested change
async with AsyncClient(
timeout=self.timeout,
proxy=self.proxy if self.proxy else None,
).stream(
"POST",
self.API_URL,
headers=headers,
json=body,
) as response:
async with AsyncClient(
timeout=self.timeout,
proxy=self.proxy if self.proxy else None,
) as client, client.stream(
"POST",
self.API_URL,
headers=headers,
json=body,
) as response:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now using async with AsyncClient(...) as client, client.stream(...) as response pattern.

if response.status_code == 200 and response.headers.get(
"content-type", ""
).lower().startswith("audio/"):
with open(path, "wb") as f:
async for chunk in response.aiter_bytes():
f.write(chunk)
Comment on lines +127 to +129
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Synchronous file I/O operations like open and f.write block the asyncio event loop. This can degrade performance and responsiveness, especially under load. Consider using an asynchronous file library or asyncio.to_thread for these operations.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted — this follows the same pattern as the existing TTS providers (e.g. fishaudio_tts_api_source.py, edge_tts_source.py). Will keep consistent for now.

return path

error_bytes = await response.aread()
error_text = error_bytes.decode("utf-8", errors="replace")[:1024]
try:
error_detail = json.loads(error_text).get("detail", error_text)
except (json.JSONDecodeError, AttributeError):
error_detail = error_text
raise RuntimeError(
f"Typecast API request failed: status {response.status_code}, "
f"response: {error_detail}"
)
Loading