mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
p2 (Pi Mobile on Chord v2) moves from pi-private into pi as one commit. Root scripts that loop all workspaces use --if-present; npm test, ./test.sh and the release script cover packages/* only (p2: cd apps/p2 && npm test); sync-versions also updates apps' workspace pins.
375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""p2 speech service: speech to text (Silero VAD + Parakeet TDT 0.6B v3) and text to speech, on CPU.
|
|
|
|
Every request needs `Authorization: Bearer $VOICE_TOKEN`. p2's voice plugin is the only client: browsers reach this
|
|
service through p2, which signs them in.
|
|
|
|
GET /health -> {"ok": true} (no auth)
|
|
GET /voices -> {"engines": [{"id", "name", "streaming", "sampleRate", "voices": [{"id", "name"}]}],
|
|
"default": {"engine", "voice"}}
|
|
Voice ids are "<engine>-<voice>" (for example "supertonic-F1", "pocket-alba", "kokoro-af_heart")
|
|
and match [A-Za-z0-9_-]{1,32}; each engine lists its default voice first. "streaming" is true for
|
|
engines that stream while they generate; the others deliver one chunk per sentence.
|
|
POST /speak {"text", "engine"?, "voice"?, "speed"?} -> audio/wav (16-bit mono) of the whole text
|
|
voice: a voice id from /voices (it names the engine; engine, if given, must match). Without voice:
|
|
the engine's default voice; without either: the service default. speed: 0.5-2.0 (default 1.05 for
|
|
Supertonic, 1.0 for Kokoro), ignored by Pocket TTS.
|
|
WS /speak/stream (primary text-to-speech interface)
|
|
client -> server (text): {"type": "speak", "id", "text", "engine"?, "voice"?, "speed"?}
|
|
{"type": "cancel", "id"?} (no id: cancel everything)
|
|
server -> client (text): {"type": "ready"}
|
|
{"type": "start", "id", "engine", "voice", "sampleRate"}
|
|
{"type": "done", "id", "cancelled", "firstChunkMs", "ms", "audioMs"}
|
|
{"type": "error", "id"?, "error"}
|
|
server -> client (binary): one audio chunk = 1 byte n (id length), n bytes id (ASCII), then
|
|
16-bit little-endian mono PCM at the request's sampleRate. The PCM may
|
|
start at an odd offset: copy it before viewing it as Int16Array.
|
|
id: [A-Za-z0-9_-]{1,64}, chosen by the client, unique among its pending requests. engine, voice
|
|
and speed as for POST /speak. Requests run in order, one at a time per socket, each as fast as
|
|
the engine generates: start, its chunks, then done, or error instead of done if synthesis fails.
|
|
A cancelled request (queued or running) gets done with cancelled: true and no further chunks.
|
|
Streaming engines send ~80 ms chunks while generating; the others one chunk per sentence.
|
|
WS /listen binary frames: 16 kHz mono PCM16LE microphone audio; text frames: {"type": "reset"}
|
|
replies: {"type": "ready"}, {"type": "speech_start"}, {"type": "speech_end"},
|
|
{"type": "final", "text", "ms"}
|
|
"""
|
|
|
|
import asyncio
|
|
import hmac
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
import wave
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
|
|
import numpy as np
|
|
import sherpa_onnx
|
|
from aiohttp import WSMsgType, web
|
|
|
|
from engines import VOICE_PATTERN, Engine, load_engines
|
|
from segmenter import Segmenter
|
|
|
|
MODELS = os.environ.get("VOICE_MODELS", "/models")
|
|
TOKEN = os.environ["VOICE_TOKEN"]
|
|
SAMPLE_RATE = 16000
|
|
MAX_TEXT = 2000
|
|
ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
|
|
parakeet = f"{MODELS}/parakeet"
|
|
recognizer = sherpa_onnx.OfflineRecognizer.from_transducer(
|
|
encoder=f"{parakeet}/encoder.int8.onnx",
|
|
decoder=f"{parakeet}/decoder.int8.onnx",
|
|
joiner=f"{parakeet}/joiner.int8.onnx",
|
|
tokens=f"{parakeet}/tokens.txt",
|
|
num_threads=4,
|
|
model_type="nemo_transducer",
|
|
)
|
|
engines: dict[str, Engine] = load_engines()
|
|
DEFAULT_ENGINE = os.environ.get("VOICE_DEFAULT_ENGINE", "supertonic")
|
|
if DEFAULT_ENGINE not in engines:
|
|
DEFAULT_ENGINE = next(iter(engines))
|
|
DEFAULT_VOICE = os.environ.get("VOICE_DEFAULT", engines[DEFAULT_ENGINE].default_voice)
|
|
if DEFAULT_VOICE not in engines[DEFAULT_ENGINE].voices:
|
|
DEFAULT_VOICE = engines[DEFAULT_ENGINE].default_voice
|
|
# Blocking work runs off the event loop: decoding in its own pool, so speech never waits behind synthesis.
|
|
stt_pool = ThreadPoolExecutor(max_workers=2)
|
|
tts_pool = ThreadPoolExecutor(max_workers=4)
|
|
|
|
|
|
def vad_config() -> sherpa_onnx.VadModelConfig:
|
|
config = sherpa_onnx.VadModelConfig()
|
|
config.silero_vad.model = f"{MODELS}/silero_vad.onnx"
|
|
config.silero_vad.threshold = float(os.environ.get("VOICE_VAD_THRESHOLD", "0.5"))
|
|
config.silero_vad.min_silence_duration = float(os.environ.get("VOICE_VAD_SILENCE", "1.2"))
|
|
config.silero_vad.min_speech_duration = 0.25
|
|
config.silero_vad.max_speech_duration = 30
|
|
config.silero_vad.window_size = 512
|
|
config.sample_rate = SAMPLE_RATE
|
|
config.num_threads = 1
|
|
return config
|
|
|
|
|
|
VAD_PREROLL_MS = float(os.environ.get("VOICE_VAD_PREROLL_MS", "750"))
|
|
|
|
|
|
class BadRequest(Exception):
|
|
pass
|
|
|
|
|
|
def authorized(request: web.Request) -> bool:
|
|
header = request.headers.get("Authorization", "")
|
|
return hmac.compare_digest(header.encode(), f"Bearer {TOKEN}".encode())
|
|
|
|
|
|
def resolve(engine_id: object, voice: object) -> tuple[Engine, str]:
|
|
"""Maps {engine?, voice?} to an engine and one of its voice names. voice is a voice id from /voices."""
|
|
engine_id = str(engine_id) if engine_id else None
|
|
if voice:
|
|
voice = str(voice)
|
|
prefix, _, name = voice.partition("-")
|
|
if not VOICE_PATTERN.match(voice) or prefix not in engines or name not in engines[prefix].voices:
|
|
raise BadRequest(f"unknown voice {voice}")
|
|
if engine_id and engine_id != prefix:
|
|
raise BadRequest("voice does not belong to engine")
|
|
return engines[prefix], name
|
|
engine = engines.get(engine_id or DEFAULT_ENGINE)
|
|
if engine is None:
|
|
raise BadRequest(f"unknown engine {engine_id}")
|
|
return engine, (DEFAULT_VOICE if engine.id == DEFAULT_ENGINE else engine.default_voice)
|
|
|
|
|
|
def parse_request(body: object) -> tuple[Engine, str, str, float | None]:
|
|
if not isinstance(body, dict) or not isinstance(body.get("text"), str):
|
|
raise BadRequest("expected {text, voice?, engine?, speed?}")
|
|
text = body["text"].strip()
|
|
if not text or len(text) > MAX_TEXT:
|
|
raise BadRequest("invalid text")
|
|
engine, voice = resolve(body.get("engine"), body.get("voice"))
|
|
speed = body.get("speed")
|
|
if speed is not None:
|
|
try:
|
|
speed = float(speed)
|
|
except (TypeError, ValueError):
|
|
raise BadRequest("invalid speed") from None
|
|
if not 0.5 <= speed <= 2.0:
|
|
raise BadRequest("invalid speed")
|
|
return engine, voice, text, speed
|
|
|
|
|
|
def pcm16(audio: np.ndarray) -> bytes:
|
|
return (np.clip(audio, -1.0, 1.0) * 32767).astype("<i2").tobytes()
|
|
|
|
|
|
def transcribe(samples: np.ndarray) -> str:
|
|
stream = recognizer.create_stream()
|
|
stream.accept_waveform(SAMPLE_RATE, samples)
|
|
recognizer.decode_stream(stream)
|
|
return stream.result.text.strip()
|
|
|
|
|
|
@web.middleware
|
|
async def auth(request: web.Request, handler):
|
|
if request.path != "/health" and not authorized(request):
|
|
return web.json_response({"error": "unauthorized"}, status=401)
|
|
return await handler(request)
|
|
|
|
|
|
async def health(_request: web.Request) -> web.Response:
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
async def voices(_request: web.Request) -> web.Response:
|
|
listed = []
|
|
for engine in engines.values():
|
|
default = DEFAULT_VOICE if engine.id == DEFAULT_ENGINE else engine.default_voice
|
|
names = [default] + [name for name in engine.voices if name != default] # the engine's default first
|
|
listed.append({
|
|
"id": engine.id,
|
|
"name": engine.name,
|
|
"streaming": engine.streaming,
|
|
"sampleRate": engine.sample_rate,
|
|
"voices": [{"id": f"{engine.id}-{name}", "name": engine.voices[name]} for name in names],
|
|
})
|
|
return web.json_response(
|
|
{"engines": listed, "default": {"engine": DEFAULT_ENGINE, "voice": f"{DEFAULT_ENGINE}-{DEFAULT_VOICE}"}}
|
|
)
|
|
|
|
|
|
async def speak(request: web.Request) -> web.Response:
|
|
try:
|
|
engine, voice, text, speed = parse_request(await request.json())
|
|
except (BadRequest, ValueError) as error:
|
|
return web.json_response({"error": str(error) or "invalid request"}, status=400)
|
|
started = time.monotonic()
|
|
|
|
def render() -> bytes:
|
|
return b"".join(pcm16(chunk) for chunk in engine.stream(text, voice, speed, threading.Event()))
|
|
|
|
pcm = await asyncio.get_running_loop().run_in_executor(tts_pool, render)
|
|
out = io.BytesIO()
|
|
with wave.open(out, "wb") as file:
|
|
file.setnchannels(1)
|
|
file.setsampwidth(2)
|
|
file.setframerate(engine.sample_rate)
|
|
file.writeframes(pcm)
|
|
return web.Response(
|
|
body=out.getvalue(),
|
|
content_type="audio/wav",
|
|
headers={
|
|
"X-Synthesis-Ms": str(round((time.monotonic() - started) * 1000)),
|
|
"X-Voice": f"{engine.id}-{voice}",
|
|
},
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SpeakJob:
|
|
id: str
|
|
engine: Engine
|
|
voice: str
|
|
text: str
|
|
speed: float | None
|
|
stop: threading.Event = field(default_factory=threading.Event)
|
|
|
|
|
|
async def speak_stream(request: web.Request) -> web.WebSocketResponse:
|
|
socket = web.WebSocketResponse(heartbeat=20, max_msg_size=64 * 1024)
|
|
await socket.prepare(request)
|
|
loop = asyncio.get_running_loop()
|
|
jobs: asyncio.Queue[SpeakJob] = asyncio.Queue()
|
|
queued: dict[str, SpeakJob] = {} # waiting and running jobs by id
|
|
current: SpeakJob | None = None
|
|
|
|
async def send_json(message: dict) -> None:
|
|
if not socket.closed:
|
|
await socket.send_json(message)
|
|
|
|
async def run(job: SpeakJob) -> None:
|
|
chunks: asyncio.Queue = asyncio.Queue()
|
|
done = object()
|
|
|
|
def produce() -> None:
|
|
try:
|
|
for chunk in job.engine.stream(job.text, job.voice, job.speed, job.stop):
|
|
loop.call_soon_threadsafe(chunks.put_nowait, chunk)
|
|
except Exception as error: # reported to the client below
|
|
loop.call_soon_threadsafe(chunks.put_nowait, error)
|
|
loop.call_soon_threadsafe(chunks.put_nowait, done)
|
|
|
|
started = time.monotonic()
|
|
first_ms = None
|
|
samples = 0
|
|
header = bytes([len(job.id)]) + job.id.encode()
|
|
await send_json({"type": "start", "id": job.id, "engine": job.engine.id,
|
|
"voice": f"{job.engine.id}-{job.voice}", "sampleRate": job.engine.sample_rate})
|
|
loop.run_in_executor(tts_pool, produce)
|
|
failure = None
|
|
while (item := await chunks.get()) is not done:
|
|
if isinstance(item, Exception):
|
|
failure = str(item) or type(item).__name__
|
|
continue
|
|
if job.stop.is_set() or socket.closed:
|
|
continue
|
|
if first_ms is None:
|
|
first_ms = round((time.monotonic() - started) * 1000)
|
|
samples += len(item)
|
|
await socket.send_bytes(header + pcm16(item))
|
|
if failure is not None:
|
|
await send_json({"type": "error", "id": job.id, "error": failure})
|
|
return
|
|
await send_json({"type": "done", "id": job.id, "cancelled": job.stop.is_set(), "firstChunkMs": first_ms,
|
|
"ms": round((time.monotonic() - started) * 1000),
|
|
"audioMs": round(samples * 1000 / job.engine.sample_rate)})
|
|
|
|
async def worker() -> None:
|
|
nonlocal current
|
|
while True:
|
|
job = await jobs.get()
|
|
if job.stop.is_set():
|
|
await send_json({"type": "done", "id": job.id, "cancelled": True, "firstChunkMs": None,
|
|
"ms": 0, "audioMs": 0})
|
|
else:
|
|
current = job
|
|
await run(job)
|
|
current = None
|
|
if queued.get(job.id) is job:
|
|
del queued[job.id]
|
|
|
|
runner = asyncio.ensure_future(worker())
|
|
await send_json({"type": "ready"})
|
|
try:
|
|
async for message in socket:
|
|
if message.type != WSMsgType.TEXT:
|
|
if message.type == WSMsgType.BINARY:
|
|
await send_json({"type": "error", "error": "expected text frames"})
|
|
continue
|
|
break
|
|
try:
|
|
body = json.loads(message.data)
|
|
except ValueError:
|
|
await send_json({"type": "error", "error": "invalid JSON"})
|
|
continue
|
|
kind = body.get("type") if isinstance(body, dict) else None
|
|
if kind == "cancel":
|
|
target = body.get("id")
|
|
for job in list(queued.values()):
|
|
if target is None or job.id == target:
|
|
job.stop.set()
|
|
continue
|
|
if kind != "speak":
|
|
await send_json({"type": "error", "error": "expected type speak or cancel"})
|
|
continue
|
|
job_id = body.get("id")
|
|
if not isinstance(job_id, str) or not ID_PATTERN.match(job_id):
|
|
await send_json({"type": "error", "error": "invalid id"})
|
|
continue
|
|
if job_id in queued:
|
|
await send_json({"type": "error", "id": job_id, "error": "duplicate id"})
|
|
continue
|
|
try:
|
|
engine, voice, text, speed = parse_request(body)
|
|
except BadRequest as error:
|
|
await send_json({"type": "error", "id": job_id, "error": str(error)})
|
|
continue
|
|
job = SpeakJob(job_id, engine, voice, text, speed)
|
|
queued[job_id] = job
|
|
jobs.put_nowait(job)
|
|
finally:
|
|
for job in queued.values():
|
|
job.stop.set()
|
|
runner.cancel()
|
|
return socket
|
|
|
|
|
|
async def listen(request: web.Request) -> web.WebSocketResponse:
|
|
socket = web.WebSocketResponse(heartbeat=20, max_msg_size=1 << 20)
|
|
await socket.prepare(request)
|
|
segmenter = Segmenter(vad_config(), VAD_PREROLL_MS)
|
|
loop = asyncio.get_running_loop()
|
|
# Finals go out in utterance order even when a later one decodes faster.
|
|
decoding: asyncio.Future | None = None
|
|
|
|
async def decode(samples: np.ndarray, previous: asyncio.Future | None) -> None:
|
|
started = time.monotonic()
|
|
text = await loop.run_in_executor(stt_pool, transcribe, samples)
|
|
if previous is not None:
|
|
await previous
|
|
if not socket.closed:
|
|
ms = round((time.monotonic() - started) * 1000)
|
|
await socket.send_json({"type": "final", "text": text, "ms": ms})
|
|
|
|
await socket.send_json({"type": "ready", "sampleRate": SAMPLE_RATE})
|
|
async for message in socket:
|
|
if message.type == WSMsgType.TEXT:
|
|
if json.loads(message.data).get("type") == "reset":
|
|
speaking = segmenter.speaking
|
|
segmenter.reset()
|
|
if speaking:
|
|
await socket.send_json({"type": "speech_end"})
|
|
continue
|
|
if message.type != WSMsgType.BINARY:
|
|
break
|
|
samples = np.frombuffer(message.data, dtype="<i2").astype(np.float32) / 32768.0
|
|
for event, utterance in segmenter.accept(samples):
|
|
await socket.send_json({"type": event})
|
|
if utterance is not None:
|
|
decoding = asyncio.ensure_future(decode(utterance, decoding))
|
|
return socket
|
|
|
|
|
|
app = web.Application(middlewares=[auth], client_max_size=64 * 1024)
|
|
app.add_routes(
|
|
[
|
|
web.get("/health", health),
|
|
web.get("/voices", voices),
|
|
web.post("/speak", speak),
|
|
web.get("/speak/stream", speak_stream),
|
|
web.get("/listen", listen),
|
|
]
|
|
)
|
|
|
|
if __name__ == "__main__":
|
|
web.run_app(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
|