diff --git a/pyproject.toml b/pyproject.toml index a8ad725b..b3ceedf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ evals = [ ] server = [ "fastapi>=0.115.8", + "ormsgpack>=1.5.0", "uvicorn>=0.34.0", "websockets>=15.0.1", ] diff --git a/python/tests/voice/test_fish_audio.py b/python/tests/voice/test_fish_audio.py new file mode 100644 index 00000000..5530591c --- /dev/null +++ b/python/tests/voice/test_fish_audio.py @@ -0,0 +1,230 @@ +from collections import deque +from unittest.mock import AsyncMock, patch + +import ormsgpack +import pytest +from pydantic import SecretStr +from timbal.voice.config import DEFAULT_VOICE_ID +from timbal.voice.fish_audio import ( + DEFAULT_TTS_MODEL, + FishAudioStreamTTS, + _resolve_api_key, + build_start_request, + effective_reference_id, + effective_tts_model, +) +from timbal.voice.providers import AudioOutputConfig + + +def _cfg(**kwargs) -> AudioOutputConfig: + return AudioOutputConfig(**kwargs) + + +def test_resolve_api_key_explicit_and_secret(): + assert _resolve_api_key("plain") == "plain" + assert _resolve_api_key(SecretStr("secret")) == "secret" + + +def test_resolve_api_key_env(monkeypatch): + monkeypatch.setenv("FISH_API_KEY", "env-key") + assert _resolve_api_key(None) == "env-key" + + +def test_resolve_api_key_missing(monkeypatch): + monkeypatch.delenv("FISH_API_KEY", raising=False) + with pytest.raises(ValueError, match="FISH_API_KEY"): + _resolve_api_key(None) + + +def test_effective_tts_model_swaps_foreign_ids(): + assert effective_tts_model(_cfg(model=None)) == DEFAULT_TTS_MODEL + assert effective_tts_model(_cfg(model="eleven_flash_v2_5")) == DEFAULT_TTS_MODEL + assert effective_tts_model(_cfg(model="faseeh-v1-preview")) == DEFAULT_TTS_MODEL + assert effective_tts_model(_cfg(model="s1")) == "s1" + assert effective_tts_model(_cfg(model="s2.1-pro-free")) == "s2.1-pro-free" + + +def test_effective_reference_id(monkeypatch): + monkeypatch.delenv("FISH_VOICE_ID", raising=False) + # Foreign/empty voice → None → Fish Audio's platform default voice. + assert effective_reference_id(_cfg(voice=None)) is None + assert effective_reference_id(_cfg(voice=DEFAULT_VOICE_ID)) is None + assert effective_reference_id(_cfg(voice="9a9cf47702da476aa4629e2506d4a857")) == "9a9cf47702da476aa4629e2506d4a857" + monkeypatch.setenv("FISH_VOICE_ID", "env-voice") + assert effective_reference_id(_cfg(voice=None)) == "env-voice" + + +def test_build_start_request(monkeypatch): + monkeypatch.delenv("FISH_VOICE_ID", raising=False) + req = build_start_request( + _cfg(voice="voice-1", sample_rate=16000, extra={"temperature": 0.6, "speed": 1.1}), + ) + assert req == { + "text": "", + "format": "pcm", + "sample_rate": 16000, + "latency": "balanced", + "prosody": {"speed": 1.1, "volume": 0.0}, + "condition_on_previous_chunks": True, + "reference_id": "voice-1", + "temperature": 0.6, + } + # Overridable via tts_extra. + off = build_start_request(_cfg(voice=None, extra={"condition_on_previous_chunks": False})) + assert off["condition_on_previous_chunks"] is False + # No reference_id key at all when the platform default voice is used. + assert "reference_id" not in build_start_request(_cfg(voice=None, sample_rate=16000)) + + +class FakeWS: + """Scripted Fish Audio live socket: iteration serves msgpack frames.""" + + def __init__(self, frames: list[dict]) -> None: + self._frames = deque(ormsgpack.packb(f) for f in frames) + self.sent: list[dict] = [] + self.closed = False + + async def send(self, raw: bytes) -> None: + self.sent.append(ormsgpack.unpackb(raw)) + + def __aiter__(self): + return self + + async def __anext__(self) -> bytes: + if not self._frames: + raise StopAsyncIteration + return self._frames.popleft() + + async def close(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_stream_protocol_roundtrip(monkeypatch): + monkeypatch.setenv("FISH_API_KEY", "test-key") + monkeypatch.delenv("FISH_VOICE_ID", raising=False) + pcm = b"\x01\x02" * 8 + fake = FakeWS( + [ + {"event": "audio", "audio": pcm}, + {"event": "audio", "audio": pcm}, + {"event": "finish", "reason": "stop"}, + ] + ) + + tts = FishAudioStreamTTS() + await tts.connect(_cfg(model="eleven_flash_v2_5", voice=DEFAULT_VOICE_ID, sample_rate=16000)) + + with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)) as connect_mock: + stream = tts.open_stream() + await stream.feed("Hello, ") + await stream.feed("world.") + await stream.end() + chunks = [chunk async for chunk in stream.audio()] + + assert chunks == [pcm, pcm] + assert fake.closed + + uri = connect_mock.await_args.args[0] + assert uri == "wss://api.fish.audio/v1/tts/live" + headers = connect_mock.await_args.kwargs["additional_headers"] + assert headers["Authorization"] == "Bearer test-key" + assert headers["model"] == DEFAULT_TTS_MODEL + + start, text1, text2, flush, stop = fake.sent + assert start["event"] == "start" + assert start["request"]["format"] == "pcm" + assert start["request"]["sample_rate"] == 16000 + assert "reference_id" not in start["request"] + assert text1 == {"event": "text", "text": "Hello, "} + assert text2 == {"event": "text", "text": "world."} + assert flush == {"event": "flush"} + assert stop == {"event": "stop"} + + +@pytest.mark.asyncio +async def test_finish_error_raises(monkeypatch): + monkeypatch.setenv("FISH_API_KEY", "test-key") + fake = FakeWS([{"event": "finish", "reason": "error"}]) + + tts = FishAudioStreamTTS() + await tts.connect(_cfg(voice="voice-1")) + + with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)): + stream = tts.open_stream() + await stream.feed("Hello") + await stream.end() + with pytest.raises(RuntimeError, match="reason=error"): + async for _ in stream.audio(): + pass + + +@pytest.mark.asyncio +async def test_synthesize_wraps_stream(monkeypatch): + monkeypatch.setenv("FISH_API_KEY", "test-key") + pcm = b"\x00\x01" * 4 + fake = FakeWS( + [ + {"event": "audio", "audio": pcm}, + {"event": "finish", "reason": "stop"}, + ] + ) + + tts = FishAudioStreamTTS() + await tts.connect(_cfg(voice="voice-1")) + + with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)): + chunks = [chunk async for chunk in tts.synthesize("Hello")] + + assert chunks == [pcm] + + +@pytest.mark.asyncio +async def test_http_402_rejection_gives_actionable_error(monkeypatch): + from websockets.datastructures import Headers + from websockets.exceptions import InvalidStatus + from websockets.http11 import Response + + monkeypatch.setenv("FISH_API_KEY", "test-key") + rejection = InvalidStatus(Response(402, "Payment Required", Headers())) + + tts = FishAudioStreamTTS() + await tts.connect(_cfg(voice="voice-1")) + + with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(side_effect=rejection)): + stream = tts.open_stream() + with pytest.raises(RuntimeError, match="HTTP 402.*s2.1-pro-free"): + await stream.feed("Hello") + + +@pytest.mark.asyncio +async def test_abort_unblocks_audio(monkeypatch): + monkeypatch.setenv("FISH_API_KEY", "test-key") + tts = FishAudioStreamTTS() + await tts.connect(_cfg(voice="voice-1")) + + stream = tts.open_stream() + await stream.abort() + chunks = [chunk async for chunk in stream.audio()] + assert chunks == [] + + +@pytest.mark.asyncio +async def test_unknown_events_ignored(monkeypatch): + monkeypatch.setenv("FISH_API_KEY", "test-key") + pcm = b"\x07\x08" + fake = FakeWS( + [ + {"event": "log", "message": "future extension"}, + {"event": "audio", "audio": pcm}, + {"event": "finish", "reason": "stop"}, + ] + ) + + tts = FishAudioStreamTTS() + await tts.connect(_cfg(voice="voice-1")) + + with patch("timbal.voice.fish_audio.ws_connect", AsyncMock(return_value=fake)): + chunks = [chunk async for chunk in tts.synthesize("Hi")] + + assert chunks == [pcm] diff --git a/python/tests/voice/test_munsit.py b/python/tests/voice/test_munsit.py new file mode 100644 index 00000000..dd40f7ee --- /dev/null +++ b/python/tests/voice/test_munsit.py @@ -0,0 +1,182 @@ +from contextlib import asynccontextmanager + +import pytest +from pydantic import SecretStr +from timbal.voice.config import DEFAULT_VOICE_ID +from timbal.voice.munsit import ( + DEFAULT_MUNSIT_VOICE_ID, + DEFAULT_TTS_MODEL, + MunsitStreamTTS, + _resolve_api_key, + effective_sample_rate, + effective_tts_model, + effective_voice_id, +) +from timbal.voice.providers import AudioOutputConfig + + +def _cfg(**kwargs) -> AudioOutputConfig: + return AudioOutputConfig(**kwargs) + + +def test_resolve_api_key_explicit_and_secret(): + assert _resolve_api_key("plain") == "plain" + assert _resolve_api_key(SecretStr("secret")) == "secret" + + +def test_resolve_api_key_env(monkeypatch): + monkeypatch.setenv("MUNSIT_API_KEY", "env-key") + assert _resolve_api_key(None) == "env-key" + + +def test_resolve_api_key_missing(monkeypatch): + monkeypatch.delenv("MUNSIT_API_KEY", raising=False) + with pytest.raises(ValueError, match="MUNSIT_API_KEY"): + _resolve_api_key(None) + + +def test_effective_sample_rate(): + assert effective_sample_rate(_cfg(sample_rate=16000)) == 16000 + assert effective_sample_rate(_cfg(sample_rate=48000)) == 48000 + # Out of range raises — a silent remap would desync session playback, + # which is clocked at config.sample_rate. + with pytest.raises(ValueError, match="8000-48000"): + effective_sample_rate(_cfg(sample_rate=96000)) + + +@pytest.mark.asyncio +async def test_connect_rejects_unsupported_sample_rate(monkeypatch): + monkeypatch.setenv("MUNSIT_API_KEY", "test-key") + tts = MunsitStreamTTS() + with pytest.raises(ValueError, match="8000-48000"): + await tts.connect(_cfg(sample_rate=96000)) + + +def test_effective_tts_model_swaps_foreign_ids(): + assert effective_tts_model(_cfg(model=None)) == DEFAULT_TTS_MODEL + assert effective_tts_model(_cfg(model="eleven_flash_v2_5")) == DEFAULT_TTS_MODEL + assert effective_tts_model(_cfg(model="faseeh-v1-preview")) == "faseeh-v1-preview" + + +def test_effective_voice_id_swaps_elevenlabs_default(monkeypatch): + monkeypatch.delenv("MUNSIT_VOICE_ID", raising=False) + assert effective_voice_id(_cfg(voice=None)) == DEFAULT_MUNSIT_VOICE_ID + assert effective_voice_id(_cfg(voice=DEFAULT_VOICE_ID)) == DEFAULT_MUNSIT_VOICE_ID + assert effective_voice_id(_cfg(voice="ar-hijazi-female-1")) == "ar-hijazi-female-1" + monkeypatch.setenv("MUNSIT_VOICE_ID", "cloned-voice") + assert effective_voice_id(_cfg(voice=None)) == "cloned-voice" + + +class FakeResponse: + def __init__(self, status_code: int, chunks: list[bytes] | None = None, body: bytes = b"") -> None: + self.status_code = status_code + self._chunks = chunks or [] + self._body = body + + async def aiter_bytes(self): + for chunk in self._chunks: + yield chunk + + async def aread(self) -> bytes: + return self._body + + +class FakeClient: + def __init__(self, response: FakeResponse) -> None: + self._response = response + self.calls: list[dict] = [] + self.closed = False + + @asynccontextmanager + async def stream(self, method, url, *, headers=None, json=None): + self.calls.append({"method": method, "url": url, "headers": headers, "json": json}) + yield self._response + + async def aclose(self) -> None: + self.closed = True + + +async def _connected(response: FakeResponse, config: AudioOutputConfig, monkeypatch) -> tuple[MunsitStreamTTS, FakeClient]: + monkeypatch.setenv("MUNSIT_API_KEY", "test-key") + tts = MunsitStreamTTS() + await tts.connect(config) + fake = FakeClient(response) + await tts._client.aclose() + tts._client = fake + return tts, fake + + +@pytest.mark.asyncio +async def test_synthesize_builds_request_and_streams(monkeypatch): + monkeypatch.delenv("MUNSIT_VOICE_ID", raising=False) + pcm = [b"\x01\x02" * 8, b"\x03\x04" * 8] + tts, fake = await _connected( + FakeResponse(200, chunks=pcm), + _cfg(model="eleven_flash_v2_5", voice=DEFAULT_VOICE_ID, sample_rate=16000, extra={"dialect": "fusha"}), + monkeypatch, + ) + + chunks = [chunk async for chunk in tts.synthesize("مرحبا بك ")] + assert chunks == pcm + + (call,) = fake.calls + assert call["method"] == "POST" + assert call["url"] == "https://api.munsit.com/api/v1/text-to-speech/faseeh-v1-preview" + assert call["headers"] == {"x-api-key": "test-key"} + assert call["json"] == { + "voice_id": DEFAULT_MUNSIT_VOICE_ID, + "text": "مرحبا بك", + "stability": 0.5, + "speed": 1.0, + "streaming": True, + "sample_rate": 16000, + "dialect": "fusha", + } + + +@pytest.mark.asyncio +async def test_synthesize_error_status_raises(monkeypatch): + tts, _ = await _connected( + FakeResponse(402, body=b'{"errorCode":40201,"errorMessage":"Insufficient wallet balance"}'), + _cfg(voice="ar-najdi-male-2"), + monkeypatch, + ) + with pytest.raises(RuntimeError, match="402"): + async for _ in tts.synthesize("مرحبا"): + pass + + +@pytest.mark.asyncio +async def test_synthesize_empty_text_is_noop(monkeypatch): + tts, fake = await _connected(FakeResponse(200), _cfg(voice="ar-najdi-male-2"), monkeypatch) + chunks = [chunk async for chunk in tts.synthesize(" ")] + assert chunks == [] + assert fake.calls == [] + + +@pytest.mark.asyncio +async def test_synthesize_requires_connect(): + tts = MunsitStreamTTS(api_key="k") + with pytest.raises(RuntimeError, match="connect"): + async for _ in tts.synthesize("مرحبا"): + pass + + +@pytest.mark.asyncio +async def test_no_open_stream_capability(monkeypatch): + tts, _ = await _connected(FakeResponse(200), _cfg(voice="ar-najdi-male-2"), monkeypatch) + assert tts.open_stream() is None + + +@pytest.mark.asyncio +async def test_close_shuts_client(monkeypatch): + tts, fake = await _connected(FakeResponse(200), _cfg(voice="ar-najdi-male-2"), monkeypatch) + await tts.close() + assert fake.closed + assert tts._client is None + + +def test_default_voice_settings_are_overridable(): + # tts_extra flows into AudioOutputConfig.extra; stability/speed ride there. + cfg = _cfg(voice="ar-najdi-male-2", extra={"stability": 0.8, "speed": 1.1}) + assert cfg.extra["stability"] == 0.8 diff --git a/python/tests/voice/test_providers.py b/python/tests/voice/test_providers.py new file mode 100644 index 00000000..07d896fb --- /dev/null +++ b/python/tests/voice/test_providers.py @@ -0,0 +1,102 @@ +import pytest +from timbal import Agent +from timbal.core.test_model import TestModel +from timbal.server.voice import build_voice_session +from timbal.voice.config import DEFAULT_VOICE_ID, VoiceConfig +from timbal.voice.elevenlabs import ( + _DEFAULT_TTS_MODEL as ELEVENLABS_DEFAULT_TTS_MODEL, +) +from timbal.voice.elevenlabs import ( + ElevenLabsStreamTTS, + effective_tts_model, +) +from timbal.voice.fish_audio import FishAudioStreamTTS +from timbal.voice.munsit import MunsitStreamTTS +from timbal.voice.providers import AudioOutputConfig, resolve_tts + + +class TestResolveTts: + def test_default_is_elevenlabs(self) -> None: + assert isinstance(resolve_tts(None), ElevenLabsStreamTTS) + assert isinstance(resolve_tts(""), ElevenLabsStreamTTS) + assert isinstance(resolve_tts(" "), ElevenLabsStreamTTS) + + def test_explicit_providers_and_aliases(self) -> None: + assert isinstance(resolve_tts("elevenlabs"), ElevenLabsStreamTTS) + assert isinstance(resolve_tts("11labs"), ElevenLabsStreamTTS) + assert isinstance(resolve_tts("munsit"), MunsitStreamTTS) + assert isinstance(resolve_tts("faseeh"), MunsitStreamTTS) + assert isinstance(resolve_tts("fishaudio"), FishAudioStreamTTS) + assert isinstance(resolve_tts("fish-audio"), FishAudioStreamTTS) + assert isinstance(resolve_tts("fish"), FishAudioStreamTTS) + # Case-insensitive, whitespace-tolerant (client hello strings). + assert isinstance(resolve_tts(" FishAudio "), FishAudioStreamTTS) + + def test_unknown_provider_raises(self) -> None: + with pytest.raises(ValueError, match="Unknown TTS provider"): + resolve_tts("polly") + + def test_provider_ids_match_config_values(self) -> None: + # ``build_voice_session`` reports ``tts.provider_id`` to clients/logs; + # it must round-trip through resolve_tts. + for provider_id in ("elevenlabs", "munsit", "fishaudio"): + assert resolve_tts(provider_id).provider_id == provider_id + + +class TestElevenLabsModelGuard: + """ElevenLabs' half of the cross-provider model guard. + + Munsit and Fish each swap foreign model ids out; without the mirror here + ElevenLabs was the one provider that would put another vendor's id in its + stream-input query string. + """ + + def test_eleven_ids_pass_through(self) -> None: + assert effective_tts_model(AudioOutputConfig(model="eleven_flash_v2_5")) == "eleven_flash_v2_5" + assert effective_tts_model(AudioOutputConfig(model="eleven_turbo_v2_5")) == "eleven_turbo_v2_5" + + def test_foreign_and_empty_ids_fall_back(self) -> None: + for model in (None, "", " ", "faseeh-v1-preview", "s2.1-pro", "scribe_v2_realtime"): + assert effective_tts_model(AudioOutputConfig(model=model)) == ELEVENLABS_DEFAULT_TTS_MODEL + + +def _session_for(**voice_config_kwargs): + agent = Agent(name="t", model=TestModel(responses=["ok"]), tools=[]) + defaults = VoiceConfig(turn_detector="heuristic", **voice_config_kwargs) + return build_voice_session(agent, defaults, {}) + + +class TestUnknownProviderFallback: + """An unrecognized ``tts_provider`` degrades to a *working* ElevenLabs session. + + The failure this pins: the fallback swapped the provider but kept the + requested model and voice, so the session reported ElevenLabs while putting + a Munsit model id and voice on its wire — TTS died on a config that looked + correct in the logs. + """ + + def test_foreign_model_and_voice_are_dropped(self) -> None: + session, meta = _session_for( + tts_provider="polly", + tts_model="faseeh-v1-preview", + voice="ar-najdi-male-2", + ) + assert meta["tts_provider"] == "elevenlabs" + assert session.audio_output.model is None + # Not merely non-Munsit: the voice is a required path segment, so it + # has to be a real ElevenLabs id rather than None/"". + assert session.audio_output.voice == DEFAULT_VOICE_ID + + def test_fallback_config_yields_an_elevenlabs_model_on_the_wire(self) -> None: + session, _ = _session_for(tts_provider="polly", tts_model="s2.1-pro") + assert effective_tts_model(session.audio_output) == ELEVENLABS_DEFAULT_TTS_MODEL + + def test_known_provider_keeps_its_own_model_and_voice(self) -> None: + session, meta = _session_for( + tts_provider="munsit", + tts_model="faseeh-v1-preview", + voice="ar-najdi-male-2", + ) + assert meta["tts_provider"] == "munsit" + assert session.audio_output.model == "faseeh-v1-preview" + assert session.audio_output.voice == "ar-najdi-male-2" diff --git a/python/timbal/server/voice.html b/python/timbal/server/voice.html index 64a4f370..962f48f3 100644 --- a/python/timbal/server/voice.html +++ b/python/timbal/server/voice.html @@ -576,6 +576,9 @@ background: var(--primary); } .btn-session[data-mode="start"]:hover:not(:disabled) { + /* Re-declare: the generic button:hover rule out-specifies the base + .btn-session[data-mode="start"] and would repaint this #fafafa. */ + background: var(--primary); opacity: 0.9; } .btn-session-icon--stop { @@ -787,6 +790,21 @@

Pipeline

+ +
@@ -970,6 +988,51 @@

Ambience

}; syncTdForStt(); +const ttsSelect = document.getElementById('tts-select'); +const ttsModelSelect = document.getElementById('tts-model-select'); +const TTS_STORAGE_KEY = 'timbal-voice-tts-provider'; +const TTS_MODEL_STORAGE_KEY = 'timbal-voice-tts-model'; +// Models per provider — the model picker follows the provider so a foreign +// model id never rides a provider's wire. +const TTS_MODELS = { + elevenlabs: ['eleven_flash_v2_5', 'eleven_turbo_v2_5', 'eleven_multilingual_v2'], + fishaudio: ['s2.1-pro', 's2.1-pro-free', 's2-pro', 's1'], + munsit: ['faseeh-v1-preview'], +}; +function syncTtsModels() { + const models = TTS_MODELS[ttsSelect.value] || []; + const prev = ttsModelSelect.value; + ttsModelSelect.innerHTML = ''; + const def = document.createElement('option'); + def.value = ''; + def.textContent = 'Server default'; + ttsModelSelect.appendChild(def); + for (const m of models) { + const o = document.createElement('option'); + o.value = m; + o.textContent = m; + ttsModelSelect.appendChild(o); + } + ttsModelSelect.value = models.includes(prev) ? prev : ''; + // "Server default" provider → the server picks the model too. + ttsModelSelect.disabled = !models.length; +} +ttsSelect.value = localStorage.getItem(TTS_STORAGE_KEY) || ''; +syncTtsModels(); +{ + const savedModel = localStorage.getItem(TTS_MODEL_STORAGE_KEY) || ''; + if ((TTS_MODELS[ttsSelect.value] || []).includes(savedModel)) ttsModelSelect.value = savedModel; +} +ttsSelect.onchange = () => { + // Applied on the next Start — the provider is fixed per WebSocket hello. + localStorage.setItem(TTS_STORAGE_KEY, ttsSelect.value); + syncTtsModels(); + localStorage.setItem(TTS_MODEL_STORAGE_KEY, ttsModelSelect.value); +}; +ttsModelSelect.onchange = () => { + localStorage.setItem(TTS_MODEL_STORAGE_KEY, ttsModelSelect.value); +}; + const modelSelect = document.getElementById('model-select'); const modelProviderSelect = document.getElementById('model-provider-select'); const modelCountEl = document.getElementById('model-count'); @@ -1913,6 +1976,8 @@

Ambience

const cfg = {}; if (tdSelect.value) cfg.turn_detector = tdSelect.value; if (sttSelect.value) cfg.stt_provider = sttSelect.value; + if (ttsSelect.value) cfg.tts_provider = ttsSelect.value; + if (ttsModelSelect.value) cfg.tts_model = ttsModelSelect.value; if (modelSelect.value) cfg.model = modelSelect.value; if (fillerSelect.value === 'off') { cfg.filler = { enabled: false }; @@ -2099,6 +2164,7 @@

Ambience

const bits = []; if (msg.model) bits.push(msg.model); if (msg.stt_provider) bits.push(`STT ${msg.stt_provider}${msg.stt_model ? `/${msg.stt_model}` : ''}`); + if (msg.tts_provider) bits.push(`TTS ${msg.tts_provider}`); if (msg.turn_detector) bits.push(msg.turn_detector); if (msg.vad_endpointing) bits.push('VAD endpointing'); setConn('live', 'Listening', bits.length diff --git a/python/timbal/server/voice.py b/python/timbal/server/voice.py index c32b28ac..0f3ceeb0 100644 --- a/python/timbal/server/voice.py +++ b/python/timbal/server/voice.py @@ -28,7 +28,7 @@ from .. import __version__ as timbal_version from ..voice.ambience import PRESETS as AMBIENT_PRESETS from ..voice.ambience import ensure_ambient_source -from ..voice.config import FillerConfig, RecordingConfig, VoiceConfig +from ..voice.config import DEFAULT_VOICE_ID, FillerConfig, RecordingConfig, VoiceConfig logger = structlog.get_logger("timbal.server.voice") @@ -147,6 +147,7 @@ def merge_voice_config(runnable: Any) -> VoiceConfig: CLIENT_SETTABLE_VOICE_FIELDS = frozenset({ "stt_provider", "stt_model", + "tts_provider", "tts_model", "voice", "language", @@ -466,8 +467,7 @@ def build_voice_session( resolve_stt, stt_provider_id, ) - from ..voice.elevenlabs import ElevenLabsStreamTTS - from ..voice.providers import AudioInputConfig, AudioOutputConfig + from ..voice.providers import AudioInputConfig, AudioOutputConfig, resolve_tts from ..voice.turn_detection import resolve_turn_detector merged = merge_client_voice_overrides(defaults, client_config) @@ -491,7 +491,22 @@ def build_voice_session( # Config id for clients/logs (``deepgram-flux``), not the class name. stt_provider = stt_provider_id(stt) stt_model = effective_stt_model(stt, stt_model_requested) - tts = ElevenLabsStreamTTS() + + tts_model_requested = merged.tts_model + tts_voice_requested = merged.voice + try: + tts = resolve_tts(merged.tts_provider) + except ValueError as e: + logger.warning("voice_ws_bad_tts_provider", error=str(e), requested_provider=merged.tts_provider) + # Same rule as the STT fallback: a Munsit/Fish model id or voice must + # not survive onto the ElevenLabs wire, or the socket fails on a config + # the session reports as ElevenLabs. The voice is a required *path* + # segment there, so substitute the default rather than clearing it. + tts = resolve_tts("elevenlabs") + tts_model_requested = None + tts_voice_requested = DEFAULT_VOICE_ID + # Config id for clients/logs (``fishaudio``), not the class name. + tts_provider = tts.provider_id # Client extras are unvalidated (model_copy in the merge): tolerate a # non-dict rather than 500-ing the socket. @@ -509,8 +524,8 @@ def build_voice_session( extra=stt_extra, ) audio_out = AudioOutputConfig( - model=merged.tts_model, - voice=merged.voice, + model=tts_model_requested, + voice=tts_voice_requested, sample_rate=merged.sample_rate, encoding=merged.encoding, extra=tts_extra, @@ -565,6 +580,8 @@ def build_voice_session( stt_provider=stt_provider, stt_model=stt_model, stt_model_requested=merged.stt_model, + tts=type(tts).__name__, + tts_provider=tts_provider, model=llm_model, turn_detector=turn_detector_label, vad_endpointing="auto" if vad_endpointing is None else vad_endpointing, @@ -636,6 +653,7 @@ def build_voice_session( "session_id": session.session_id, "stt_provider": stt_provider, "stt_model": stt_model, + "tts_provider": tts_provider, "model": llm_model, "turn_detector": turn_detector_label, # Server config, not client-settable. Phase 1: the browser mixes this diff --git a/python/timbal/tools/__init__.py b/python/timbal/tools/__init__.py index ffa2d5f3..87899e02 100644 --- a/python/timbal/tools/__init__.py +++ b/python/timbal/tools/__init__.py @@ -5,6 +5,18 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from .aircall import ( + AircallCreateContact, + AircallGetCall, + AircallGetCallTranscription, + AircallGetContact, + AircallListCalls, + AircallListContacts, + AircallPing, + AircallRequest, + AircallSearchCalls, + AircallUpdateContact, + ) from .asana import ( AsanaAddTaskToSection, AsanaCreateProject, @@ -24,18 +36,6 @@ AsanaSearchTasks, AsanaUpdateTask, ) - from .aircall import ( - AircallCreateContact, - AircallGetCall, - AircallGetCallTranscription, - AircallGetContact, - AircallListCalls, - AircallListContacts, - AircallPing, - AircallRequest, - AircallSearchCalls, - AircallUpdateContact, - ) from .bash import Bash from .cala import CalaQuery, CalaSearch from .cloudflare import ( @@ -92,91 +92,91 @@ ConnectifUpsertProduct, ) from .covermanager import ( - CoverManagerRequest, - CoverManagerGetRestaurantList, - CoverManagerGetRestaurantListWithPagination, - CoverManagerGetRestaurantFromSlug, - CoverManagerGetCompanies, - CoverManagerGetRestaurantsSubgroup, - CoverManagerSetReservsWebhook, - CoverManagerGetRestaurantByName, - CoverManagerGetRestaurantByPlace, - CoverManagerGetReservsBasic, - CoverManagerGetReservs, - CoverManagerGetReservRestaurant, - CoverManagerGetMap, - CoverManagerGetTableAvailabilityByRestaurant, - CoverManagerCreateCategory, - CoverManagerCreateTag, - CoverManagerCreateTagsByCategory, + CoverManagerAddClient, + CoverManagerAddCommentaryClient, + CoverManagerAddExternalPayment, + CoverManagerAddPromotionalCode, + CoverManagerAvailability, CoverManagerAvailabilityDays, CoverManagerAvailabilityDaysInfoHoursPeople, CoverManagerAvailabilityDaysTotal, - CoverManagerAvailability, CoverManagerAvailabilityExtended, CoverManagerAvailabilityMessage, - CoverManagerIsReservable, - CoverManagerGetZones, - CoverManagerReserv, - CoverManagerUpdateReserv, - CoverManagerReservWalkInReserv, - CoverManagerWaitingList, - CoverManagerReservForce, CoverManagerCancelClient, - CoverManagerCrossSelling, - CoverManagerSetWebhookUrl, - CoverManagerSetConfirmUrl, - CoverManagerSetCancelUrl, - CoverManagerSetSeatedStatus, - CoverManagerSetConfirmStatus, - CoverManagerRevertStatus, - CoverManagerSitClientPending, - CoverManagerUndoSeatedStatus, - CoverManagerSetTicketReserv, - CoverManagerSetTicketParcial, - CoverManagerSetCard, - CoverManagerAddExternalPayment, - CoverManagerGetSecurePaymentInfo, - CoverManagerAddCommentaryClient, - CoverManagerUpdateMinimumSpend, - CoverManagerGetTickets, - CoverManagerGetProductByAvailability, - CoverManagerSetTablesId, - CoverManagerGetPayUrl, - CoverManagerSatisfactions, - CoverManagerGetResumenDate, - CoverManagerMakeSatisfactionSurvey, - CoverManagerListClients, - CoverManagerAddClient, - CoverManagerGetClient, - CoverManagerUpdateFoodPreference, - CoverManagerManageClientsReview, - CoverManagerGetClientsReservs, - CoverManagerGetOrders, - CoverManagerUpdateOrderStatus, - CoverManagerAddPromotionalCode, - CoverManagerUpdatePromotionalCodeUpdate, - CoverManagerDeletePromotionalCode, CoverManagerCheckCode, CoverManagerCreate, + CoverManagerCreateCategory, + CoverManagerCreateTag, + CoverManagerCreateTagsByCategory, + CoverManagerCrossSelling, CoverManagerDelete, - CoverManagerListOnthego, + CoverManagerDeletePromotionalCode, CoverManagerEdit, - CoverManagerGetPaysPays, - CoverManagerGetPaysTypes, + CoverManagerGetClient, + CoverManagerGetClientsReservs, + CoverManagerGetCompanies, + CoverManagerGetExternalPaysMultilicenses, CoverManagerGetExternalPaysPays, - CoverManagerGetRefundsPays, - CoverManagerGetProducts, + CoverManagerGetMap, CoverManagerGetMapAlt, - CoverManagerReservWalkInMultilicenses, - CoverManagerGetReserv, + CoverManagerGetOrders, CoverManagerGetPays, + CoverManagerGetPaysPays, + CoverManagerGetPaysTypes, + CoverManagerGetPayUrl, + CoverManagerGetProductByAvailability, + CoverManagerGetProducts, CoverManagerGetRefundsMultilicenses, - CoverManagerGetExternalPaysMultilicenses, + CoverManagerGetRefundsPays, + CoverManagerGetReserv, + CoverManagerGetReservRestaurant, + CoverManagerGetReservs, + CoverManagerGetReservsBasic, + CoverManagerGetRestaurantByName, + CoverManagerGetRestaurantByPlace, + CoverManagerGetRestaurantFromSlug, + CoverManagerGetRestaurantList, + CoverManagerGetRestaurantListWithPagination, + CoverManagerGetRestaurantsSubgroup, + CoverManagerGetResumenDate, + CoverManagerGetSecurePaymentInfo, + CoverManagerGetTableAvailabilityByRestaurant, + CoverManagerGetTickets, CoverManagerGetWebhookChannel, - CoverManagerSetWebhookChannel, CoverManagerGetWebhookTpvWebhooks, + CoverManagerGetZones, + CoverManagerIsReservable, + CoverManagerListClients, + CoverManagerListOnthego, + CoverManagerMakeSatisfactionSurvey, + CoverManagerManageClientsReview, + CoverManagerRequest, + CoverManagerReserv, + CoverManagerReservForce, + CoverManagerReservWalkInMultilicenses, + CoverManagerReservWalkInReserv, + CoverManagerRevertStatus, + CoverManagerSatisfactions, + CoverManagerSetCancelUrl, + CoverManagerSetCard, + CoverManagerSetConfirmStatus, + CoverManagerSetConfirmUrl, + CoverManagerSetReservsWebhook, + CoverManagerSetSeatedStatus, + CoverManagerSetTablesId, + CoverManagerSetTicketParcial, + CoverManagerSetTicketReserv, + CoverManagerSetWebhookChannel, CoverManagerSetWebhookTpv, + CoverManagerSetWebhookUrl, + CoverManagerSitClientPending, + CoverManagerUndoSeatedStatus, + CoverManagerUpdateFoodPreference, + CoverManagerUpdateMinimumSpend, + CoverManagerUpdateOrderStatus, + CoverManagerUpdatePromotionalCodeUpdate, + CoverManagerUpdateReserv, + CoverManagerWaitingList, ) from .dynamics_business_central import ( DynamicsBCCreateCustomer, @@ -535,6 +535,13 @@ KlaviyoUpdateProfile, ) from .knowledge_base import KnowledgeBaseQuery + from .krea import ( + KreaCancelJob, + KreaGenerateImage, + KreaGenerateVideo, + KreaGetJob, + KreaListJobs, + ) from .lancedb import ( LanceDBCreateFTSIndex, LanceDBCreateTable, @@ -664,13 +671,6 @@ QuiverAIListModels, QuiverAIVectorizeSVG, ) - from .krea import ( - KreaCancelJob, - KreaGenerateImage, - KreaGenerateVideo, - KreaGetJob, - KreaListJobs, - ) from .read import Read from .replicate import ( ReplicateCancelPrediction, diff --git a/python/timbal/voice/__init__.py b/python/timbal/voice/__init__.py index 28ab72bc..97dec02d 100644 --- a/python/timbal/voice/__init__.py +++ b/python/timbal/voice/__init__.py @@ -81,6 +81,14 @@ def __getattr__(name: str): from .elevenlabs import ElevenLabsRealtimeSTT return ElevenLabsRealtimeSTT + if name == "MunsitStreamTTS": + from .munsit import MunsitStreamTTS + + return MunsitStreamTTS + if name == "FishAudioStreamTTS": + from .fish_audio import FishAudioStreamTTS + + return FishAudioStreamTTS if name == "SmartTurnEouModel": from .smart_turn import SmartTurnEouModel @@ -114,9 +122,11 @@ def __getattr__(name: str): "EouPredictor", "FillerConfig", "FillerSpoken", + "FishAudioStreamTTS", "HeuristicTurnDetector", "LexicalTurnDetector", "LocalAudioTurnDetector", + "MunsitStreamTTS", "NamoTextEouPredictor", "PartialDecision", "PlaybackTracker", diff --git a/python/timbal/voice/config.py b/python/timbal/voice/config.py index 2e741b05..70c9af96 100644 --- a/python/timbal/voice/config.py +++ b/python/timbal/voice/config.py @@ -111,6 +111,9 @@ class VoiceConfig(BaseModel): stt_provider: str = "elevenlabs" stt_model: str = "scribe_v2_realtime" + tts_provider: str = "elevenlabs" + """``"elevenlabs"``, ``"munsit"`` (Arabic; requires ``MUNSIT_API_KEY``), or + ``"fishaudio"`` (requires ``FISH_API_KEY``).""" tts_model: str = "eleven_flash_v2_5" voice: str = DEFAULT_VOICE_ID language: str | None = None diff --git a/python/timbal/voice/elevenlabs.py b/python/timbal/voice/elevenlabs.py index 06169842..8e9f12a6 100644 --- a/python/timbal/voice/elevenlabs.py +++ b/python/timbal/voice/elevenlabs.py @@ -100,6 +100,22 @@ def _tts_output_format(config: AudioOutputConfig) -> str: return "pcm_16000" +def effective_tts_model(config: AudioOutputConfig) -> str: + """Model id actually sent to ElevenLabs (foreign leftovers swapped out). + + The mirror of the Munsit/Fish guards. ``tts_model`` is server-wide while + ``tts_provider`` is per-session and client-settable, so a session that + selects another provider's model — or falls back here after an unknown + provider — must not put ``faseeh-v1-preview`` in the stream-input query + string. ElevenLabs rejects the handshake, which reads as "TTS is broken" + rather than "the model id belongs to a different provider". + """ + m = (config.model or "").strip() + if m.startswith("eleven"): + return m + return _DEFAULT_TTS_MODEL + + class ElevenLabsRealtimeSTT(SpeechToText): """ElevenLabs Scribe v2 realtime WebSocket (VAD commits by default).""" @@ -271,6 +287,8 @@ class ElevenLabsStreamTTS(TextToSpeech): fine with ``eleven_flash_v2_5`` (the default for real-time voice). """ + provider_id = "elevenlabs" + def __init__(self, api_key: str | SecretStr | None = None) -> None: self._api_key_explicit = api_key self._api_key: str | None = None @@ -326,7 +344,7 @@ async def _ensure_ws_locked(self) -> None: assert cfg is not None extra = dict(cfg.extra) host = str(extra.pop("tts_host", "api.elevenlabs.io")) - model_id = cfg.model or _DEFAULT_TTS_MODEL + model_id = effective_tts_model(cfg) output_format = _tts_output_format(cfg) inactivity_timeout = int(extra.pop("inactivity_timeout", _DEFAULT_TTS_INACTIVITY_TIMEOUT)) inactivity_timeout = max(20, min(inactivity_timeout, 180)) diff --git a/python/timbal/voice/fish_audio.py b/python/timbal/voice/fish_audio.py new file mode 100644 index 00000000..57c9da9e --- /dev/null +++ b/python/timbal/voice/fish_audio.py @@ -0,0 +1,294 @@ +"""Fish Audio streaming TTS for :class:`~timbal.voice.VoiceSession`. + +Uses the official live TTS WebSocket: + +* ``wss://api.fish.audio/v1/tts/live`` — MessagePack frames + +One connection per agent reply: a ``start`` event carries the TTS request +(``format: pcm`` at the session sample rate), ``text`` events stream the reply +incrementally, ``flush`` + ``stop`` end it, and the server replies with +``audio`` events followed by a terminal ``finish`` event. + +Requires ``websockets`` + ``ormsgpack`` (``pip install timbal[server]``) and +``FISH_API_KEY``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +from collections.abc import AsyncIterator +from typing import Any + +import structlog +from pydantic import SecretStr +from websockets.asyncio.client import connect as ws_connect +from websockets.exceptions import ConnectionClosed, InvalidStatus + +from .config import DEFAULT_VOICE_ID as _ELEVENLABS_DEFAULT_VOICE_ID +from .providers import ( + AudioOutputConfig, + TextToSpeech, + TTSStream, +) + +logger = structlog.get_logger("timbal.voice.fish_audio") + +DEFAULT_TTS_MODEL = "s2.1-pro" +_KNOWN_MODELS = ("s1", "s2-pro", "s2.1-pro", "s2.1-pro-free") + +_DEFAULT_HOST = "api.fish.audio" + + +def _resolve_api_key(explicit: str | SecretStr | None) -> str: + if isinstance(explicit, SecretStr): + return explicit.get_secret_value() + if explicit: + return explicit + key = os.environ.get("FISH_API_KEY") + if not key: + raise ValueError("Set FISH_API_KEY or pass api_key to the provider.") + return key + + +def effective_tts_model(config: AudioOutputConfig) -> str: + """Model id actually sent to Fish Audio (foreign leftovers swapped out). + + The server-wide ``tts_model`` default is ElevenLabs' — a session that only + switched ``tts_provider`` must not put ``eleven_flash_v2_5`` on the Fish + Audio wire. Unknown ids also fall back: the server would silently degrade + to its own default anyway, so make the substitution explicit. + """ + m = (config.model or "").strip() + if m in _KNOWN_MODELS: + return m + return DEFAULT_TTS_MODEL + + +def effective_reference_id(config: AudioOutputConfig) -> str | None: + """Fish Audio voice model id, or None for the platform default voice. + + The server-wide ``voice`` default is an ElevenLabs voice id; drop it so + Fish Audio picks its own default unless the caller chose a real reference. + """ + v = (config.voice or "").strip() + if not v or v == _ELEVENLABS_DEFAULT_VOICE_ID: + return os.environ.get("FISH_VOICE_ID") or None + return v + + +def build_start_request(config: AudioOutputConfig) -> dict[str, Any]: + """The ``request`` payload of the ``start`` event (HTTP TTS API fields).""" + extra = dict(config.extra) + request: dict[str, Any] = { + "text": "", + "format": "pcm", + "sample_rate": config.sample_rate, + # "balanced" trades a little quality for latency — the right default + # for live voice; override via tts_extra for narration-grade output. + "latency": str(extra.get("latency", "balanced")), + "prosody": { + "speed": float(extra.get("speed", 1.0)), + "volume": float(extra.get("volume", 0.0)), + }, + # Explicit (matches the server default): previous audio conditions + # later chunks, keeping one consistent voice across a reply's flushes. + "condition_on_previous_chunks": bool(extra.get("condition_on_previous_chunks", True)), + } + reference_id = effective_reference_id(config) + if reference_id: + request["reference_id"] = reference_id + for key in ("temperature", "top_p", "chunk_length", "normalize"): + if key in extra: + request[key] = extra[key] + return request + + +class FishAudioStreamTTS(TextToSpeech): + """Fish Audio TTS via the live WebSocket, one connection per reply. + + ``open_stream`` returns a fresh :class:`_FishAudioTTSStream` per agent + reply, so all flushes of a reply share one session and voice consistency + is kept via ``condition_on_previous_chunks`` (sent explicitly in the + ``start`` request). ``synthesize`` is the per-segment fallback + (stream = feed once + end). + """ + + provider_id = "fishaudio" + + def __init__(self, api_key: str | SecretStr | None = None) -> None: + self._api_key_explicit = api_key + self._api_key: str | None = None + self._out: AudioOutputConfig | None = None + + async def connect(self, config: AudioOutputConfig) -> None: + self._api_key = _resolve_api_key(self._api_key_explicit) + # Fail at session start, not first reply, if the extra is missing. + import ormsgpack # noqa: F401 + + self._out = config + + def open_stream(self) -> _FishAudioTTSStream: + if not self._api_key or not self._out: + raise RuntimeError("Call connect() before open_stream().") + return _FishAudioTTSStream(self) + + async def synthesize(self, text: str) -> AsyncIterator[bytes]: + if not self._api_key or not self._out: + raise RuntimeError("Call connect() before synthesize().") + if not text.strip(): + return + stream = self.open_stream() + await stream.feed(text) + await stream.end() + async for chunk in stream.audio(): + yield chunk + + async def close(self) -> None: + self._out = None + + +class _FishAudioTTSStream(TTSStream): + """One Fish Audio WS session fed incrementally over an agent reply. + + Lifecycle: lazy-connect on first ``feed`` (``start`` event carries the TTS + request), ``end`` sends ``flush`` + ``stop``; ``audio()`` terminates on the + server's ``finish`` event. + """ + + def __init__(self, tts: FishAudioStreamTTS) -> None: + self._tts = tts + self._ws: Any = None + self._reader_task: asyncio.Task[None] | None = None + # Created eagerly so ``audio()`` can be iterated before the first feed + # opens the connection (the session starts the pump task immediately). + self._queue: asyncio.Queue[dict | None] = asyncio.Queue() + self._ended = False + self._aborted = False + self._chunks = 0 + self._last_error: str | None = None + + async def _send(self, payload: dict[str, Any]) -> None: + import ormsgpack + + await self._ws.send(ormsgpack.packb(payload)) + + async def _open(self) -> None: + tts = self._tts + cfg = tts._out + assert cfg is not None and tts._api_key is not None + host = str(cfg.extra.get("tts_host", _DEFAULT_HOST)) + model = effective_tts_model(cfg) + + uri = f"wss://{host}/v1/tts/live" + logger.debug("fish_tts_ws_connecting", uri=uri, model=model) + try: + self._ws = await ws_connect( + uri, + additional_headers={ + "Authorization": f"Bearer {tts._api_key}", + "model": model, + }, + ) + except InvalidStatus as e: + status = e.response.status_code + if status == 402: + hint = ( + f" — insufficient wallet balance for model {model!r}; use 's2.1-pro-free' or top up at fish.audio" + ) + elif status in (401, 403): + hint = " — check FISH_API_KEY" + else: + hint = "" + raise RuntimeError(f"Fish Audio rejected the connection (HTTP {status}){hint}") from e + await self._send({"event": "start", "request": build_start_request(cfg)}) + self._reader_task = asyncio.create_task(self._read_loop()) + logger.debug("fish_tts_ws_connected") + + async def _read_loop(self) -> None: + import ormsgpack + + assert self._ws is not None + try: + async for raw in self._ws: + msg = ormsgpack.unpackb(raw) + event = msg.get("event") + if event == "finish": + if msg.get("reason") == "error": + self._last_error = "Fish Audio finished with reason=error" + break + # Unknown events are ignored per protocol docs. + await self._queue.put(msg) + except ConnectionClosed as e: + if not self._ended and not self._aborted: + self._last_error = str(e) + logger.warning("fish_tts_ws_closed_unrequested", error=str(e)) + except asyncio.CancelledError: + raise + except Exception as e: + self._last_error = str(e) + logger.error("fish_tts_reader_error", error=str(e), exc_info=True) + finally: + with contextlib.suppress(Exception): + self._queue.put_nowait(None) + + async def feed(self, text: str) -> None: + if self._ended or self._aborted or not text.strip(): + return + if self._ws is None: + await self._open() + logger.debug("fish_tts_stream_feed", text_chars=len(text), text_preview=text[:120]) + await self._send({"event": "text", "text": text}) + + async def end(self) -> None: + if self._ended or self._aborted: + return + self._ended = True + if self._ws is None: + # Nothing was ever fed — unblock audio() directly. + self._queue.put_nowait(None) + return + try: + # flush forces synthesis of buffered text; stop makes the server + # drain remaining audio and emit finish, which terminates audio(). + await self._send({"event": "flush"}) + await self._send({"event": "stop"}) + except Exception as e: + logger.warning("fish_tts_stream_end_failed", error=str(e)) + self._queue.put_nowait(None) + + async def abort(self) -> None: + if self._aborted: + return + self._aborted = True + if self._ws is not None: + with contextlib.suppress(Exception): + await self._send({"event": "stop"}) + with contextlib.suppress(Exception): + await self._ws.close() + self._queue.put_nowait(None) + + async def audio(self) -> AsyncIterator[bytes]: + try: + while True: + msg = await self._queue.get() + if msg is None: + if self._last_error and not self._aborted: + raise RuntimeError(f"Fish Audio TTS failed: {self._last_error}") + return + audio = msg.get("audio") + if audio: + self._chunks += 1 + yield bytes(audio) + finally: + if self._reader_task and not self._reader_task.done(): + self._reader_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._reader_task + self._reader_task = None + if self._ws is not None: + with contextlib.suppress(Exception): + await self._ws.close() + self._ws = None + logger.debug("fish_tts_stream_done", audio_chunks=self._chunks) diff --git a/python/timbal/voice/munsit.py b/python/timbal/voice/munsit.py new file mode 100644 index 00000000..9855541d --- /dev/null +++ b/python/timbal/voice/munsit.py @@ -0,0 +1,166 @@ +"""Munsit (Faseeh) streaming Arabic TTS for :class:`~timbal.voice.VoiceSession`. + +Uses the HTTP chunked-streaming endpoint: + +* ``POST /api/v1/text-to-speech/{model_id}`` with ``streaming: true`` — + raw PCM16 chunks are yielded as they are generated. + +Munsit also documents a TTS WebSocket (``/websocket/text-to-speech``), but as +of 2026-08 it accepts ``initConnection``/``text`` frames and never produces +audio (verified empirically across auth methods, flush flags and long texts), +so this provider deliberately uses HTTP streaming — which Munsit itself +recommends when the text of a segment is available upfront. No ``open_stream`` +capability: the session falls back to per-segment ``synthesize``. + +Requires ``MUNSIT_API_KEY``. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from typing import Any + +import structlog +from pydantic import SecretStr + +from .config import DEFAULT_VOICE_ID as _ELEVENLABS_DEFAULT_VOICE_ID +from .providers import ( + AudioOutputConfig, + TextToSpeech, +) + +logger = structlog.get_logger("timbal.voice.munsit") + +DEFAULT_TTS_MODEL = "faseeh-v1-preview" +# Override with MUNSIT_VOICE_ID (cloned/custom voices are account-specific). +DEFAULT_MUNSIT_VOICE_ID = "ar-najdi-male-2" + +_DEFAULT_HOST = "api.munsit.com" +_SAMPLE_RATE_MIN = 8_000 +_SAMPLE_RATE_MAX = 48_000 + + +def _resolve_api_key(explicit: str | SecretStr | None) -> str: + if isinstance(explicit, SecretStr): + return explicit.get_secret_value() + if explicit: + return explicit + key = os.environ.get("MUNSIT_API_KEY") + if not key: + raise ValueError("Set MUNSIT_API_KEY or pass api_key to the provider.") + return key + + +def effective_sample_rate(config: AudioOutputConfig) -> int: + """Sample rate actually requested (endpoint accepts 8000–48000 Hz). + + Out-of-range rates raise instead of remapping: the session clocks playback + at ``config.sample_rate``, so silently requesting a different rate would + desync audio (chipmunk/slow-motion speech). + """ + sr = config.sample_rate + if not (_SAMPLE_RATE_MIN <= sr <= _SAMPLE_RATE_MAX): + raise ValueError(f"Munsit TTS supports {_SAMPLE_RATE_MIN}-{_SAMPLE_RATE_MAX} Hz; got {sr}.") + return sr + + +def effective_tts_model(config: AudioOutputConfig) -> str: + """Model id actually sent to Munsit (foreign leftovers swapped out). + + The server-wide ``tts_model`` default is ElevenLabs' — a session that only + switched ``tts_provider`` must not put ``eleven_flash_v2_5`` on the Munsit + wire. + """ + m = (config.model or "").strip() + if not m or m.lower().startswith(("eleven", "scribe")): + return DEFAULT_TTS_MODEL + return m + + +def effective_voice_id(config: AudioOutputConfig) -> str: + """Voice id actually sent to Munsit. + + The server-wide ``voice`` default is an ElevenLabs voice id; swap it for a + Munsit voice unless the caller picked one explicitly. + """ + v = (config.voice or "").strip() + if not v or v == _ELEVENLABS_DEFAULT_VOICE_ID: + return os.environ.get("MUNSIT_VOICE_ID") or DEFAULT_MUNSIT_VOICE_ID + return v + + +class MunsitStreamTTS(TextToSpeech): + """Munsit TTS via HTTP chunked streaming, one request per segment. + + A persistent ``httpx.AsyncClient`` keeps connections pooled across + segments, so per-segment requests skip the TCP+TLS handshake. + """ + + provider_id = "munsit" + + def __init__(self, api_key: str | SecretStr | None = None) -> None: + self._api_key_explicit = api_key + self._api_key: str | None = None + self._out: AudioOutputConfig | None = None + self._client: Any = None + + async def connect(self, config: AudioOutputConfig) -> None: + self._api_key = _resolve_api_key(self._api_key_explicit) + # Fail at session start, not first reply, on an unsupported rate. + effective_sample_rate(config) + self._out = config + import httpx + + # read=None: chunk gaps track generation pace and a hard read timeout + # would sever long segments mid-audio. + self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0, read=None)) + + async def synthesize(self, text: str) -> AsyncIterator[bytes]: + if not self._api_key or self._client is None or self._out is None: + raise RuntimeError("Call connect() before synthesize().") + stripped = text.strip() + if not stripped: + return + + cfg = self._out + extra = dict(cfg.extra) + host = str(extra.get("tts_host", _DEFAULT_HOST)) + model_id = effective_tts_model(cfg) + payload: dict[str, Any] = { + "voice_id": effective_voice_id(cfg), + "text": stripped, + "stability": float(extra.get("stability", 0.5)), + "speed": float(extra.get("speed", 1.0)), + "streaming": True, + "sample_rate": effective_sample_rate(cfg), + } + if extra.get("dialect"): + payload["dialect"] = str(extra["dialect"]) + + url = f"https://{host}/api/v1/text-to-speech/{model_id}" + logger.debug("munsit_tts_request", model_id=model_id, text_chars=len(stripped), text_preview=stripped[:120]) + chunk_count = 0 + async with self._client.stream( + "POST", + url, + headers={"x-api-key": self._api_key}, + json=payload, + ) as response: + if response.status_code != 200: + body = (await response.aread()).decode("utf-8", errors="replace") + raise RuntimeError(f"Munsit TTS error {response.status_code}: {body[:500]}") + async for chunk in response.aiter_bytes(): + if chunk: + chunk_count += 1 + yield chunk + logger.debug("munsit_tts_request_done", audio_chunks=chunk_count) + + async def close(self) -> None: + if self._client is not None: + import contextlib + + with contextlib.suppress(Exception): + await self._client.aclose() + self._client = None + self._out = None diff --git a/python/timbal/voice/providers.py b/python/timbal/voice/providers.py index 09e9f652..66ca9740 100644 --- a/python/timbal/voice/providers.py +++ b/python/timbal/voice/providers.py @@ -10,7 +10,7 @@ from collections.abc import AsyncIterator from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, SecretStr class AudioInputConfig(BaseModel): @@ -98,6 +98,10 @@ class TextToSpeech(ABC): Lifecycle: ``connect`` → ``synthesize`` (repeatable) → ``close``. """ + provider_id: str = "unknown" + """Config-style provider id (``"elevenlabs"``, ``"munsit"``, ...) — matches + playground / ``voice_config.tts_provider`` values, not the class name.""" + @abstractmethod async def connect(self, config: AudioOutputConfig) -> None: ... @@ -117,3 +121,34 @@ def open_stream(self) -> TTSStream | None: @abstractmethod async def close(self) -> None: ... + + +def resolve_tts( + provider: str | None = None, + *, + api_key: str | SecretStr | None = None, +) -> TextToSpeech: + """TTS factory for the voice server — the counterpart of ``resolve_stt``. + + ``provider`` is case-insensitive: ``"elevenlabs"`` (default when empty; + aliases ``"el"``, ``"11labs"``), ``"munsit"`` (alias ``"faseeh"``), or + ``"fishaudio"`` (aliases ``"fish"``, ``"fish-audio"``). Unknown ids raise + ``ValueError`` so the caller can log and fall back explicitly. + + Provider modules import lazily — selecting Munsit never imports the + ElevenLabs/Fish WebSocket stacks. + """ + p = (provider or "").strip().lower() or "elevenlabs" + if p in ("elevenlabs", "el", "11labs"): + from . import elevenlabs + + return elevenlabs.ElevenLabsStreamTTS(api_key=api_key) + if p in ("munsit", "faseeh"): + from . import munsit + + return munsit.MunsitStreamTTS(api_key=api_key) + if p in ("fishaudio", "fish-audio", "fish"): + from . import fish_audio + + return fish_audio.FishAudioStreamTTS(api_key=api_key) + raise ValueError(f"Unknown TTS provider: {provider!r}") diff --git a/uv.lock b/uv.lock index 44fc2dd4..63294f95 100644 --- a/uv.lock +++ b/uv.lock @@ -1334,6 +1334,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + [[package]] name = "overrides" version = "7.7.0" @@ -2350,6 +2398,7 @@ all = [ { name = "libcst", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "onnxruntime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "openpyxl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ormsgpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pymupdf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-docx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2372,6 +2421,7 @@ evals = [ ] server = [ { name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "ormsgpack", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] @@ -2410,6 +2460,8 @@ requires-dist = [ { name = "openai", specifier = ">=1.60.0" }, { name = "openpyxl", marker = "extra == 'all'", specifier = ">=3.1.5" }, { name = "openpyxl", marker = "extra == 'documents'", specifier = ">=3.1.5" }, + { name = "ormsgpack", marker = "extra == 'all'", specifier = ">=1.5.0" }, + { name = "ormsgpack", marker = "extra == 'server'", specifier = ">=1.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pymupdf", marker = "extra == 'all'", specifier = ">=1.27.1" }, { name = "pymupdf", marker = "extra == 'documents'", specifier = ">=1.27.1" },