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" },