fix: clear 19 TTS/STT/etc. services from pyright ignore list

Several adjacent fix shapes that together drop 19 files from the
pyrightconfig.json ignore list (96 → 77) and full-pyright errors from
605 → 580. Default pyright stays clean.

TTS voice/context_id None handling — most files in this batch had a
single error of the shape "value typed `T | None` passed where `T` is
required" coming out of `assert_given(self._settings.voice)` (which
strips `_NotGiven` but not `None`) or `get_active_audio_context_id()`.
Two patterns:

- For services where a missing voice means the request can't proceed
  (hume, openai, xtts, groq, kokoro, piper), added an explicit None
  check. Inside `run_tts` we yield an `ErrorFrame` and return — matching
  each service's existing error-emission style (a few wrap `Exception`
  broadly and were fine; openai/hume/xtts had narrower or no try blocks
  so a bare `raise ValueError` would have escaped uncaught). Piper
  validates in `__init__`, where failing fast at construction is the
  right shape. OpenAI also gained a `voice not in VALID_VOICES` guard
  with a clear message listing supported voices.

- For services where a missing audio context just means "skip this
  message" (fish, lmnt, smallest, sarvam, neuphonic), widened
  `TTSService.append_to_audio_context`'s `context_id` signature to
  `str | None`. The function body already explicitly handled the None
  case with a debug log + early return, so the prior `str` annotation
  was a lie; making it honest cleared call sites without local guards.
  inworld's `_close_context` got the same treatment.

google.genai imports — switched `from google import genai` to
`import google.genai as genai` in google/image.py and google/llm.py.
The dotted form sidesteps a PEP 420 namespace-package stub gap (the
`google` namespace stubs come from a different distribution and don't
declare `genai`), which means pyright now resolves `genai` to the
real module rather than `Unknown`. IDE autocomplete on `genai.<x>`
works for the first time. In image.py this surfaced three latent
bugs that the `Unknown` resolution had been hiding (model was
`str | _NotGiven | None` not narrowed before passing to the SDK; two
spots accessed `.image_bytes` on an `Image | None` without a guard) —
all fixed. llm.py's dotted import surfaced 8 errors (Content-list
typing nuances, internal `_api_client` access, a few small Optionals);
deferred to a future pass since they're outside this commit's scope,
so the file stays in the ignore list with the dotted import.

Latent bug fixes spotted along the way:

- resembleai/tts.py was calling `push_error(ErrorFrame(...))`, but
  `push_error` takes a string — there's a separate `push_error_frame`
  for the frame case. Switched to the right method.
- openai/base_llm.py: `max_completion_tokens` was the only sibling
  field on `OpenAILLMSettings` missing `| None` in its type, which
  caused the assignment in openai/llm.py from `params.max_completion_tokens`
  (`int | None`) to fail. Added `| None` for consistency with
  `max_tokens` etc.
- heygen/base_api.py: `livekit_url: str = None` and `ws_url: str = None`
  declared `str` while defaulting to `None`. Removed the bogus
  defaults — both fields are required at construction in every
  in-tree call site, and the previous `str = None` was a Pydantic
  footgun.

Other small ones: gladia/stt.py needed a None guard on `_session_url`
before `websocket_connect`; openrouter/llm.py's
`build_chat_completion_params` override widened to `dict[str, Any]`
diverging from the parent's `OpenAILLMInvocationParams` — restored
the parent's type; neuphonic/tts.py guarded the receive loop's
`async for message in self._websocket` with a local-variable narrowing
matching the pattern from 9e9b1f39e.

groq/tts.py: tightened `output_format`'s typing to
`Literal["flac","mp3","mulaw","ogg","wav"] | str = "wav"`. The literal
side gives IDE autocomplete hints for the currently-supported set;
the `| str` side keeps callers unblocked if groq adds a new format
before this list is updated. A `cast` at the API boundary satisfies
groq's stricter `Literal` parameter type. The literal alias mirrors
the inlined Literal on `groq.resources.audio.speech.AsyncSpeech.create`'s
`response_format` (the SDK doesn't export it as a named symbol).

websocket_service.py: scoped `# pyright: ignore[reportAttributeAccessIssue]`
on `websockets.WebSocketClientProtocol`. That alias is now a deprecated
re-export from the legacy submodule and pyright doesn't surface it
on the top-level `websockets` namespace; runtime is fine. Migrating
to `websockets.ClientConnection` is a separate piece of work
(transports/websocket/client.py uses the same alias four times) and
left for a future commit.

Files dropped from the ignore list: fish/tts.py, gladia/stt.py,
google/image.py, groq/tts.py, heygen/base_api.py, hume/tts.py,
inworld/tts.py, kokoro/tts.py, lmnt/tts.py, neuphonic/tts.py,
openai/llm.py, openai/tts.py, openrouter/llm.py, piper/tts.py,
resembleai/tts.py, sarvam/tts.py, smallest/tts.py,
websocket_service.py, xtts/tts.py.
This commit is contained in:
Paul Kompfner
2026-04-29 10:56:04 -04:00
parent 96756bc1f6
commit 814f00ce41
18 changed files with 86 additions and 43 deletions

View File

@@ -37,58 +37,39 @@
"src/pipecat/services/deepgram/sagemaker/tts.py", "src/pipecat/services/deepgram/sagemaker/tts.py",
"src/pipecat/services/deepgram/tts.py", "src/pipecat/services/deepgram/tts.py",
"src/pipecat/services/elevenlabs/tts.py", "src/pipecat/services/elevenlabs/tts.py",
"src/pipecat/services/fish/tts.py",
"src/pipecat/services/gladia/stt.py",
"src/pipecat/services/google/gemini_live/llm.py", "src/pipecat/services/google/gemini_live/llm.py",
"src/pipecat/services/google/gemini_live/vertex/llm.py", "src/pipecat/services/google/gemini_live/vertex/llm.py",
"src/pipecat/services/google/image.py",
"src/pipecat/services/google/llm.py", "src/pipecat/services/google/llm.py",
"src/pipecat/services/google/stt.py", "src/pipecat/services/google/stt.py",
"src/pipecat/services/google/tts.py", "src/pipecat/services/google/tts.py",
"src/pipecat/services/gradium/stt.py", "src/pipecat/services/gradium/stt.py",
"src/pipecat/services/groq/tts.py",
"src/pipecat/services/heygen/api_interactive_avatar.py", "src/pipecat/services/heygen/api_interactive_avatar.py",
"src/pipecat/services/heygen/base_api.py",
"src/pipecat/services/heygen/client.py", "src/pipecat/services/heygen/client.py",
"src/pipecat/services/heygen/video.py", "src/pipecat/services/heygen/video.py",
"src/pipecat/services/hume/tts.py",
"src/pipecat/services/inworld/realtime/llm.py", "src/pipecat/services/inworld/realtime/llm.py",
"src/pipecat/services/inworld/tts.py",
"src/pipecat/services/kokoro/tts.py",
"src/pipecat/services/llm_service.py", "src/pipecat/services/llm_service.py",
"src/pipecat/services/lmnt/tts.py",
"src/pipecat/services/mem0/memory.py", "src/pipecat/services/mem0/memory.py",
"src/pipecat/services/mistral/stt.py", "src/pipecat/services/mistral/stt.py",
"src/pipecat/services/mistral/tts.py", "src/pipecat/services/mistral/tts.py",
"src/pipecat/services/moondream/vision.py", "src/pipecat/services/moondream/vision.py",
"src/pipecat/services/neuphonic/tts.py",
"src/pipecat/services/nvidia/stt.py", "src/pipecat/services/nvidia/stt.py",
"src/pipecat/services/nvidia/tts.py", "src/pipecat/services/nvidia/tts.py",
"src/pipecat/services/openai/base_llm.py", "src/pipecat/services/openai/base_llm.py",
"src/pipecat/services/openai/image.py", "src/pipecat/services/openai/image.py",
"src/pipecat/services/openai/llm.py",
"src/pipecat/services/openai/realtime/llm.py", "src/pipecat/services/openai/realtime/llm.py",
"src/pipecat/services/openai/responses/llm.py", "src/pipecat/services/openai/responses/llm.py",
"src/pipecat/services/openai/stt.py", "src/pipecat/services/openai/stt.py",
"src/pipecat/services/openai/tts.py",
"src/pipecat/services/openrouter/llm.py",
"src/pipecat/services/piper/tts.py",
"src/pipecat/services/resembleai/tts.py",
"src/pipecat/services/rime/tts.py", "src/pipecat/services/rime/tts.py",
"src/pipecat/services/sambanova/llm.py", "src/pipecat/services/sambanova/llm.py",
"src/pipecat/services/sarvam/stt.py", "src/pipecat/services/sarvam/stt.py",
"src/pipecat/services/sarvam/tts.py",
"src/pipecat/services/simli/video.py", "src/pipecat/services/simli/video.py",
"src/pipecat/services/smallest/tts.py",
"src/pipecat/services/speechmatics/stt.py", "src/pipecat/services/speechmatics/stt.py",
"src/pipecat/services/stt_service.py", "src/pipecat/services/stt_service.py",
"src/pipecat/services/tavus/video.py", "src/pipecat/services/tavus/video.py",
"src/pipecat/services/tts_service.py", "src/pipecat/services/tts_service.py",
"src/pipecat/services/ultravox/llm.py", "src/pipecat/services/ultravox/llm.py",
"src/pipecat/services/websocket_service.py",
"src/pipecat/services/whisper/stt.py", "src/pipecat/services/whisper/stt.py",
"src/pipecat/services/xai/realtime/llm.py", "src/pipecat/services/xai/realtime/llm.py",
"src/pipecat/services/xtts/tts.py",
"src/pipecat/transports/base_output.py", "src/pipecat/transports/base_output.py",
"src/pipecat/transports/daily/transport.py", "src/pipecat/transports/daily/transport.py",
"src/pipecat/transports/heygen/transport.py", "src/pipecat/transports/heygen/transport.py",

View File

@@ -542,6 +542,8 @@ class GladiaSTTService(WebsocketSTTService):
logger.debug(f"{self}Connecting to Gladia WebSocket") logger.debug(f"{self}Connecting to Gladia WebSocket")
if self._session_url is None:
raise RuntimeError(f"{self} session URL is not initialized")
self._websocket = await websocket_connect(self._session_url) self._websocket = await websocket_connect(self._session_url)
self._connection_active = True self._connection_active = True

View File

@@ -30,7 +30,7 @@ from pipecat.services.image_service import ImageGenService
from pipecat.services.settings import NOT_GIVEN, ImageGenSettings, _NotGiven, assert_given from pipecat.services.settings import NOT_GIVEN, ImageGenSettings, _NotGiven, assert_given
try: try:
from google import genai import google.genai as genai
from google.genai import types from google.genai import types
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
logger.error(f"Exception: {e}") logger.error(f"Exception: {e}")
@@ -153,8 +153,12 @@ class GoogleImageGenService(ImageGenService):
await self.start_ttfb_metrics() await self.start_ttfb_metrics()
try: try:
model = assert_given(self._settings.model)
if model is None:
yield ErrorFrame("Google image generation model must be specified")
return
response = await self._client.aio.models.generate_images( response = await self._client.aio.models.generate_images(
model=self._settings.model, model=model,
prompt=prompt, prompt=prompt,
config=types.GenerateImagesConfig( config=types.GenerateImagesConfig(
number_of_images=assert_given(self._settings.number_of_images), number_of_images=assert_given(self._settings.number_of_images),
@@ -169,8 +173,9 @@ class GoogleImageGenService(ImageGenService):
for img_response in response.generated_images: for img_response in response.generated_images:
# Google returns the image data directly # Google returns the image data directly
image_bytes = img_response.image.image_bytes if img_response.image is None or img_response.image.image_bytes is None:
image = Image.open(io.BytesIO(image_bytes)) continue
image = Image.open(io.BytesIO(img_response.image.image_bytes))
frame = URLImageRawFrame( frame = URLImageRawFrame(
url=None, # Google doesn't provide URLs, only image data url=None, # Google doesn't provide URLs, only image data

View File

@@ -52,7 +52,7 @@ from pipecat.utils.tracing.service_decorators import traced_llm
os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false" os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "false"
try: try:
from google import genai import google.genai as genai
from google.api_core.exceptions import DeadlineExceeded from google.api_core.exceptions import DeadlineExceeded
from google.genai.types import ( from google.genai.types import (
GenerateContentConfig, GenerateContentConfig,

View File

@@ -10,6 +10,7 @@ import io
import wave import wave
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal, cast
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
@@ -31,6 +32,19 @@ except ModuleNotFoundError as e:
logger.error("In order to use Groq, you need to `pip install pipecat-ai[groq]`.") logger.error("In order to use Groq, you need to `pip install pipecat-ai[groq]`.")
raise Exception(f"Missing module: {e}") raise Exception(f"Missing module: {e}")
# Hint set for `output_format`. The values mirror the Literal that
# `groq.resources.audio.speech.AsyncSpeech.create` accepts on its
# `response_format` parameter (also visible as the `response_format` field of
# `groq.types.audio.SpeechCreateParams`). The groq SDK does not export this as
# a named alias, so we redeclare it here.
#
# This alias is used in unions like `GroqAudioFormat | str`, so pyright shows
# these values as completion hints without rejecting other strings. If groq
# adds a new format before this list is updated, callers can still pass it and
# we forward it through (with a cast at the API boundary). Keep in sync on a
# best-effort basis when bumping the groq dep.
GroqAudioFormat = Literal["flac", "mp3", "mulaw", "ogg", "wav"]
@dataclass @dataclass
class GroqTTSSettings(TTSSettings): class GroqTTSSettings(TTSSettings):
@@ -74,7 +88,7 @@ class GroqTTSService(TTSService):
self, self,
*, *,
api_key: str, api_key: str,
output_format: str = "wav", output_format: GroqAudioFormat | str = "wav",
params: InputParams | None = None, params: InputParams | None = None,
model_name: str | None = None, model_name: str | None = None,
voice_id: str | None = None, voice_id: str | None = None,
@@ -147,7 +161,7 @@ class GroqTTSService(TTSService):
) )
self._api_key = api_key self._api_key = api_key
self._output_format = output_format self._output_format: str = output_format
self._client = AsyncGroq(api_key=self._api_key) self._client = AsyncGroq(api_key=self._api_key)
@@ -178,12 +192,18 @@ class GroqTTSService(TTSService):
speed = assert_given(self._settings.speed) speed = assert_given(self._settings.speed)
if model is None: if model is None:
raise ValueError("Groq TTS model must be specified") raise ValueError("Groq TTS model must be specified")
if voice is None:
raise ValueError("Groq TTS voice must be specified")
if speed is None: if speed is None:
raise ValueError("Groq TTS speed must be specified") raise ValueError("Groq TTS speed must be specified")
response = await self._client.audio.speech.create( response = await self._client.audio.speech.create(
model=model, model=model,
voice=voice, voice=voice,
response_format=self._output_format, # Cast satisfies groq's stricter Literal typing while letting
# callers pass any string (e.g. a newer groq format we haven't
# yet added to GroqAudioFormat). If the value is unsupported,
# groq's API will surface a runtime error with a clear message.
response_format=cast(GroqAudioFormat, self._output_format),
# Note: as of 2026-02-25, only a speed of 1.0 is supported, but # Note: as of 2026-02-25, only a speed of 1.0 is supported, but
# here we pass it for completeness and future-proofing # here we pass it for completeness and future-proofing
speed=speed, speed=speed,

View File

@@ -33,8 +33,8 @@ class StandardSessionResponse(BaseModel):
access_token: str access_token: str
livekit_agent_token: str livekit_agent_token: str
livekit_url: str = None livekit_url: str
ws_url: str = None ws_url: str
raw_response: Any raw_response: Any

View File

@@ -19,6 +19,7 @@ from pipecat import version as pipecat_version
from pipecat.frames.frames import ( from pipecat.frames.frames import (
CancelFrame, CancelFrame,
EndFrame, EndFrame,
ErrorFrame,
Frame, Frame,
InterruptionFrame, InterruptionFrame,
StartFrame, StartFrame,
@@ -289,10 +290,14 @@ class HumeTTSService(TTSService):
""" """
logger.debug(f"{self}: Generating Hume TTS: [{text}]") logger.debug(f"{self}: Generating Hume TTS: [{text}]")
voice_id = assert_given(self._settings.voice)
if voice_id is None:
yield ErrorFrame(error="Hume TTS voice must be specified")
return
# Build the request payload # Build the request payload
utterance_kwargs: dict[str, Any] = { utterance_kwargs: dict[str, Any] = {
"text": text, "text": text,
"voice": PostedUtteranceVoiceWithId(id=assert_given(self._settings.voice)), "voice": PostedUtteranceVoiceWithId(id=voice_id),
} }
if self._settings.description is not None: if self._settings.description is not None:
utterance_kwargs["description"] = self._settings.description utterance_kwargs["description"] = self._settings.description

View File

@@ -794,8 +794,10 @@ class InworldTTSService(WebsocketTTSService):
return word_times return word_times
async def _close_context(self, context_id: str): async def _close_context(self, context_id: str | None):
if context_id and self._websocket: if not context_id:
return
if self._websocket:
logger.info(f"{self}: Closing context {context_id} due to interruption or completion") logger.info(f"{self}: Closing context {context_id} due to interruption or completion")
try: try:
await self._send_close_context(context_id) await self._send_close_context(context_id)

View File

@@ -216,6 +216,8 @@ class KokoroTTSService(TTSService):
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
voice = assert_given(self._settings.voice) voice = assert_given(self._settings.voice)
if voice is None:
raise ValueError("Kokoro TTS voice must be specified")
lang = assert_given(self._settings.language) lang = assert_given(self._settings.language)
if lang is None: if lang is None:
raise ValueError("Kokoro TTS language must be specified") raise ValueError("Kokoro TTS language must be specified")

View File

@@ -331,7 +331,10 @@ class NeuphonicTTSService(InterruptibleTTSService):
async def _receive_messages(self): async def _receive_messages(self):
"""Receive and process messages from Neuphonic WebSocket.""" """Receive and process messages from Neuphonic WebSocket."""
async for message in self._websocket: websocket = self._websocket
if websocket is None:
return
async for message in websocket:
if isinstance(message, str): if isinstance(message, str):
msg = json.loads(message) msg = json.loads(message)
if msg.get("data") and msg["data"].get("audio"): if msg.get("data") and msg["data"].get("audio"):

View File

@@ -66,7 +66,7 @@ class OpenAILLMSettings(LLMSettings):
) )
top_p: float | None | _NotGiven | OpenAINotGiven = field(default_factory=lambda: _NOT_GIVEN) top_p: float | None | _NotGiven | OpenAINotGiven = field(default_factory=lambda: _NOT_GIVEN)
max_tokens: int | None | _NotGiven | OpenAINotGiven = field(default_factory=lambda: _NOT_GIVEN) max_tokens: int | None | _NotGiven | OpenAINotGiven = field(default_factory=lambda: _NOT_GIVEN)
max_completion_tokens: int | _NotGiven | OpenAINotGiven = field( max_completion_tokens: int | None | _NotGiven | OpenAINotGiven = field(
default_factory=lambda: _NOT_GIVEN default_factory=lambda: _NOT_GIVEN
) )

View File

@@ -235,12 +235,22 @@ class OpenAITTSService(TTSService):
Frame: Audio frames containing the synthesized speech data. Frame: Audio frames containing the synthesized speech data.
""" """
logger.debug(f"{self}: Generating TTS [{text}]") logger.debug(f"{self}: Generating TTS [{text}]")
voice = assert_given(self._settings.voice)
if voice is None:
yield ErrorFrame(error="OpenAI TTS voice must be specified")
return
if voice not in VALID_VOICES:
yield ErrorFrame(
error=f"OpenAI TTS voice {voice!r} is not supported "
f"(must be one of: {', '.join(sorted(VALID_VOICES))})"
)
return
try: try:
# Setup API parameters # Setup API parameters
create_params = { create_params = {
"input": text, "input": text,
"model": self._settings.model, "model": self._settings.model,
"voice": VALID_VOICES[assert_given(self._settings.voice)], "voice": VALID_VOICES[voice],
"response_format": "pcm", "response_format": "pcm",
} }

View File

@@ -15,6 +15,7 @@ from typing import Any
from loguru import logger from loguru import logger
from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams
from pipecat.services.openai.base_llm import BaseOpenAILLMService from pipecat.services.openai.base_llm import BaseOpenAILLMService
from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.settings import assert_given from pipecat.services.settings import assert_given
@@ -96,7 +97,9 @@ class OpenRouterLLMService(OpenAILLMService):
logger.debug(f"Creating OpenRouter client with api {base_url}") logger.debug(f"Creating OpenRouter client with api {base_url}")
return super().create_client(api_key, base_url, **kwargs) return super().create_client(api_key, base_url, **kwargs)
def build_chat_completion_params(self, params_from_context: dict[str, Any]) -> dict[str, Any]: def build_chat_completion_params(
self, params_from_context: OpenAILLMInvocationParams
) -> dict[str, Any]:
"""Builds chat parameters, handling model-specific constraints. """Builds chat parameters, handling model-specific constraints.
Args: Args:

View File

@@ -101,6 +101,8 @@ class PiperTTSService(TTSService):
download_dir = download_dir or Path.cwd() download_dir = download_dir or Path.cwd()
_voice = assert_given(self._settings.voice) _voice = assert_given(self._settings.voice)
if _voice is None:
raise ValueError("Piper TTS voice must be specified")
model_file = f"{_voice}.onnx" model_file = f"{_voice}.onnx"
model_path_resolved = Path(download_dir) / model_file model_path_resolved = Path(download_dir) / model_file

View File

@@ -409,7 +409,9 @@ class ResembleAITTSService(WebsocketTTSService):
await self.push_frame(TTSStoppedFrame(context_id=context_id)) await self.push_frame(TTSStoppedFrame(context_id=context_id))
await self.stop_all_metrics() await self.stop_all_metrics()
await self.push_error(ErrorFrame(error=f"{self} error: {error_name} - {error_msg}")) await self.push_error_frame(
ErrorFrame(error=f"{self} error: {error_name} - {error_msg}")
)
# Check if this is an unrecoverable error (connection-level failure) # Check if this is an unrecoverable error (connection-level failure)
if status_code in [401, 403]: if status_code in [401, 403]:

View File

@@ -1174,16 +1174,18 @@ class TTSService(AIService):
logger.trace(f"{self} created audio context {context_id}") logger.trace(f"{self} created audio context {context_id}")
async def append_to_audio_context( async def append_to_audio_context(
self, context_id: str, frame: Frame | _WordTimestampEntry | None self, context_id: str | None, frame: Frame | _WordTimestampEntry | None
): ):
"""Append a frame or word-timestamp entry to an existing audio context queue. """Append a frame or word-timestamp entry to an existing audio context queue.
Passing ``None`` signals end-of-context (used by remove_audio_context to mark Passing a ``frame`` of ``None`` signals end-of-context (used by remove_audio_context
the queue for deletion). If the context no longer exists but the context_id to mark the queue for deletion). If the context no longer exists but the context_id
matches the active turn, the context is transparently recreated before appending. matches the active turn, the context is transparently recreated before appending.
Args: Args:
context_id: The context to append to. context_id: The context to append to. ``None`` is accepted as a no-op
(with a debug log) so callers can pass through values from
``get_active_audio_context_id()`` without an explicit guard.
frame: The frame, word-timestamp entry, or ``None`` (end-of-context sentinel) frame: The frame, word-timestamp entry, or ``None`` (end-of-context sentinel)
to append. to append.
""" """

View File

@@ -42,7 +42,7 @@ class WebsocketService(ABC):
reconnect_on_error: Whether to automatically reconnect on connection errors. reconnect_on_error: Whether to automatically reconnect on connection errors.
**kwargs: Additional arguments (unused, for compatibility). **kwargs: Additional arguments (unused, for compatibility).
""" """
self._websocket: websockets.WebSocketClientProtocol | None = None self._websocket: websockets.WebSocketClientProtocol | None = None # pyright: ignore[reportAttributeAccessIssue]
self._reconnect_on_error = reconnect_on_error self._reconnect_on_error = reconnect_on_error
self._reconnect_in_progress: bool = False self._reconnect_in_progress: bool = False
self._disconnecting: bool = False self._disconnecting: bool = False

View File

@@ -214,7 +214,11 @@ class XTTSService(TTSService):
logger.error(f"{self} no studio speakers available") logger.error(f"{self} no studio speakers available")
return return
embeddings = self._studio_speakers[assert_given(self._settings.voice)] voice = assert_given(self._settings.voice)
if voice is None:
yield ErrorFrame(error="XTTS voice must be specified")
return
embeddings = self._studio_speakers[voice]
url = self._base_url + "/tts_stream" url = self._base_url + "/tts_stream"