Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0d4313a
feat: add Speko router extensions
idafoh Aug 31, 2026
1885bca
fix: address speko extension review feedback (#2307)
idafoh Sep 11, 2026
a27db61
fix: harden speko lifecycle and review validation (#2307)
idafoh Sep 18, 2026
654090f
fix: drain accepted asr audio before finalizing (#2307)
idafoh Sep 18, 2026
1200732
test(speko): run standalone suites on pytest 8.4
Ilmak17 Nov 12, 2026
049d672
fix(speko_asr_python): bound buffering and harden setup and commit fa…
Ilmak17 Nov 12, 2026
a9e7e59
fix(speko_llm2_python): report malformed router responses as ten errors
Ilmak17 Nov 12, 2026
d7f2093
fix(speko_tts2_python): stop safely and account audio end from emitte…
Ilmak17 Nov 12, 2026
0d5e98f
test(tts_guarder): add speko ttfb reference hook
Ilmak17 Nov 12, 2026
6ceb7a5
fix(speko_tts2_python): stamp first audio inside the receive task
Ilmak17 Nov 12, 2026
89c4bc8
fix(speko_asr_python): report a fatal setup failure once
Ilmak17 Nov 12, 2026
984db54
fix(speko_asr_python): report TTLW when finalize has no final result
Ilmak17 Nov 13, 2026
6ad804c
fix(speko_asr): pair commit acks FIFO and keep buffered audio per ses…
Ilmak17 Nov 13, 2026
b6636e8
fix(speko_llm2): forward the item schema of array tool parameters
Ilmak17 Nov 13, 2026
ce296e0
fix(speko_tts2): skip empty audio frames and dump only emitted PCM
Ilmak17 Nov 13, 2026
12082c6
fix(speko_asr): correlate commit answers by commit_id and bound replay
Ilmak17 Nov 13, 2026
74ab5b5
fix(speko_asr): take commit correlation only from session.ready
Ilmak17 Nov 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"smallest_asr_python",
"rtzr_asr_python",
"soniox_asr_python",
"speko_asr_python",
"tencent_asr_python",
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,27 @@ After these changes, all seven live sequential-request TTFB configurations also
passed with the independent reference enabled and the existing 1 ms tolerance.
The endpoint checks and single-extension installation checks are separate from
that live result: the live verification app supplies all selected packages.

## Speko Router hook

As of: 2026-11-12

`speko_tts2_python` registers `ttfb_hooks/speko.py` (`SpekoObserver`) on the
extension's `client` module. The guarder maps the configured `base_url` to the
Router's TTS socket itself (`https`→`wss`, `http`→`ws`, base path kept, then
`/v1/tts/stream`); it does not import the client's URL helper. Text is the first
`input.append` JSON frame with non-whitespace text; audio is the first nonempty
binary frame on that connection. `session.configure`, `input.commit`,
`input.cancel`, `session.close` and every Router JSON event are excluded. The
Router runs one utterance per session, so audio follows the connection's active
text request, and a previous connection's audio cannot complete a new request.

Offline coverage joins the parametrized wire, deferred-decode and local
WebSocket transport tests, plus Speko-specific control-frame and
connection-isolation cases. Endpoint cases cover `https://r.test/edge/` →
`wss://r.test/edge/v1/tts/stream` and `http://127.0.0.1:8080` →
`ws://127.0.0.1:8080/v1/tts/stream`. Speko normalization requires `api_key`,
so the default-endpoint test supplies a placeholder. The extension reports TTFB
from just before the `input.append` send to the first audio frame, rounded to
the nearest millisecond, to stay within the 1 ms reference tolerance. The live
sequential-request run for Speko has not been recorded here yet.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ else
fi

# Only websocket TTS extensions report connection status.
if [[ "${EXTENSION_NAME}" == "fish_audio_tts_python" || "${EXTENSION_NAME}" == "bytedance_tts" || "${EXTENSION_NAME}" == "bytedance_tts_duplex" || "${EXTENSION_NAME}" == "minimax_tts_websocket" || "${EXTENSION_NAME}" == "minimax_tts_websocket_python" || "${EXTENSION_NAME}" == "rime_tts" ]]; then
if [[ "${EXTENSION_NAME}" == "fish_audio_tts_python" || "${EXTENSION_NAME}" == "bytedance_tts" || "${EXTENSION_NAME}" == "bytedance_tts_duplex" || "${EXTENSION_NAME}" == "minimax_tts_websocket" || "${EXTENSION_NAME}" == "minimax_tts_websocket_python" || "${EXTENSION_NAME}" == "rime_tts" || "${EXTENSION_NAME}" == "speko_tts2_python" ]]; then
export ENABLE_CONNECTION_STATUS="True"
else
export ENABLE_CONNECTION_STATUS="False"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
TTS_CONNECTION_STATUS_CONFIG_FILE = "property_basic_audio_setting1.json"
SUPPORTED_WEBSOCKET_TTS_EXTENSIONS = {
"fish_audio_tts_python",
"speko_tts2_python",
"bytedance_tts",
"bytedance_tts_duplex",
"minimax_tts_websocket",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,21 @@
{"params": {"url": "${env:TTFB_ENDPOINT|}"}},
"wss://env.test/ws",
),
(
"speko_tts2_python",
{"params": {"api_key": "k", "base_url": "https://r.test/edge/"}},
"wss://r.test/edge/v1/tts/stream",
),
(
"speko_tts2_python",
{"params": {"api_key": "k", "base_url": "http://127.0.0.1:8080"}},
"ws://127.0.0.1:8080/v1/tts/stream",
),
]

# Normalization of these extensions also validates a required credential.
MINIMAL_CONFIGS = {"speko_tts2_python": {"params": {"api_key": "k"}}}


@pytest.mark.parametrize("vendor,config,expected", CASES)
def test_selected_extension_endpoint_precedence(
Expand All @@ -58,7 +71,9 @@ def test_selected_extension_endpoint_precedence(
def test_selected_extension_default_endpoint(extension_name):
if extension_name not in CONFIGS:
pytest.skip("No TTFB endpoint registered for this extension")
_, normalized, url = effective_config(extension_name, {})
_, normalized, url = effective_config(
extension_name, MINIMAL_CONFIGS.get(extension_name, {})
)
if extension_name == "elevenlabs_multiple_context_tts_python":
url = url or normalized.params.get("base_url") or HOOKS[extension_name][3]
assert url.startswith("wss://")
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Deterministic wire fixtures for all seven first-batch TTFB modes."""
"""Deterministic wire fixtures for the first-batch TTFB modes and Speko."""

import gzip
import json
Expand All @@ -11,6 +11,7 @@
from .ttfb_hooks.bytedance_duplex import ByteDanceDuplexObserver
from .ttfb_hooks.minimax import MiniMaxObserver
from .ttfb_hooks.soul import SoulObserver
from .ttfb_hooks.speko import SpekoObserver
from .test_soul_ttfb_reference import gateway_frame


Expand Down Expand Up @@ -41,6 +42,10 @@ def wire(mode, context, audio=False, empty=False):
json.dumps({"request": {"reqid": context, "text": text}}).encode()
)
return b"\x11\x10\x11\x00" + len(body).to_bytes(4, "big") + body
if mode == "speko":
if audio:
return pcm
return json.dumps({"type": "input.append", "text": text})
if mode == "bytedance-duplex":
return duplex(
352 if audio else 200,
Expand Down Expand Up @@ -82,6 +87,7 @@ def wire(mode, context, audio=False, empty=False):
("minimax", MiniMaxObserver),
("minimax-duplex", MiniMaxObserver),
("soul", SoulObserver),
("speko", SpekoObserver),
],
)
def test_hook_times_only_first_text_and_nonempty_audio(
Expand Down Expand Up @@ -143,6 +149,7 @@ def test_context_does_not_cross_requests(monkeypatch, mode, observer_class):
("minimax", "minimax_tts_websocket", "minimax_tts"),
("minimax-duplex", "minimax_tts_websocket_duplex", "minimax_tts"),
("soul", "soul_tts_python", "soul_tts"),
("speko", "speko_tts2_python", "client"),
],
)
def test_hook_installs_on_transport_and_restores(
Expand Down Expand Up @@ -179,6 +186,10 @@ async def provider(socket):
async with websockets.serve(provider, "127.0.0.1", 0) as server:
port = server.sockets[0].getsockname()[1]
url = f"ws://127.0.0.1:{port}/test"
# Speko configures an origin; the client appends the stream path.
connect_url = (
f"{url}/v1/tts/stream" if extension == "speko_tts2_python" else url
)
model = "eleven_v4" if mode == "ttd" else "eleven_flash_v2_5"
# The installer only needs a dependency binding and config contract.
# No vendor package/SDK is required by these transport utility tests.
Expand All @@ -193,6 +204,7 @@ def __init__(self, **values):
self.url = values["url"]
self.uri = self.url
self.api_url = self.url
self.base_url = self.url
self.model_id = values["params"]["model_id"]
self.params = values["params"]

Expand All @@ -212,7 +224,7 @@ def update_params(self):
) as reference:
assert reference is not None
reference.begin_request(request)
async with module.websockets.connect(url) as socket:
async with module.websockets.connect(connect_url) as socket:
await socket.send(outgoing)
assert await socket.recv() == wire(
mode, request, audio=True, empty=True
Expand All @@ -235,6 +247,7 @@ def update_params(self):
("minimax", MiniMaxObserver),
("minimax-duplex", MiniMaxObserver),
("soul", SoulObserver),
("speko", SpekoObserver),
],
)
def test_hook_defers_decode_and_retains_first_audio_arrival(
Expand Down Expand Up @@ -293,3 +306,54 @@ def test_reading_previous_request_does_not_return_pending_request(monkeypatch):
observer.observe_send(wire("tts", "second"))
assert reference.elapsed_ms("first") == 20
assert reference.elapsed_ms("second") is None


def test_speko_hook_ignores_control_frames_and_router_events(monkeypatch):
now = [0]
monkeypatch.setattr("tests.ttfb_hooks.base.time.monotonic_ns", lambda: now[0])
reference = TTFBReference()
observer = SpekoObserver(reference)
reference.begin_request("request")
for frame in (
{"type": "session.configure", "audio": {"encoding": "pcm_s16le"}},
{"type": "input.append", "text": " \n\t"},
{"type": "input.commit"},
{"type": "input.cancel"},
{"type": "session.close"},
):
observer.observe_send(json.dumps(frame))
now[0] = 10_000_000
observer.observe_send(wire("speko", "request"))
observer.observe_send(json.dumps({"type": "input.commit"}))
for event in (
{"type": "session.ready", "request_id": "router"},
{"type": "utterance.started", "sequence": 1},
{"type": "usage.updated", "usage": {"characters": 6}},
):
now[0] += 5_000_000
observer.observe_receive(json.dumps(event))
now[0] = 40_000_000
observer.observe_receive(b"")
assert reference.elapsed_ms("request") is None
now[0] = 45_000_000
observer.observe_receive(wire("speko", "request", audio=True))
observer.observe_receive(json.dumps({"type": "utterance.done", "sequence": 1}))
assert reference.elapsed_ms("request") == 35


def test_speko_old_connection_audio_cannot_complete_new_request(monkeypatch):
now = [0]
monkeypatch.setattr("tests.ttfb_hooks.base.time.monotonic_ns", lambda: now[0])
reference = TTFBReference()
old_connection = SpekoObserver(reference)
reference.begin_request("old")
old_connection.observe_send(wire("speko", "old"))
reference.begin_request("new")
connection = SpekoObserver(reference)
connection.observe_send(wire("speko", "new"))
now[0] = 10_000_000
old_connection.observe_receive(wire("speko", "old", audio=True))
assert reference.elapsed_ms("new") is None
now[0] = 20_000_000
connection.observe_receive(wire("speko", "new", audio=True))
assert reference.elapsed_ms("new") == 20
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Speko Router input.append text and nonempty binary PCM audio frames."""

import json

from .base import ConnectionObserver


class SpekoObserver(ConnectionObserver):
def text_context(self, raw, request_id):
# configure/commit/cancel/close are control frames, never text.
if not isinstance(raw, str):
return None
message = json.loads(raw)
text = message.get("text")
if message.get("type") == "input.append" and isinstance(text, str) and text.strip():
# One utterance is in flight per Router session.
return request_id
return None

def audio_context(self, raw):
# Router events are JSON text frames; only binary frames carry PCM.
if isinstance(raw, bytes) and raw:
return self.active_context
return None
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from contextlib import contextmanager
from importlib import import_module
from threading import RLock
from urllib.parse import urlsplit, urlunsplit

from .timing_hooks.transport import ObservedWebSockets

Expand Down Expand Up @@ -93,6 +94,12 @@ def elapsed_ms(self, request_id):
"soul_tts",
"wss://qa-soul-llm.soulapp.cn/llm",
),
"speko_tts2_python": (
"speko",
"SpekoObserver",
"client",
"wss://router.speko.dev/v1/tts/stream",
),
}


Expand All @@ -103,9 +110,18 @@ def elapsed_ms(self, request_id):
"minimax_tts_websocket": ("MinimaxTTSWebsocketConfig", "url"),
"minimax_tts_websocket_duplex": ("MinimaxTTSWebsocketDuplexConfig", "url"),
"soul_tts_python": ("SoulTTSWebsocketConfig", "url"),
"speko_tts2_python": ("SpekoTTS2Config", "base_url"),
}


def speko_stream_url(base_url):
"""The Router's TTS WebSocket for a configured HTTP(S) or WS(S) origin."""
parsed = urlsplit(base_url)
scheme = {"https": "wss", "http": "ws"}.get(parsed.scheme, parsed.scheme)
path = parsed.path.rstrip("/") + "/v1/tts/stream"
return urlunsplit((scheme, parsed.netloc, path, "", ""))


def resolve_environment(value):
if isinstance(value, dict):
return {key: resolve_environment(item) for key, item in value.items()}
Expand All @@ -129,7 +145,10 @@ def effective_config(extension_name, config):
if extension_name == "elevenlabs_multiple_context_tts_python":
normalized.apply_defaults()
normalized.update_params()
return module, normalized, getattr(normalized, field)
url = getattr(normalized, field)
if extension_name == "speko_tts2_python":
url = speko_stream_url(url)
return module, normalized, url


@contextmanager
Expand Down
Loading
Loading