You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
412 lines
16 KiB
412 lines
16 KiB
#!/usr/bin/env python3
|
|
"""
|
|
Soniox Collaborative Bi-Directional Input Synchronization Gateway (v5.4)
|
|
- Ultra-low latency streaming speech recognition with pre-warmed Soniox pool.
|
|
- Strict artifact & lone-punctuation suppression (e.g. «, », ., quotes).
|
|
- Clean single-injection pipeline on speech finalization.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from collections import OrderedDict
|
|
import aiohttp
|
|
from aiohttp import web
|
|
import websockets
|
|
from websockets.protocol import State
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
logger = logging.getLogger("SyncGateway")
|
|
|
|
connected_mac_websockets = set()
|
|
connected_phone_websockets = set()
|
|
|
|
# Global Room State Snapshot (Single Source of Truth)
|
|
current_room_state = {
|
|
"type": "sync_state",
|
|
"source": "server_init",
|
|
"app": "Desktop",
|
|
"text": "",
|
|
"cursor": 0,
|
|
"selection": 0,
|
|
"revision": 0,
|
|
"timestamp": time.time()
|
|
}
|
|
room_lock = asyncio.Lock()
|
|
|
|
SONIOX_WS_URL = (
|
|
"wss://translate.compare.soniox.com/compare/api/compare-websocket"
|
|
"?language_hints=fa&language_hints=en&language_hints=ar"
|
|
"&enable_speaker_diarization=false&enable_language_identification=true"
|
|
"&enable_endpoint_detection=false&providers=soniox"
|
|
)
|
|
SONIOX_HEADERS = {
|
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
"Origin": "https://translate.compare.soniox.com",
|
|
}
|
|
|
|
def is_ws_open(ws) -> bool:
|
|
if ws is None:
|
|
return False
|
|
if hasattr(ws, 'closed'):
|
|
return not ws.closed
|
|
if hasattr(ws, 'state'):
|
|
return ws.state == State.OPEN
|
|
return True
|
|
|
|
class SonioxPool:
|
|
"""Pre-warms upstream WebSockets to Soniox for 0ms speech start delay."""
|
|
def __init__(self, size=4):
|
|
self._pool = asyncio.Queue(maxsize=size)
|
|
self._refilling = False
|
|
|
|
async def get_session(self):
|
|
while not self._pool.empty():
|
|
try:
|
|
ws = self._pool.get_nowait()
|
|
if is_ws_open(ws):
|
|
asyncio.create_task(self.refill())
|
|
return ws
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
|
|
logger.info("Pool empty, connecting fresh Soniox session...")
|
|
asyncio.create_task(self.refill())
|
|
return await self._create_ws()
|
|
|
|
async def _create_ws(self):
|
|
try:
|
|
return await websockets.connect(
|
|
SONIOX_WS_URL,
|
|
additional_headers=SONIOX_HEADERS,
|
|
open_timeout=3.5,
|
|
ping_interval=15,
|
|
)
|
|
except Exception as e:
|
|
logger.error("Failed to connect to upstream Soniox: %s", e)
|
|
return None
|
|
|
|
async def refill(self):
|
|
if self._refilling or self._pool.full():
|
|
return
|
|
self._refilling = True
|
|
try:
|
|
while not self._pool.full():
|
|
ws = await self._create_ws()
|
|
if ws and is_ws_open(ws):
|
|
await self._pool.put(ws)
|
|
else:
|
|
break
|
|
finally:
|
|
self._refilling = False
|
|
|
|
soniox_pool = SonioxPool()
|
|
|
|
def sanitize_text(text: str, allow_multiline: bool = True, preserve_trailing_space: bool = False) -> str:
|
|
if not text:
|
|
return ""
|
|
has_trailing_space = text.endswith(" ")
|
|
if allow_multiline:
|
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
lines = [re.sub(r"[ \t]+", " ", line) for line in normalized.split("\n")]
|
|
cleaned = "\n".join(lines).strip("\r\n")
|
|
else:
|
|
cleaned = re.sub(r"[\r\n\t]+", " ", text)
|
|
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
|
|
# Suppress lone punctuation artifacts (e.g. «, », ., ,, !, ?, etc.)
|
|
if re.fullmatch(r"[\s«»\.\,\،\؛\؟\!\?\:\;\-\–—\"\'\(\)\[\]\{\}]+", cleaned):
|
|
return ""
|
|
|
|
if (preserve_trailing_space or has_trailing_space) and not cleaned.endswith(" "):
|
|
cleaned += " "
|
|
|
|
return cleaned
|
|
|
|
def sanitize_and_flatten_text(text: str) -> str:
|
|
return sanitize_text(text, allow_multiline=False)
|
|
|
|
async def broadcast_state(payload_dict: dict, exclude_ws=None):
|
|
"""Broadcasts state snapshot to all connected clients (Mac and Phone) except sender."""
|
|
global current_room_state
|
|
async with room_lock:
|
|
current_room_state["revision"] += 1
|
|
payload_dict["revision"] = current_room_state["revision"]
|
|
payload_dict["timestamp"] = time.time()
|
|
|
|
# Update our cached authoritative state
|
|
current_room_state.update(payload_dict)
|
|
payload_str = json.dumps(payload_dict, ensure_ascii=False)
|
|
|
|
logger.info("📡 Broadcasting %s (len: %d) to %d Macs, %d Phones",
|
|
payload_dict.get("type"), len(payload_dict.get("text", "")),
|
|
len(connected_mac_websockets), len(connected_phone_websockets))
|
|
|
|
# 1. Send to Phone clients
|
|
dead_phones = set()
|
|
for ws in list(connected_phone_websockets):
|
|
if ws is exclude_ws:
|
|
continue
|
|
try:
|
|
if is_ws_open(ws):
|
|
await ws.send_str(payload_str)
|
|
else:
|
|
dead_phones.add(ws)
|
|
except Exception:
|
|
dead_phones.add(ws)
|
|
for dead in dead_phones:
|
|
connected_phone_websockets.discard(dead)
|
|
|
|
# 2. Send to Mac clients
|
|
dead_macs = set()
|
|
for ws in list(connected_mac_websockets):
|
|
if ws is exclude_ws:
|
|
continue
|
|
try:
|
|
if is_ws_open(ws):
|
|
await ws.send_str(payload_str)
|
|
else:
|
|
dead_macs.add(ws)
|
|
except Exception:
|
|
dead_macs.add(ws)
|
|
for dead in dead_macs:
|
|
connected_mac_websockets.discard(dead)
|
|
|
|
async def handle_phone_stream_ws(request):
|
|
"""Duplex WebSocket for Android client (Real-time Live Sync + Audio Dictation)."""
|
|
ws = web.WebSocketResponse(heartbeat=10.0, autoping=True)
|
|
await ws.prepare(request)
|
|
client_ip = request.remote
|
|
logger.info("📱 Android Client Connected: %s", client_ip)
|
|
connected_phone_websockets.add(ws)
|
|
|
|
# Immediately hydrate phone with full current state!
|
|
async with room_lock:
|
|
await ws.send_str(json.dumps(current_room_state, ensure_ascii=False))
|
|
|
|
active_soniox_ws = None
|
|
reader_task = None
|
|
stop_event = asyncio.Event()
|
|
current_session_id = ""
|
|
|
|
full_final_tokens = []
|
|
current_non_final = ""
|
|
|
|
async def soniox_reader(soniox_ws, sid):
|
|
nonlocal current_non_final
|
|
try:
|
|
async for s_msg in soniox_ws:
|
|
if not is_ws_open(ws):
|
|
break
|
|
try:
|
|
data = json.loads(s_msg)
|
|
if data.get("type") == "data" and "parts" in data:
|
|
new_finals = []
|
|
new_non_finals = []
|
|
got_fin = False
|
|
for p in data["parts"]:
|
|
if p.get("translation_status") == "translation":
|
|
continue
|
|
txt = p.get("text", "")
|
|
is_final = p.get("is_final", False)
|
|
if "<fin>" in txt:
|
|
got_fin = True
|
|
clean = txt.replace("<fin>", "")
|
|
if clean: new_finals.append(clean)
|
|
elif is_final:
|
|
if txt: new_finals.append(txt)
|
|
else:
|
|
if txt: new_non_finals.append(txt)
|
|
|
|
if new_finals:
|
|
full_final_tokens.extend(new_finals)
|
|
current_non_final = "".join(new_non_finals)
|
|
|
|
live_text = sanitize_and_flatten_text("".join(full_final_tokens) + current_non_final)
|
|
if live_text and is_ws_open(ws):
|
|
await ws.send_str(json.dumps({
|
|
"type": "live",
|
|
"session_id": sid,
|
|
"text": live_text
|
|
}))
|
|
|
|
if got_fin or data.get("session_ended") or data.get("type") == "session_done":
|
|
stop_event.set()
|
|
break
|
|
except Exception as e:
|
|
logger.warning("Soniox parse error: %s", e)
|
|
except Exception as e:
|
|
logger.warning("Soniox reader error: %s", e)
|
|
finally:
|
|
stop_event.set()
|
|
|
|
try:
|
|
async for msg in ws:
|
|
if msg.type == aiohttp.WSMsgType.BINARY:
|
|
# Live PCM audio chunk
|
|
if active_soniox_ws and is_ws_open(active_soniox_ws):
|
|
await active_soniox_ws.send(msg.data)
|
|
|
|
elif msg.type == aiohttp.WSMsgType.TEXT:
|
|
try:
|
|
data = json.loads(msg.data)
|
|
except Exception:
|
|
continue
|
|
|
|
msg_type = data.get("type") or data.get("action")
|
|
sid = data.get("session_id", f"sess_{int(time.time()*1000)}")
|
|
|
|
# ALL text / sync / insert operations must be broadcast to Mac!
|
|
if msg_type in ("sync_state", "insert_speech", "speech_insert", "phone_input_edit", "update_input", "paste"):
|
|
is_speech = msg_type in ("insert_speech", "speech_insert")
|
|
clean_text = sanitize_text(data.get("text", ""), allow_multiline=(not is_speech), preserve_trailing_space=is_speech)
|
|
if not clean_text and is_speech:
|
|
continue # Do not broadcast empty speech or lone quotes
|
|
data["text"] = clean_text
|
|
data["source"] = "android"
|
|
data["type"] = msg_type
|
|
await broadcast_state(data, exclude_ws=ws)
|
|
|
|
elif msg_type == "start":
|
|
current_session_id = sid
|
|
full_final_tokens.clear()
|
|
current_non_final = ""
|
|
stop_event.clear()
|
|
|
|
active_soniox_ws = await soniox_pool.get_session()
|
|
if not active_soniox_ws or not is_ws_open(active_soniox_ws):
|
|
await ws.send_str(json.dumps({"type": "error", "message": "Upstream Soniox unavailable"}))
|
|
continue
|
|
|
|
if reader_task and not reader_task.done():
|
|
reader_task.cancel()
|
|
reader_task = asyncio.create_task(soniox_reader(active_soniox_ws, current_session_id))
|
|
logger.info("🎙️ Started Live Speech Session: %s", current_session_id)
|
|
|
|
elif msg_type == "stop":
|
|
if active_soniox_ws and is_ws_open(active_soniox_ws):
|
|
await active_soniox_ws.send(json.dumps({"type": "finalize"}))
|
|
try:
|
|
await asyncio.wait_for(stop_event.wait(), timeout=0.35)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
|
|
raw_final = "".join(full_final_tokens) + current_non_final
|
|
clean_final = sanitize_and_flatten_text(raw_final)
|
|
logger.info("⚡ Session %s final text: '%s'", sid, clean_final)
|
|
|
|
# Return final speech text to phone
|
|
if is_ws_open(ws):
|
|
await ws.send_str(json.dumps({
|
|
"type": "final",
|
|
"session_id": sid,
|
|
"text": clean_final,
|
|
"cursor_pos": data.get("cursor_pos", -1)
|
|
}))
|
|
|
|
if reader_task and not reader_task.done():
|
|
reader_task.cancel()
|
|
if active_soniox_ws:
|
|
try:
|
|
await active_soniox_ws.close()
|
|
except Exception:
|
|
pass
|
|
active_soniox_ws = None
|
|
|
|
asyncio.create_task(soniox_pool.refill())
|
|
|
|
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR):
|
|
break
|
|
|
|
finally:
|
|
connected_phone_websockets.discard(ws)
|
|
if reader_task and not reader_task.done():
|
|
reader_task.cancel()
|
|
if active_soniox_ws:
|
|
try:
|
|
await active_soniox_ws.close()
|
|
except Exception:
|
|
pass
|
|
asyncio.create_task(soniox_pool.refill())
|
|
logger.info("📱 Android Client disconnected: %s", client_ip)
|
|
|
|
return ws
|
|
|
|
async def handle_mac_ws(request):
|
|
"""Persistent WebSocket for Mac Bridge (receives live Mac typing & pushes edits)."""
|
|
ws = web.WebSocketResponse(heartbeat=10.0, autoping=True)
|
|
await ws.prepare(request)
|
|
client_ip = request.remote
|
|
logger.info("🖥️ Mac client connected: %s", client_ip)
|
|
connected_mac_websockets.add(ws)
|
|
|
|
try:
|
|
await ws.send_str(json.dumps({"type": "welcome", "message": "Connected to Soniox Sync Gateway"}))
|
|
async for msg in ws:
|
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
try:
|
|
data = json.loads(msg.data)
|
|
msg_type = data.get("type")
|
|
|
|
if msg_type == "sync_state" or msg_type == "mac_input_state":
|
|
# Mac reports typing / cursor change: broadcast to all phones immediately!
|
|
data["source"] = "mac"
|
|
data["type"] = "sync_state"
|
|
await broadcast_state(data, exclude_ws=ws)
|
|
|
|
elif msg_type == "ping":
|
|
await ws.send_str(json.dumps({"type": "pong"}))
|
|
except Exception as e:
|
|
logger.warning("Mac message error: %s", e)
|
|
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR):
|
|
break
|
|
finally:
|
|
connected_mac_websockets.discard(ws)
|
|
logger.info("🖥️ Mac client disconnected: %s", client_ip)
|
|
|
|
return ws
|
|
|
|
async def handle_health(request):
|
|
async with room_lock:
|
|
state_copy = dict(current_room_state)
|
|
return web.json_response({
|
|
"status": "ok",
|
|
"service": "Soniox Collaborative Sync Gateway v5.4",
|
|
"connected_macs": len(connected_mac_websockets),
|
|
"connected_phones": len(connected_phone_websockets),
|
|
"current_app": state_copy.get("app", ""),
|
|
"current_revision": state_copy.get("revision", 0),
|
|
"current_text_len": len(state_copy.get("text", ""))
|
|
})
|
|
|
|
async def handle_paste(request):
|
|
try:
|
|
data = await request.json()
|
|
text = data.get("text", "")
|
|
cursor = data.get("cursor_pos") or data.get("cursor")
|
|
data["source"] = "http_post"
|
|
data["type"] = "update_input"
|
|
await broadcast_state(data)
|
|
return web.json_response({"status": "synced", "revision": current_room_state["revision"]})
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=400)
|
|
|
|
async def start_background_tasks(app):
|
|
asyncio.create_task(soniox_pool.refill())
|
|
|
|
def create_app():
|
|
app = web.Application(client_max_size=25 * 1024 * 1024)
|
|
app.on_startup.append(start_background_tasks)
|
|
app.router.add_get("/health", handle_health)
|
|
app.router.add_get("/status", handle_health)
|
|
app.router.add_post("/paste", handle_paste)
|
|
app.router.add_get("/ws/stream", handle_phone_stream_ws)
|
|
app.router.add_get("/ws/mac", handle_mac_ws)
|
|
return app
|
|
|
|
if __name__ == "__main__":
|
|
app = create_app()
|
|
web.run_app(app, host="0.0.0.0", port=8999)
|