feat(dictate): use local socket-activated Parakeet
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""GPU-isolated local dictate service."""
|
||||
|
||||
from .protocol import PROTOCOL_VERSION
|
||||
|
||||
__all__ = ["PROTOCOL_VERSION"]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""In-memory PCM validation and conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
import struct
|
||||
|
||||
MAX_PCM_BYTES = 5 * 60 * 16_000 * 2
|
||||
|
||||
|
||||
class AudioError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def pcm_s16le_to_floats(pcm: bytes, *, max_bytes: int = MAX_PCM_BYTES) -> array[float]:
|
||||
"""Convert mono little-endian signed-16 PCM to a bounded float32 array."""
|
||||
|
||||
if len(pcm) > max_bytes:
|
||||
raise AudioError("PCM exceeds the five-minute duration limit")
|
||||
if len(pcm) % 2:
|
||||
raise AudioError("PCM byte length must be even")
|
||||
return array("f", (sample[0] / 32768.0 for sample in struct.iter_unpack("<h", pcm)))
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Lazy, offline NeMo adapter for an explicit local Parakeet archive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
MAX_TERMS_FILE_BYTES = 64 * 1024
|
||||
MAX_CODING_TERMS = 512
|
||||
MAX_TERM_BYTES = 128
|
||||
PINNED_MODEL_SHA256 = "3cbdc85877e668ca7b82d0d56770eb1fac76691f55d6b97545e8d61ca588d10d"
|
||||
|
||||
|
||||
class TranscriptionModel(Protocol):
|
||||
def load(self) -> None: ...
|
||||
def transcribe(self, samples: Sequence[float]) -> str: ...
|
||||
|
||||
|
||||
def load_coding_terms(path: str | os.PathLike[str]) -> list[str]:
|
||||
"""Load a bounded host-owned phrase list, ignoring comments and duplicates."""
|
||||
|
||||
terms_path = Path(path).expanduser()
|
||||
if not terms_path.is_file():
|
||||
raise ValueError("PI_DICTATE_TERMS_PATH must name an existing local dictionary")
|
||||
if terms_path.stat().st_size > MAX_TERMS_FILE_BYTES:
|
||||
raise ValueError(f"coding dictionary exceeds {MAX_TERMS_FILE_BYTES} bytes")
|
||||
|
||||
terms: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_line in terms_path.read_text(encoding="utf-8").splitlines():
|
||||
term = raw_line.strip()
|
||||
if not term or term.startswith("#"):
|
||||
continue
|
||||
if len(term.encode("utf-8")) > MAX_TERM_BYTES:
|
||||
raise ValueError(f"coding dictionary terms may not exceed {MAX_TERM_BYTES} UTF-8 bytes")
|
||||
if term not in seen:
|
||||
terms.append(term)
|
||||
seen.add(term)
|
||||
if len(terms) > MAX_CODING_TERMS:
|
||||
raise ValueError(f"coding dictionary may not exceed {MAX_CODING_TERMS} terms")
|
||||
return terms
|
||||
|
||||
|
||||
class NemoParakeetModel:
|
||||
"""Load NeMo only when requested so core tests require neither CUDA nor NeMo."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str | os.PathLike[str],
|
||||
*,
|
||||
terms_path: str | os.PathLike[str] | None = None,
|
||||
boost_alpha: float = 1.0,
|
||||
expected_sha256: str = PINNED_MODEL_SHA256,
|
||||
) -> None:
|
||||
path = Path(model_path).expanduser()
|
||||
if path.suffix != ".nemo" or not path.is_file():
|
||||
raise ValueError("PI_DICTATE_MODEL_PATH must name an existing local .nemo file")
|
||||
if boost_alpha <= 0:
|
||||
raise ValueError("PI_DICTATE_BOOST_ALPHA must be positive")
|
||||
self._model_path = path.resolve()
|
||||
self._terms = load_coding_terms(terms_path) if terms_path is not None else []
|
||||
self._boost_alpha = boost_alpha
|
||||
self._expected_sha256 = expected_sha256
|
||||
self._model: object | None = None
|
||||
self._numpy: object | None = None
|
||||
|
||||
@property
|
||||
def loaded(self) -> bool:
|
||||
return self._model is not None
|
||||
|
||||
def load(self) -> None:
|
||||
if self._model is not None:
|
||||
return
|
||||
with self._model_path.open("rb") as model_file:
|
||||
actual_sha256 = hashlib.file_digest(model_file, "sha256").hexdigest()
|
||||
if actual_sha256 != self._expected_sha256:
|
||||
raise ValueError(
|
||||
"model checksum mismatch: expected the pinned Parakeet revision"
|
||||
)
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
os.environ["NEMO_OFFLINE"] = "1"
|
||||
|
||||
import numpy
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
from omegaconf import open_dict
|
||||
|
||||
model = ASRModel.restore_from(
|
||||
restore_path=str(self._model_path),
|
||||
map_location="cuda",
|
||||
)
|
||||
model.to("cuda")
|
||||
model.eval()
|
||||
if self._terms:
|
||||
decoding = deepcopy(model.cfg.decoding)
|
||||
with open_dict(decoding):
|
||||
greedy = decoding["greedy"]
|
||||
greedy["boosting_tree"] = {
|
||||
"key_phrases_list": list(self._terms),
|
||||
"context_score": 1.0,
|
||||
"depth_scaling": 2.0,
|
||||
"unk_score": 0.0,
|
||||
"final_eos_score": 1.0,
|
||||
"score_per_phrase": 0.0,
|
||||
"source_lang": "en",
|
||||
"use_triton": True,
|
||||
"uniform_weights": False,
|
||||
"use_bpe_dropout": False,
|
||||
}
|
||||
greedy["boosting_tree_alpha"] = self._boost_alpha
|
||||
model.change_decoding_strategy(decoding, verbose=False)
|
||||
self._numpy = numpy
|
||||
self._model = model
|
||||
|
||||
def transcribe(self, samples: Sequence[float]) -> str:
|
||||
if self._model is None or self._numpy is None:
|
||||
raise RuntimeError("model is not loaded")
|
||||
numpy = self._numpy
|
||||
audio = numpy.asarray(samples, dtype=numpy.float32) # type: ignore[attr-defined]
|
||||
output: Sequence[object] = self._model.transcribe( # type: ignore[attr-defined]
|
||||
audio=[audio],
|
||||
batch_size=1,
|
||||
num_workers=0,
|
||||
verbose=False,
|
||||
)
|
||||
if not output:
|
||||
return ""
|
||||
hypothesis = output[0]
|
||||
if isinstance(hypothesis, str):
|
||||
return hypothesis
|
||||
text = getattr(hypothesis, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
raise RuntimeError("NeMo returned an unsupported transcription result")
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Bounded newline-delimited JSON protocol primitives for dictate protocol v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
PROTOCOL_VERSION = 1
|
||||
MAX_FRAME_BYTES = 64 * 1024
|
||||
MAX_TRANSCRIPT_BYTES = 32 * 1024
|
||||
MAX_ERROR_DETAIL_BYTES = 2 * 1024
|
||||
MAX_ID_BYTES = 128
|
||||
|
||||
|
||||
class ProtocolError(ValueError):
|
||||
"""A stable protocol error suitable for returning to a client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
detail: str,
|
||||
*,
|
||||
close_connection: bool = False,
|
||||
request_id: str = "",
|
||||
generation_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(detail)
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
self.close_connection = close_connection
|
||||
self.request_id = request_id
|
||||
self.generation_id = generation_id
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Request:
|
||||
version: int
|
||||
type: str
|
||||
request_id: str
|
||||
generation_id: str | None = None
|
||||
|
||||
|
||||
_REQUEST_FIELDS = {
|
||||
"hello": frozenset({"version", "type", "requestId"}),
|
||||
"ping": frozenset({"version", "type", "requestId"}),
|
||||
"start": frozenset({"version", "type", "requestId", "generationId"}),
|
||||
"stop": frozenset({"version", "type", "requestId", "generationId"}),
|
||||
"cancel": frozenset({"version", "type", "requestId", "generationId"}),
|
||||
}
|
||||
|
||||
|
||||
def _bounded_utf8(value: str, limit: int) -> str:
|
||||
raw = value.encode("utf-8", errors="replace")
|
||||
if len(raw) <= limit:
|
||||
return value
|
||||
return raw[:limit].decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def _validate_id(value: Any, field: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ProtocolError("invalid_request", f"{field} must be a non-empty string")
|
||||
try:
|
||||
encoded = value.encode("utf-8")
|
||||
except UnicodeEncodeError as error:
|
||||
raise ProtocolError("invalid_request", f"{field} must contain valid Unicode") from error
|
||||
if len(encoded) > MAX_ID_BYTES:
|
||||
raise ProtocolError("invalid_request", f"{field} exceeds {MAX_ID_BYTES} UTF-8 bytes")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_id(value: Any) -> str:
|
||||
try:
|
||||
return _validate_id(value, "identifier")
|
||||
except ProtocolError:
|
||||
return ""
|
||||
|
||||
|
||||
def parse_request(frame: Mapping[str, Any]) -> Request:
|
||||
"""Validate a decoded request and reject all fields outside its exact schema."""
|
||||
|
||||
if not isinstance(frame, Mapping):
|
||||
raise ProtocolError("invalid_request", "request must be a JSON object")
|
||||
|
||||
request_id_value = frame.get("requestId")
|
||||
request_id = _safe_id(request_id_value)
|
||||
version = frame.get("version")
|
||||
if not isinstance(version, int) or isinstance(version, bool):
|
||||
raise ProtocolError("invalid_request", "version must be an integer", request_id=request_id)
|
||||
if version != PROTOCOL_VERSION:
|
||||
raise ProtocolError(
|
||||
"unsupported_version",
|
||||
f"protocol version {version} is unsupported",
|
||||
close_connection=True,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
request_type = frame.get("type")
|
||||
if not isinstance(request_type, str) or request_type not in _REQUEST_FIELDS:
|
||||
raise ProtocolError("invalid_request", "unknown request type", request_id=request_id)
|
||||
|
||||
expected_fields = _REQUEST_FIELDS[request_type]
|
||||
if frozenset(frame) != expected_fields:
|
||||
raise ProtocolError(
|
||||
"invalid_request",
|
||||
f"{request_type} requires exactly: {', '.join(sorted(expected_fields))}",
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
request_id = _validate_id(request_id_value, "requestId")
|
||||
generation_id = None
|
||||
if "generationId" in expected_fields:
|
||||
generation_id = _validate_id(frame.get("generationId"), "generationId")
|
||||
|
||||
return Request(version, request_type, request_id, generation_id)
|
||||
|
||||
|
||||
class FrameDecoder:
|
||||
"""Incrementally decode bounded newline-delimited JSON objects."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._failed = False
|
||||
|
||||
def feed(self, data: bytes) -> list[dict[str, Any]]:
|
||||
if self._failed:
|
||||
raise ProtocolError("connection_closed", "decoder is closed", close_connection=True)
|
||||
self._buffer.extend(data)
|
||||
frames: list[dict[str, Any]] = []
|
||||
|
||||
while True:
|
||||
newline = self._buffer.find(b"\n")
|
||||
if newline < 0:
|
||||
if len(self._buffer) > MAX_FRAME_BYTES:
|
||||
self._fail("frame_too_large", f"frame exceeds {MAX_FRAME_BYTES} bytes")
|
||||
return frames
|
||||
if newline > MAX_FRAME_BYTES:
|
||||
self._fail("frame_too_large", f"frame exceeds {MAX_FRAME_BYTES} bytes")
|
||||
|
||||
raw = bytes(self._buffer[:newline])
|
||||
del self._buffer[: newline + 1]
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
self._fail("malformed_json", "frame contains malformed JSON")
|
||||
if not isinstance(value, dict):
|
||||
self._fail("malformed_json", "frame must contain a JSON object")
|
||||
frames.append(value)
|
||||
|
||||
def _fail(self, code: str, detail: str) -> None:
|
||||
self._failed = True
|
||||
self._buffer.clear()
|
||||
raise ProtocolError(code, detail, close_connection=True)
|
||||
|
||||
|
||||
def encode_frame(frame: Mapping[str, Any]) -> bytes:
|
||||
"""Encode one bounded service frame, applying the error-detail output cap."""
|
||||
|
||||
payload = dict(frame)
|
||||
if payload.get("type") == "result":
|
||||
transcript = payload.get("transcript")
|
||||
if not isinstance(transcript, str):
|
||||
raise ProtocolError("invalid_event", "result transcript must be a string")
|
||||
if len(transcript.encode("utf-8")) > MAX_TRANSCRIPT_BYTES:
|
||||
raise ProtocolError("transcript_too_large", "transcript exceeds output limit")
|
||||
if payload.get("type") == "error":
|
||||
detail = payload.get("detail", "")
|
||||
if not isinstance(detail, str):
|
||||
detail = str(detail)
|
||||
payload["detail"] = _bounded_utf8(detail, MAX_ERROR_DETAIL_BYTES)
|
||||
payload["requestId"] = _safe_id(payload.get("requestId"))
|
||||
generation_id = _safe_id(payload.get("generationId"))
|
||||
if generation_id:
|
||||
payload["generationId"] = generation_id
|
||||
else:
|
||||
payload.pop("generationId", None)
|
||||
|
||||
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
if len(encoded) > MAX_FRAME_BYTES:
|
||||
raise ProtocolError("frame_too_large", "encoded frame exceeds output limit")
|
||||
return encoded + b"\n"
|
||||
|
||||
|
||||
def error_event(error: ProtocolError) -> dict[str, Any]:
|
||||
event: dict[str, Any] = {
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "error",
|
||||
"requestId": _safe_id(error.request_id),
|
||||
"code": error.code,
|
||||
"detail": _bounded_utf8(error.detail, MAX_ERROR_DETAIL_BYTES),
|
||||
}
|
||||
generation_id = _safe_id(error.generation_id)
|
||||
if generation_id:
|
||||
event["generationId"] = generation_id
|
||||
return event
|
||||
@@ -0,0 +1,297 @@
|
||||
"""PipeWire recorder adapter with bounded in-memory output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import BinaryIO, Callable, Protocol
|
||||
|
||||
from .audio import MAX_PCM_BYTES
|
||||
|
||||
PW_RECORD_COMMAND = [
|
||||
"pw-record",
|
||||
"--raw",
|
||||
"--format",
|
||||
"s16",
|
||||
"--rate",
|
||||
"16000",
|
||||
"--channels",
|
||||
"1",
|
||||
"-",
|
||||
]
|
||||
MAX_STDERR_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class Process(Protocol):
|
||||
pid: int
|
||||
stdout: BinaryIO
|
||||
stderr: BinaryIO
|
||||
returncode: int | None
|
||||
|
||||
def poll(self) -> int | None: ...
|
||||
def terminate(self) -> None: ...
|
||||
def kill(self) -> None: ...
|
||||
def wait(self, timeout: float | None = None) -> int: ...
|
||||
|
||||
|
||||
ProcessFactory = Callable[..., Process]
|
||||
ProcessGroupSignal = Callable[[int, int], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Capture:
|
||||
pcm: bytes
|
||||
returncode: int
|
||||
signal: int | None
|
||||
stderr_tail: bytes
|
||||
|
||||
|
||||
class RecorderError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
detail: str,
|
||||
*,
|
||||
returncode: int | None = None,
|
||||
signal_number: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(detail)
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
self.returncode = returncode
|
||||
self.signal_number = signal_number
|
||||
|
||||
|
||||
class PwRecorder:
|
||||
"""Capture raw PCM while continuously draining both child pipes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
process_factory: ProcessFactory = subprocess.Popen,
|
||||
max_bytes: int = MAX_PCM_BYTES,
|
||||
stderr_limit: int = MAX_STDERR_BYTES,
|
||||
stop_timeout: float = 3.0,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
process_group_signal: ProcessGroupSignal = os.killpg,
|
||||
) -> None:
|
||||
self._process_factory = process_factory
|
||||
self._max_bytes = max_bytes
|
||||
self._stderr_limit = stderr_limit
|
||||
self._stop_timeout = stop_timeout
|
||||
self._clock = clock
|
||||
self._process_group_signal = process_group_signal
|
||||
self._process: Process | None = None
|
||||
self._stdout_thread: threading.Thread | None = None
|
||||
self._stderr_thread: threading.Thread | None = None
|
||||
self._pcm = bytearray()
|
||||
self._stderr = bytearray()
|
||||
self._lock = threading.Lock()
|
||||
self._intentional = False
|
||||
self._duration_limited = False
|
||||
self._duration_stop_at: float | None = None
|
||||
self._reported = False
|
||||
|
||||
@property
|
||||
def buffered_bytes(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._pcm)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._process is not None:
|
||||
raise RecorderError("invalid_state", "recorder is already started")
|
||||
try:
|
||||
process = self._process_factory(
|
||||
list(PW_RECORD_COMMAND),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
raise RecorderError("recorder_spawn", f"could not start pw-record: {error}") from error
|
||||
if process.stdout is None or process.stderr is None:
|
||||
raise RecorderError("recorder_spawn", "pw-record pipes were not created")
|
||||
self._process = process
|
||||
self._stdout_thread = threading.Thread(target=self._drain_stdout, daemon=True)
|
||||
self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
|
||||
self._stdout_thread.start()
|
||||
self._stderr_thread.start()
|
||||
|
||||
def stop(self) -> Capture:
|
||||
return self._finish(discard=False)
|
||||
|
||||
def cancel(self) -> None:
|
||||
try:
|
||||
self._finish(discard=True)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
|
||||
def poll_unexpected(self) -> RecorderError | None:
|
||||
process = self._require_process()
|
||||
returncode = process.poll()
|
||||
if returncode is None:
|
||||
with self._lock:
|
||||
duration_stop_at = self._duration_stop_at
|
||||
if (
|
||||
not self._intentional
|
||||
and not self._reported
|
||||
and duration_stop_at is not None
|
||||
and self._clock() - duration_stop_at >= self._stop_timeout
|
||||
):
|
||||
self._reported = True
|
||||
self._signal_group(process, signal.SIGKILL)
|
||||
process.wait(timeout=self._stop_timeout)
|
||||
self._join_drainers()
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
return RecorderError(
|
||||
"duration_limit",
|
||||
"recording exceeded the five-minute duration limit; pw-record was killed after ignoring SIGTERM",
|
||||
returncode=process.returncode,
|
||||
signal_number=signal.SIGKILL,
|
||||
)
|
||||
return None
|
||||
if self._intentional or self._reported:
|
||||
return None
|
||||
self._reported = True
|
||||
self._join_drainers()
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
if self._duration_limited:
|
||||
return RecorderError(
|
||||
"duration_limit",
|
||||
"recording exceeded the five-minute duration limit",
|
||||
returncode=returncode,
|
||||
signal_number=-returncode if returncode < 0 else None,
|
||||
)
|
||||
return self._exit_error(returncode, "pw-record exited unexpectedly")
|
||||
|
||||
def _finish(self, *, discard: bool) -> Capture:
|
||||
process = self._require_process()
|
||||
self._intentional = True
|
||||
terminated_running_process = process.poll() is None
|
||||
if terminated_running_process:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
try:
|
||||
returncode = process.wait(timeout=self._stop_timeout)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
self._signal_group(process, signal.SIGKILL)
|
||||
process.wait(timeout=self._stop_timeout)
|
||||
self._join_drainers()
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
raise RecorderError(
|
||||
"recorder_timeout",
|
||||
"pw-record did not stop after SIGTERM and was killed",
|
||||
returncode=process.returncode,
|
||||
signal_number=signal.SIGKILL,
|
||||
) from error
|
||||
|
||||
self._join_drainers()
|
||||
stderr_tail = self._stderr_bytes()
|
||||
if self._duration_limited:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
raise RecorderError(
|
||||
"duration_limit",
|
||||
"recording exceeded the five-minute duration limit",
|
||||
returncode=returncode,
|
||||
signal_number=-returncode if returncode < 0 else None,
|
||||
)
|
||||
clean_pipewire_sigterm = (
|
||||
terminated_running_process and returncode == 1 and not stderr_tail
|
||||
)
|
||||
if returncode not in (0, -signal.SIGTERM) and not clean_pipewire_sigterm:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
raise self._exit_error(returncode, "pw-record failed while stopping")
|
||||
with self._lock:
|
||||
pcm = b"" if discard else bytes(self._pcm)
|
||||
self._pcm.clear()
|
||||
return Capture(
|
||||
pcm=pcm,
|
||||
returncode=returncode,
|
||||
signal=-returncode if returncode < 0 else None,
|
||||
stderr_tail=stderr_tail,
|
||||
)
|
||||
|
||||
def _drain_stdout(self) -> None:
|
||||
process = self._require_process()
|
||||
while True:
|
||||
chunk = process.stdout.read(64 * 1024)
|
||||
if not chunk:
|
||||
return
|
||||
with self._lock:
|
||||
if self._duration_limited:
|
||||
continue
|
||||
if len(self._pcm) + len(chunk) > self._max_bytes:
|
||||
self._duration_limited = True
|
||||
self._duration_stop_at = self._clock()
|
||||
self._pcm.clear()
|
||||
else:
|
||||
self._pcm.extend(chunk)
|
||||
continue
|
||||
if process.poll() is None:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
|
||||
def _drain_stderr(self) -> None:
|
||||
process = self._require_process()
|
||||
while True:
|
||||
chunk = process.stderr.read(16 * 1024)
|
||||
if not chunk:
|
||||
return
|
||||
with self._lock:
|
||||
self._stderr.extend(chunk)
|
||||
overflow = len(self._stderr) - self._stderr_limit
|
||||
if overflow > 0:
|
||||
del self._stderr[:overflow]
|
||||
|
||||
def _join_drainers(self) -> None:
|
||||
for thread in (self._stdout_thread, self._stderr_thread):
|
||||
if thread is None:
|
||||
continue
|
||||
thread.join(timeout=self._stop_timeout)
|
||||
if thread.is_alive():
|
||||
raise RecorderError(
|
||||
"recorder_timeout",
|
||||
"pw-record pipe did not close after the process group stopped",
|
||||
)
|
||||
|
||||
def _signal_group(self, process: Process, signal_number: int) -> None:
|
||||
try:
|
||||
self._process_group_signal(process.pid, signal_number)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
def _stderr_bytes(self) -> bytes:
|
||||
with self._lock:
|
||||
return bytes(self._stderr)
|
||||
|
||||
def _exit_error(self, returncode: int, prefix: str) -> RecorderError:
|
||||
if returncode < 0:
|
||||
classification = f"signal {-returncode}"
|
||||
signal_number = -returncode
|
||||
else:
|
||||
classification = f"code {returncode}"
|
||||
signal_number = None
|
||||
stderr = self._stderr_bytes().decode("utf-8", errors="replace").strip()
|
||||
detail = f"{prefix} with {classification}"
|
||||
if stderr:
|
||||
detail = f"{detail}: {stderr}"
|
||||
return RecorderError(
|
||||
"recorder_exit",
|
||||
detail,
|
||||
returncode=returncode,
|
||||
signal_number=signal_number,
|
||||
)
|
||||
|
||||
def _require_process(self) -> Process:
|
||||
if self._process is None:
|
||||
raise RecorderError("invalid_state", "recorder has not started")
|
||||
return self._process
|
||||
@@ -0,0 +1,570 @@
|
||||
"""Dictate service state machine and Unix-domain socket loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import Executor, Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from queue import Empty, SimpleQueue
|
||||
import selectors
|
||||
import socket
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from .audio import AudioError, pcm_s16le_to_floats
|
||||
from .model import NemoParakeetModel, TranscriptionModel
|
||||
from .protocol import (
|
||||
MAX_FRAME_BYTES,
|
||||
MAX_TRANSCRIPT_BYTES,
|
||||
PROTOCOL_VERSION,
|
||||
FrameDecoder,
|
||||
ProtocolError,
|
||||
Request,
|
||||
encode_frame,
|
||||
error_event,
|
||||
parse_request,
|
||||
)
|
||||
from .recorder import Capture, PwRecorder, RecorderError
|
||||
|
||||
DEFAULT_IDLE_SECONDS = 15 * 60
|
||||
MAX_CLIENT_CONNECTIONS = 16
|
||||
MAX_CONNECTION_OUTPUT_BYTES = MAX_FRAME_BYTES
|
||||
|
||||
|
||||
class Recorder(Protocol):
|
||||
def start(self) -> None: ...
|
||||
def stop(self) -> Capture: ...
|
||||
def cancel(self) -> None: ...
|
||||
def poll_unexpected(self) -> RecorderError | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Outbound:
|
||||
client_id: str
|
||||
frame: dict[str, object]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Generation:
|
||||
owner: str
|
||||
generation_id: str
|
||||
request_id: str
|
||||
state: str
|
||||
recorder: Recorder | None = None
|
||||
tombstoned: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Completion:
|
||||
kind: str
|
||||
generation_id: str
|
||||
future: Future[object]
|
||||
|
||||
|
||||
class DictateServiceCore:
|
||||
"""Single-generation protocol state machine with injected host boundaries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: TranscriptionModel,
|
||||
recorder_factory: Callable[[], Recorder] = PwRecorder,
|
||||
executor: Executor | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
idle_seconds: float = DEFAULT_IDLE_SECONDS,
|
||||
) -> None:
|
||||
self._model = model
|
||||
self._recorder_factory = recorder_factory
|
||||
self._executor = executor or ThreadPoolExecutor(max_workers=1, thread_name_prefix="dictate")
|
||||
self._owns_executor = executor is None
|
||||
self._clock = clock
|
||||
self._idle_seconds = idle_seconds
|
||||
self._last_activity = clock()
|
||||
self._clients: dict[str, bool] = {}
|
||||
self._active: _Generation | None = None
|
||||
self._model_loaded = bool(getattr(model, "loaded", False))
|
||||
self._worker_kind: str | None = None
|
||||
self._completions: SimpleQueue[_Completion] = SimpleQueue()
|
||||
self._events: list[Outbound] = []
|
||||
|
||||
@property
|
||||
def active_generation_id(self) -> str | None:
|
||||
return self._active.generation_id if self._active else None
|
||||
|
||||
def accepted(self, client_id: str) -> None:
|
||||
"""Track a socket without treating acceptance as protocol activity."""
|
||||
self._clients.setdefault(client_id, False)
|
||||
|
||||
def handle_request(self, client_id: str, request: Request) -> None:
|
||||
"""Handle one already parsed request and queue its observable events."""
|
||||
self._last_activity = self._clock()
|
||||
self._clients.setdefault(client_id, False)
|
||||
|
||||
if request.type == "hello":
|
||||
self._clients[client_id] = True
|
||||
self._emit(
|
||||
client_id,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "pong",
|
||||
"requestId": request.request_id,
|
||||
"service": "pi-dictate",
|
||||
"ready": self._model_loaded,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if not self._clients[client_id]:
|
||||
self._request_error(client_id, request, "hello_required", "hello is required first")
|
||||
return
|
||||
|
||||
if request.type == "ping":
|
||||
self._emit(
|
||||
client_id,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "pong",
|
||||
"requestId": request.request_id,
|
||||
"service": "pi-dictate",
|
||||
"ready": self._model_loaded,
|
||||
"busy": self._active is not None,
|
||||
},
|
||||
)
|
||||
elif request.type == "start":
|
||||
self._start(client_id, request)
|
||||
elif request.type == "stop":
|
||||
self._stop(client_id, request)
|
||||
elif request.type == "cancel":
|
||||
self._cancel(client_id, request)
|
||||
|
||||
def poll(self) -> None:
|
||||
"""Apply completed workers and recorder exits without blocking the socket loop."""
|
||||
while True:
|
||||
try:
|
||||
completion = self._completions.get_nowait()
|
||||
except Empty:
|
||||
break
|
||||
self._apply_completion(completion)
|
||||
|
||||
active = self._active
|
||||
if active and active.state == "listening" and active.recorder and self._worker_kind is None:
|
||||
try:
|
||||
failure = active.recorder.poll_unexpected()
|
||||
except RecorderError as error:
|
||||
failure = error
|
||||
if failure is not None:
|
||||
self._async_error(active, failure.code, failure.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
|
||||
def drain_events(self) -> list[Outbound]:
|
||||
events, self._events = self._events, []
|
||||
return events
|
||||
|
||||
def disconnect(self, client_id: str) -> None:
|
||||
self._clients.pop(client_id, None)
|
||||
active = self._active
|
||||
if active is None or active.owner != client_id:
|
||||
return
|
||||
active.tombstoned = True
|
||||
if active.state == "listening" and active.recorder is not None:
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
|
||||
def should_exit(self) -> bool:
|
||||
if self._active is not None or self._worker_kind is not None:
|
||||
return False
|
||||
return self._clock() - self._last_activity >= self._idle_seconds
|
||||
|
||||
def close(self) -> None:
|
||||
active = self._active
|
||||
if active and active.recorder and active.state == "listening":
|
||||
try:
|
||||
active.recorder.cancel()
|
||||
except RecorderError:
|
||||
pass
|
||||
self._active = None
|
||||
if self._owns_executor:
|
||||
self._executor.shutdown(wait=True, cancel_futures=False)
|
||||
|
||||
def _start(self, client_id: str, request: Request) -> None:
|
||||
assert request.generation_id is not None
|
||||
if self._active is not None or self._worker_kind is not None:
|
||||
self._request_error(client_id, request, "busy", "another generation is active")
|
||||
return
|
||||
active = _Generation(client_id, request.generation_id, request.request_id, "loading")
|
||||
self._active = active
|
||||
if not self._model_loaded:
|
||||
self._state(active, "loading")
|
||||
self._submit("load", active, self._model.load)
|
||||
return
|
||||
self._begin_recording(active)
|
||||
|
||||
def _begin_recording(self, active: _Generation) -> None:
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
return
|
||||
try:
|
||||
recorder = self._recorder_factory()
|
||||
recorder.start()
|
||||
except RecorderError as error:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
return
|
||||
except OSError as error:
|
||||
self._async_error(active, "recorder_spawn", str(error))
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
return
|
||||
active.recorder = recorder
|
||||
self._state(active, "listening")
|
||||
|
||||
def _stop(self, client_id: str, request: Request) -> None:
|
||||
active = self._owned_generation(client_id, request)
|
||||
if active is None:
|
||||
return
|
||||
if active.state != "listening" or active.recorder is None:
|
||||
self._request_error(client_id, request, "invalid_state", f"cannot stop while {active.state}")
|
||||
return
|
||||
active.request_id = request.request_id
|
||||
try:
|
||||
failure = active.recorder.poll_unexpected()
|
||||
except RecorderError as error:
|
||||
failure = error
|
||||
if failure is not None:
|
||||
self._async_error(active, failure.code, failure.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
return
|
||||
active.state = "stopping"
|
||||
self._state(active, "stopping", assign=False)
|
||||
self._submit("stop", active, self._stop_and_convert, active.recorder)
|
||||
|
||||
def _cancel(self, client_id: str, request: Request) -> None:
|
||||
active = self._owned_generation(client_id, request)
|
||||
if active is None:
|
||||
return
|
||||
if active.tombstoned:
|
||||
self._request_error(client_id, request, "invalid_state", "generation is already cancelled")
|
||||
return
|
||||
active.tombstoned = True
|
||||
active.request_id = request.request_id
|
||||
self._emit(
|
||||
client_id,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "cancelled",
|
||||
"requestId": active.request_id,
|
||||
"generationId": active.generation_id,
|
||||
},
|
||||
)
|
||||
if active.state == "listening" and active.recorder is not None:
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
|
||||
def _owned_generation(self, client_id: str, request: Request) -> _Generation | None:
|
||||
assert request.generation_id is not None
|
||||
active = self._active
|
||||
if active is None:
|
||||
self._request_error(client_id, request, "invalid_state", "no generation is active")
|
||||
return None
|
||||
if active.owner != client_id:
|
||||
self._request_error(client_id, request, "not_owner", "generation belongs to another client")
|
||||
return None
|
||||
if active.generation_id != request.generation_id:
|
||||
self._request_error(
|
||||
client_id,
|
||||
request,
|
||||
"generation_mismatch",
|
||||
"generation ID does not match the active generation",
|
||||
)
|
||||
return None
|
||||
return active
|
||||
|
||||
@staticmethod
|
||||
def _stop_and_convert(recorder: Recorder) -> Sequence[float]:
|
||||
capture = recorder.stop()
|
||||
return pcm_s16le_to_floats(capture.pcm)
|
||||
|
||||
def _submit(
|
||||
self,
|
||||
kind: str,
|
||||
active: _Generation,
|
||||
function: Callable[..., object],
|
||||
*args: object,
|
||||
) -> None:
|
||||
self._worker_kind = kind
|
||||
future = self._executor.submit(function, *args)
|
||||
future.add_done_callback(
|
||||
lambda completed, kind=kind, generation_id=active.generation_id: self._completions.put(
|
||||
_Completion(kind, generation_id, completed)
|
||||
)
|
||||
)
|
||||
|
||||
def _apply_completion(self, completion: _Completion) -> None:
|
||||
if self._worker_kind == completion.kind:
|
||||
self._worker_kind = None
|
||||
active = self._active
|
||||
if active is None or active.generation_id != completion.generation_id:
|
||||
return
|
||||
try:
|
||||
result = completion.future.result()
|
||||
except RecorderError as error:
|
||||
if not active.tombstoned:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
return
|
||||
except AudioError as error:
|
||||
if not active.tombstoned:
|
||||
self._async_error(active, "invalid_audio", str(error))
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
return
|
||||
except BaseException as error:
|
||||
if not active.tombstoned:
|
||||
code = "model_failure" if completion.kind in ("load", "infer") else "recorder_failure"
|
||||
self._async_error(active, code, str(error))
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
return
|
||||
|
||||
if completion.kind == "load":
|
||||
self._model_loaded = True
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
else:
|
||||
self._begin_recording(active)
|
||||
elif completion.kind == "stop":
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
return
|
||||
samples = result
|
||||
if not isinstance(samples, Sequence) or isinstance(samples, (str, bytes, bytearray)):
|
||||
self._async_error(active, "invalid_audio", "audio conversion returned invalid samples")
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
return
|
||||
self._state(active, "transcribing")
|
||||
self._submit("infer", active, self._model.transcribe, samples)
|
||||
elif completion.kind == "infer":
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
return
|
||||
if not isinstance(result, str):
|
||||
self._async_error(active, "model_failure", "model returned a non-string transcript")
|
||||
elif len(result.encode("utf-8")) > MAX_TRANSCRIPT_BYTES:
|
||||
self._async_error(active, "transcript_too_large", "transcript exceeds output limit")
|
||||
else:
|
||||
self._emit(
|
||||
active.owner,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "result",
|
||||
"requestId": active.request_id,
|
||||
"generationId": active.generation_id,
|
||||
"transcript": result,
|
||||
},
|
||||
)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
elif completion.kind == "cancel":
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
|
||||
def _finish_generation(self, active: _Generation, *, notify_idle: bool) -> None:
|
||||
if self._active is not active:
|
||||
return
|
||||
self._active = None
|
||||
self._last_activity = self._clock()
|
||||
if notify_idle and active.owner in self._clients:
|
||||
self._state(active, "idle", assign=False)
|
||||
|
||||
def _state(self, active: _Generation, state: str, *, assign: bool = True) -> None:
|
||||
if assign:
|
||||
active.state = state
|
||||
self._emit(
|
||||
active.owner,
|
||||
{
|
||||
"version": PROTOCOL_VERSION,
|
||||
"type": "state",
|
||||
"requestId": active.request_id,
|
||||
"generationId": active.generation_id,
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
|
||||
def _request_error(self, client_id: str, request: Request, code: str, detail: str) -> None:
|
||||
error = ProtocolError(
|
||||
code,
|
||||
detail,
|
||||
request_id=request.request_id,
|
||||
generation_id=request.generation_id,
|
||||
)
|
||||
self._emit(client_id, error_event(error))
|
||||
|
||||
def _async_error(self, active: _Generation, code: str, detail: str) -> None:
|
||||
error = ProtocolError(
|
||||
code,
|
||||
detail,
|
||||
request_id=active.request_id,
|
||||
generation_id=active.generation_id,
|
||||
)
|
||||
self._emit(active.owner, error_event(error))
|
||||
|
||||
def _emit(self, client_id: str, frame: dict[str, object]) -> None:
|
||||
if client_id in self._clients:
|
||||
self._events.append(Outbound(client_id, frame))
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Connection:
|
||||
socket: socket.socket
|
||||
decoder: FrameDecoder
|
||||
output: bytearray
|
||||
close_after_write: bool = False
|
||||
|
||||
|
||||
def inherited_listener(environ: dict[str, str] | None = None) -> socket.socket:
|
||||
"""Return systemd's single inherited listener at fd 3."""
|
||||
environment = os.environ if environ is None else environ
|
||||
try:
|
||||
listen_pid = int(environment.get("LISTEN_PID", "0"))
|
||||
listen_fds = int(environment.get("LISTEN_FDS", "0"))
|
||||
except ValueError as error:
|
||||
raise RuntimeError("invalid systemd socket activation environment") from error
|
||||
if listen_pid != os.getpid() or listen_fds != 1:
|
||||
raise RuntimeError("expected exactly one systemd socket-activated listener")
|
||||
listener = socket.socket(fileno=3)
|
||||
listener.setblocking(False)
|
||||
return listener
|
||||
|
||||
|
||||
def serve(listener: socket.socket, core: DictateServiceCore) -> None:
|
||||
"""Serve protocol v1 on an explicit listener until the core becomes idle."""
|
||||
selector = selectors.DefaultSelector()
|
||||
connections: dict[str, _Connection] = {}
|
||||
sequence = 0
|
||||
listener.setblocking(False)
|
||||
selector.register(listener, selectors.EVENT_READ, None)
|
||||
try:
|
||||
while not core.should_exit():
|
||||
core.poll()
|
||||
for outbound in core.drain_events():
|
||||
connection = connections.get(outbound.client_id)
|
||||
if connection is None:
|
||||
continue
|
||||
try:
|
||||
encoded = encode_frame(outbound.frame)
|
||||
except ProtocolError:
|
||||
_close_connection(selector, connections, core, outbound.client_id)
|
||||
continue
|
||||
if len(connection.output) + len(encoded) > MAX_CONNECTION_OUTPUT_BYTES:
|
||||
_close_connection(selector, connections, core, outbound.client_id)
|
||||
continue
|
||||
connection.output.extend(encoded)
|
||||
selector.modify(
|
||||
connection.socket,
|
||||
selectors.EVENT_READ | selectors.EVENT_WRITE,
|
||||
outbound.client_id,
|
||||
)
|
||||
for key, mask in selector.select(timeout=0.1):
|
||||
if key.data is None:
|
||||
try:
|
||||
client, _address = listener.accept()
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if len(connections) >= MAX_CLIENT_CONNECTIONS:
|
||||
client.close()
|
||||
continue
|
||||
client.setblocking(False)
|
||||
sequence += 1
|
||||
client_id = f"client-{sequence}"
|
||||
connections[client_id] = _Connection(client, FrameDecoder(), bytearray())
|
||||
core.accepted(client_id)
|
||||
selector.register(client, selectors.EVENT_READ, client_id)
|
||||
continue
|
||||
client_id = key.data
|
||||
connection = connections.get(client_id)
|
||||
if connection is None:
|
||||
continue
|
||||
if mask & selectors.EVENT_READ:
|
||||
try:
|
||||
data = connection.socket.recv(64 * 1024)
|
||||
except BlockingIOError:
|
||||
data = None
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
continue
|
||||
if data == b"":
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
continue
|
||||
if data:
|
||||
try:
|
||||
for frame in connection.decoder.feed(data):
|
||||
core.handle_request(client_id, parse_request(frame))
|
||||
except ProtocolError as error:
|
||||
connection.output.extend(encode_frame(error_event(error)))
|
||||
if error.close_connection:
|
||||
connection.close_after_write = True
|
||||
if mask & selectors.EVENT_WRITE and connection.output:
|
||||
try:
|
||||
sent = connection.socket.send(connection.output)
|
||||
except BlockingIOError:
|
||||
sent = 0
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
continue
|
||||
del connection.output[:sent]
|
||||
if connection.close_after_write and not connection.output:
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
elif client_id in connections:
|
||||
events = selectors.EVENT_READ
|
||||
if connection.output:
|
||||
events |= selectors.EVENT_WRITE
|
||||
selector.modify(connection.socket, events, client_id)
|
||||
finally:
|
||||
for client_id in list(connections):
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
selector.unregister(listener)
|
||||
listener.close()
|
||||
selector.close()
|
||||
core.close()
|
||||
|
||||
|
||||
def _close_connection(
|
||||
selector: selectors.BaseSelector,
|
||||
connections: dict[str, _Connection],
|
||||
core: DictateServiceCore,
|
||||
client_id: str,
|
||||
) -> None:
|
||||
connection = connections.pop(client_id, None)
|
||||
if connection is None:
|
||||
return
|
||||
core.disconnect(client_id)
|
||||
try:
|
||||
selector.unregister(connection.socket)
|
||||
except KeyError:
|
||||
pass
|
||||
connection.socket.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
model_path = os.environ.get("PI_DICTATE_MODEL_PATH")
|
||||
if not model_path:
|
||||
raise SystemExit("PI_DICTATE_MODEL_PATH must name an explicit local .nemo file")
|
||||
terms_path = os.environ.get("PI_DICTATE_TERMS_PATH")
|
||||
if not terms_path:
|
||||
raise SystemExit("PI_DICTATE_TERMS_PATH must name the host coding dictionary")
|
||||
idle_value = os.environ.get("PI_DICTATE_IDLE_SECONDS", str(DEFAULT_IDLE_SECONDS))
|
||||
boost_value = os.environ.get("PI_DICTATE_BOOST_ALPHA", "1.0")
|
||||
try:
|
||||
idle_seconds = float(idle_value)
|
||||
boost_alpha = float(boost_value)
|
||||
except ValueError as error:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS and PI_DICTATE_BOOST_ALPHA must be numeric") from error
|
||||
if idle_seconds <= 0:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS must be positive")
|
||||
if boost_alpha <= 0:
|
||||
raise SystemExit("PI_DICTATE_BOOST_ALPHA must be positive")
|
||||
|
||||
model = NemoParakeetModel(model_path, terms_path=terms_path, boost_alpha=boost_alpha)
|
||||
core = DictateServiceCore(model=model, idle_seconds=idle_seconds)
|
||||
listener = inherited_listener()
|
||||
serve(listener, core)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user