feat(dictate): support configurable ASR backends
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
"""Backend-neutral transcription and realtime turn abstractions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from queue import Empty, Full, Queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from .recorder import Capture
|
||||
|
||||
MAX_PCM_QUEUE_BYTES = 240_000 # five seconds of 24 kHz mono PCM16
|
||||
MAX_PCM_QUEUE_CHUNKS = 64
|
||||
MAX_PROVIDER_EVENT_BYTES = 64 * 1024
|
||||
MAX_PROVIDER_EVENT_QUEUE_BYTES = 512 * 1024
|
||||
MAX_PROVIDER_EVENT_QUEUE_ITEMS = 128
|
||||
MAX_PROVIDER_TEXT_BYTES = 32 * 1024
|
||||
RECEIVE_TIMEOUT = object()
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
"""A bounded, redacted provider failure suitable for the service boundary."""
|
||||
|
||||
def __init__(self, code: str, detail: str = "transcription provider failed") -> None:
|
||||
self.code = code
|
||||
try:
|
||||
self.detail = detail.encode("utf-8", errors="replace")[:2048].decode("utf-8", errors="ignore")
|
||||
except (AttributeError, UnicodeError):
|
||||
self.detail = "transcription provider failed"
|
||||
super().__init__(self.detail)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendDescriptor:
|
||||
backend: str
|
||||
model: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TranscriptionTurn(Protocol):
|
||||
sample_rate_hz: int
|
||||
retain_pcm: bool
|
||||
|
||||
def enqueue_pcm(self, chunk: bytes) -> None: ...
|
||||
def finish(self, capture: Capture | None = None) -> str: ...
|
||||
def poll_error(self) -> ProviderError | None: ...
|
||||
def cancel(self) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
@property
|
||||
def cleanup_complete(self) -> bool: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TranscriptionBackend(Protocol):
|
||||
descriptor: BackendDescriptor
|
||||
sample_rate_hz: int
|
||||
|
||||
def start_turn(self) -> TranscriptionTurn: ...
|
||||
|
||||
def cancel_prepare(self) -> None: ...
|
||||
|
||||
|
||||
class PcmQueue:
|
||||
"""A byte- and item-bounded nonblocking queue for recorder drain threads."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_bytes: int = MAX_PCM_QUEUE_BYTES,
|
||||
max_chunks: int = MAX_PCM_QUEUE_CHUNKS,
|
||||
) -> None:
|
||||
if max_bytes <= 0 or max_chunks <= 0:
|
||||
raise ValueError("PCM queue bounds must be positive")
|
||||
self._queue: Queue[bytes | None] = Queue(maxsize=max_chunks)
|
||||
self._max_bytes = max_bytes
|
||||
self._queued_bytes = 0
|
||||
self._lock = threading.Lock()
|
||||
self._close_lock = threading.Lock()
|
||||
self._closed = False
|
||||
self._end_queued = False
|
||||
|
||||
@property
|
||||
def queued_bytes(self) -> int:
|
||||
with self._lock:
|
||||
return self._queued_bytes
|
||||
|
||||
def put(self, chunk: bytes) -> None:
|
||||
if not isinstance(chunk, bytes) or not chunk:
|
||||
raise ProviderError("queue_invalid", "provider received invalid audio")
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise ProviderError("queue_closed", "provider audio queue is closed")
|
||||
if len(chunk) > self._max_bytes or self._queued_bytes + len(chunk) > self._max_bytes:
|
||||
raise ProviderError("queue_overflow", "provider audio queue is full")
|
||||
self._queued_bytes += len(chunk)
|
||||
try:
|
||||
self._queue.put_nowait(chunk)
|
||||
except Full as error:
|
||||
with self._lock:
|
||||
self._queued_bytes -= len(chunk)
|
||||
raise ProviderError("queue_overflow", "provider audio queue is full") from error
|
||||
|
||||
def close(self, *, timeout: float = 15.0) -> None:
|
||||
if not self._close_lock.acquire(timeout=max(0.0, timeout)):
|
||||
raise ProviderError("queue_overflow", "provider audio queue could not be finalized")
|
||||
try:
|
||||
with self._lock:
|
||||
if self._end_queued:
|
||||
return
|
||||
self._closed = True
|
||||
# A full queue still has valid audio that must be drained. The
|
||||
# closed flag is a side-channel EOF marker when the sentinel cannot
|
||||
# be inserted yet, rather than dropping the queued tail.
|
||||
try:
|
||||
self._queue.put_nowait(None)
|
||||
except Full:
|
||||
pass
|
||||
with self._lock:
|
||||
self._end_queued = True
|
||||
finally:
|
||||
self._close_lock.release()
|
||||
|
||||
def get(self, timeout: float | None = None) -> bytes | None:
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
poll_timeout = 0.1 if deadline is None else max(0.0, min(0.1, deadline - time.monotonic()))
|
||||
try:
|
||||
chunk = self._queue.get(timeout=poll_timeout)
|
||||
except Empty:
|
||||
with self._lock:
|
||||
closed = self._closed
|
||||
if closed:
|
||||
return None
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise
|
||||
continue
|
||||
if chunk is not None:
|
||||
with self._lock:
|
||||
self._queued_bytes -= len(chunk)
|
||||
return chunk
|
||||
|
||||
|
||||
class LocalNemoTurn:
|
||||
"""Post-stop local inference turn retaining audio only in the recorder."""
|
||||
|
||||
sample_rate_hz = 16_000
|
||||
retain_pcm = True
|
||||
|
||||
def __init__(self, model: Any) -> None:
|
||||
self._model = model
|
||||
self._cancelled = False
|
||||
self._closed = False
|
||||
self._finished = False
|
||||
|
||||
@property
|
||||
def cleanup_complete(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
def enqueue_pcm(self, chunk: bytes) -> None:
|
||||
# Local turns use Capture.pcm so no second copy is made here.
|
||||
if not self.retain_pcm:
|
||||
raise ProviderError("local_invalid", "local turn cannot stream audio")
|
||||
|
||||
def finish(self, capture: Capture | None = None) -> str:
|
||||
if self._cancelled:
|
||||
raise ProviderError("local_cancelled", "local turn was cancelled")
|
||||
if self._closed or self._finished:
|
||||
raise ProviderError("local_invalid", "local turn was already finalized")
|
||||
self._finished = True
|
||||
if capture is None:
|
||||
raise ProviderError("local_invalid", "local capture is missing")
|
||||
if capture.sample_rate_hz != self.sample_rate_hz:
|
||||
raise ProviderError("local_invalid", "local capture has an unsupported sample rate")
|
||||
from .audio import pcm_s16le_to_floats
|
||||
|
||||
max_bytes = getattr(self._model, "max_pcm_bytes", 5 * 60 * 16_000 * 2)
|
||||
samples = pcm_s16le_to_floats(capture.pcm, max_bytes=max_bytes)
|
||||
result = self._model.transcribe(samples)
|
||||
if not isinstance(result, str):
|
||||
raise ProviderError("local_failure", "local model returned an invalid transcript")
|
||||
return result
|
||||
|
||||
def poll_error(self) -> ProviderError | None:
|
||||
return None
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
class LocalNemoBackend:
|
||||
"""Backend wrapper preserving the lazy, warm NeMo model behavior."""
|
||||
|
||||
sample_rate_hz = 16_000
|
||||
|
||||
def __init__(self, model: Any) -> None:
|
||||
self._model = model
|
||||
self.descriptor = BackendDescriptor("local", "nemo-parakeet")
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return bool(getattr(self._model, "loaded", False))
|
||||
|
||||
def start_turn(self) -> LocalNemoTurn:
|
||||
self._model.load()
|
||||
return LocalNemoTurn(self._model)
|
||||
|
||||
def cancel_prepare(self) -> None:
|
||||
# NeMo model loading is not interruptible, but the service tombstone
|
||||
# prevents a cancelled preparation from starting capture.
|
||||
return None
|
||||
|
||||
|
||||
class QueuedCloudTurn:
|
||||
"""Common ordered sender/receiver for cloud adapters.
|
||||
|
||||
Provider handshakes happen before this object is created. The recorder only
|
||||
calls ``enqueue_pcm``; it never performs network I/O. A sender thread drains
|
||||
the bounded queue and sends the end marker after every queued PCM chunk. A
|
||||
receiver thread continuously consumes provider events during capture so a
|
||||
websocket's receive buffer cannot stall the sender.
|
||||
"""
|
||||
|
||||
sample_rate_hz = 24_000
|
||||
retain_pcm = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: Any,
|
||||
*,
|
||||
timeout: float = 15.0,
|
||||
max_queue_bytes: int = MAX_PCM_QUEUE_BYTES,
|
||||
max_queue_chunks: int = MAX_PCM_QUEUE_CHUNKS,
|
||||
) -> None:
|
||||
if max_queue_bytes <= 0 or max_queue_bytes > MAX_PCM_QUEUE_BYTES:
|
||||
raise ValueError("cloud PCM backlog must not exceed five seconds")
|
||||
self._transport = transport
|
||||
self._timeout = timeout
|
||||
self._queue = PcmQueue(max_bytes=max_queue_bytes, max_chunks=max_queue_chunks)
|
||||
self._provider_events: Queue[tuple[dict[str, Any], int]] = Queue(maxsize=MAX_PROVIDER_EVENT_QUEUE_ITEMS)
|
||||
self._provider_event_bytes = 0
|
||||
self._provider_closed = False
|
||||
self._sender_error: ProviderError | None = None
|
||||
self._receiver_error: ProviderError | None = None
|
||||
self._event_lock = threading.Lock()
|
||||
self._cancel_event = threading.Event()
|
||||
self._transport_close_lock = threading.Lock()
|
||||
self._transport_close_thread: threading.Thread | None = None
|
||||
self._cancelled = False
|
||||
self._finished = False
|
||||
self._closed = False
|
||||
self._sent_bytes = 0
|
||||
self._stream_started_at = time.monotonic()
|
||||
self._sender = threading.Thread(target=self._send_loop, daemon=True, name="dictate-provider-send")
|
||||
self._receiver = threading.Thread(target=self._receive_loop, daemon=True, name="dictate-provider-recv")
|
||||
self._sender.start()
|
||||
self._receiver.start()
|
||||
|
||||
@property
|
||||
def cleanup_complete(self) -> bool:
|
||||
close_thread = self._transport_close_thread
|
||||
return (
|
||||
not self._sender.is_alive()
|
||||
and not self._receiver.is_alive()
|
||||
and (close_thread is None or not close_thread.is_alive())
|
||||
)
|
||||
|
||||
def enqueue_pcm(self, chunk: bytes) -> None:
|
||||
if self._cancelled or self._finished:
|
||||
raise ProviderError(f"{self._provider_prefix}_queue_closed", "provider turn is no longer accepting audio")
|
||||
try:
|
||||
self._queue.put(chunk)
|
||||
except ProviderError as error:
|
||||
raise ProviderError(f"{self._provider_prefix}_{error.code}", error.detail) from error
|
||||
|
||||
def close_input(self) -> None:
|
||||
"""Signal EOF after the recorder has drained stdout to EOF."""
|
||||
self._queue.close()
|
||||
|
||||
def poll_error(self) -> ProviderError | None:
|
||||
error = self._sender_error or self._receiver_error
|
||||
if error is not None:
|
||||
return error
|
||||
with self._event_lock:
|
||||
provider_closed = self._provider_closed
|
||||
if provider_closed and not self._finished and not self._cancelled:
|
||||
return ProviderError(
|
||||
self._provider_prefix + "_premature_close",
|
||||
"provider closed before recording was finalized",
|
||||
)
|
||||
return None
|
||||
|
||||
def finish(self, capture: Capture | None = None) -> str:
|
||||
if self._cancelled:
|
||||
raise ProviderError("cancelled", "provider turn was cancelled")
|
||||
if self._finished:
|
||||
raise ProviderError("invalid_state", "provider turn was already finalized")
|
||||
self._finished = True
|
||||
try:
|
||||
self._queue.close(timeout=self._timeout)
|
||||
except ProviderError:
|
||||
self.cancel()
|
||||
raise
|
||||
self._sender.join(timeout=self._timeout)
|
||||
if self._sender.is_alive():
|
||||
self.cancel()
|
||||
raise ProviderError(self._provider_prefix + "_timeout", "provider audio sender timed out")
|
||||
if self._sender_error is not None:
|
||||
error = self._sender_error
|
||||
self.close()
|
||||
raise error
|
||||
try:
|
||||
transcript = self._finish_provider()
|
||||
# A correlated final isn't enough to publish a result while the
|
||||
# receiver can still report a queued provider failure. Stop and
|
||||
# join it first, then make the receiver error check authoritative.
|
||||
self._cancel_event.set()
|
||||
self._request_transport_close()
|
||||
self._receiver.join(timeout=self._timeout)
|
||||
if self._receiver.is_alive():
|
||||
raise ProviderError(
|
||||
self._provider_prefix + "_timeout",
|
||||
"provider receiver did not stop after the final transcript",
|
||||
)
|
||||
if self._receiver_error is not None:
|
||||
raise self._receiver_error
|
||||
return transcript
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self._cancelled and self.cleanup_complete:
|
||||
return
|
||||
self._cancelled = True
|
||||
self._cancel_event.set()
|
||||
self._request_transport_close()
|
||||
try:
|
||||
self._queue.close(timeout=0.1)
|
||||
except ProviderError:
|
||||
pass
|
||||
# Closing the transport is the cancellation signal. Do not block the
|
||||
# protocol loop waiting for provider threads; ``cleanup_complete``
|
||||
# keeps the tombstoned generation busy until they actually exit.
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed and self.cleanup_complete:
|
||||
return
|
||||
self._cancel_event.set()
|
||||
self._request_transport_close()
|
||||
# Keep close bounded and nonblocking from the service loop. A later
|
||||
# poll retries the joins while cleanup_complete prevents a new turn.
|
||||
for thread in (self._sender, self._receiver, self._transport_close_thread):
|
||||
if thread is not None:
|
||||
thread.join(timeout=min(self._timeout, 0.05))
|
||||
self._closed = self.cleanup_complete
|
||||
|
||||
def _send_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
chunk = self._queue.get(timeout=self._timeout)
|
||||
if chunk is None:
|
||||
# Queue closure means commit only for a successful recorder
|
||||
# EOF. Cancellation closes the same bounded queue merely to
|
||||
# wake this worker and must never finalize partial audio.
|
||||
if not self._cancel_event.is_set():
|
||||
self._send_end()
|
||||
return
|
||||
# Pace the stream against the declared PCM rate. This prevents
|
||||
# a fast recorder from building more than five seconds of
|
||||
# provider-side backlog while preserving ordered sends.
|
||||
target_elapsed = self._sent_bytes / (self.sample_rate_hz * 2)
|
||||
elapsed = time.monotonic() - self._stream_started_at
|
||||
if target_elapsed > elapsed and self._cancel_event.wait(target_elapsed - elapsed):
|
||||
return
|
||||
if self._cancel_event.is_set():
|
||||
return
|
||||
self._send_audio(chunk)
|
||||
self._sent_bytes += len(chunk)
|
||||
except ProviderError as error:
|
||||
self._sender_error = error
|
||||
self._abort_transport()
|
||||
except Empty:
|
||||
self._sender_error = ProviderError(
|
||||
self._provider_prefix + "_timeout", "provider audio sender timed out"
|
||||
)
|
||||
self._abort_transport()
|
||||
except Exception:
|
||||
self._sender_error = ProviderError(
|
||||
self._provider_prefix + "_send", "provider audio upload failed"
|
||||
)
|
||||
self._abort_transport()
|
||||
|
||||
def _receive_loop(self) -> None:
|
||||
try:
|
||||
while not self._cancel_event.is_set():
|
||||
event = self._receive_provider_event(timeout=0.1)
|
||||
if event is RECEIVE_TIMEOUT:
|
||||
continue
|
||||
if event is None:
|
||||
with self._event_lock:
|
||||
self._provider_closed = True
|
||||
return
|
||||
if not isinstance(event, dict):
|
||||
raise ProviderError(self._provider_prefix + "_malformed_event", "provider event is invalid")
|
||||
try:
|
||||
size = len(json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
|
||||
except (TypeError, UnicodeError, ValueError, RecursionError, OverflowError) as error:
|
||||
raise ProviderError(self._provider_prefix + "_malformed_event", "provider event is invalid") from error
|
||||
with self._event_lock:
|
||||
if self._provider_event_bytes + size > MAX_PROVIDER_EVENT_QUEUE_BYTES:
|
||||
raise ProviderError(self._provider_prefix + "_event_overflow", "provider event queue is full")
|
||||
self._provider_event_bytes += size
|
||||
try:
|
||||
self._provider_events.put_nowait((event, size))
|
||||
except Full as error:
|
||||
with self._event_lock:
|
||||
self._provider_event_bytes -= size
|
||||
raise ProviderError(self._provider_prefix + "_event_overflow", "provider event queue is full") from error
|
||||
except ProviderError as error:
|
||||
if not self._cancel_event.is_set():
|
||||
self._receiver_error = error
|
||||
self._abort_transport()
|
||||
except Exception:
|
||||
if not self._cancel_event.is_set():
|
||||
self._receiver_error = ProviderError(
|
||||
self._provider_prefix + "_receive", "provider event stream failed"
|
||||
)
|
||||
self._abort_transport()
|
||||
|
||||
def _next_provider_event(self, timeout: float) -> dict[str, Any] | None:
|
||||
try:
|
||||
event, size = self._provider_events.get(timeout=max(0.0, timeout))
|
||||
except Empty as error:
|
||||
error_from_receiver = self._receiver_error
|
||||
if error_from_receiver is not None:
|
||||
raise error_from_receiver
|
||||
with self._event_lock:
|
||||
closed = self._provider_closed
|
||||
if closed:
|
||||
return None
|
||||
raise ProviderError(self._provider_prefix + "_timeout", "timed out waiting for provider event") from error
|
||||
with self._event_lock:
|
||||
self._provider_event_bytes -= size
|
||||
return event
|
||||
|
||||
@property
|
||||
def _provider_prefix(self) -> str:
|
||||
return "provider"
|
||||
|
||||
def _send_audio(self, chunk: bytes) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _send_end(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _finish_provider(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def _receive_provider_event(self, *, timeout: float) -> dict[str, Any] | None | object:
|
||||
raise NotImplementedError
|
||||
|
||||
def _request_transport_close(self) -> None:
|
||||
with self._transport_close_lock:
|
||||
if self._transport_close_thread is not None:
|
||||
return
|
||||
self._transport_close_thread = threading.Thread(
|
||||
target=self._abort_transport,
|
||||
daemon=True,
|
||||
name="dictate-provider-close",
|
||||
)
|
||||
self._transport_close_thread.start()
|
||||
|
||||
def _abort_transport(self) -> None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def bounded_provider_text(value: Any, *, field: str = "transcript") -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ProviderError("provider_malformed", f"provider {field} is invalid")
|
||||
try:
|
||||
if len(value.encode("utf-8")) > MAX_PROVIDER_TEXT_BYTES:
|
||||
raise ProviderError("provider_malformed", f"provider {field} is too large")
|
||||
except (UnicodeEncodeError, AttributeError) as error:
|
||||
raise ProviderError("provider_malformed", f"provider {field} is invalid") from error
|
||||
return value
|
||||
|
||||
|
||||
def provider_event(value: Any) -> dict[str, Any]:
|
||||
"""Validate one already decoded event and apply a serialized-size bound."""
|
||||
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise ProviderError("provider_malformed", "provider event is not an object")
|
||||
try:
|
||||
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
except (TypeError, UnicodeError, ValueError, RecursionError, OverflowError) as error:
|
||||
raise ProviderError("provider_malformed", "provider event is invalid") from error
|
||||
if len(encoded) > MAX_PROVIDER_EVENT_BYTES:
|
||||
raise ProviderError("provider_malformed", "provider event is too large")
|
||||
return value
|
||||
|
||||
|
||||
def backend_from_config(config: Any) -> TranscriptionBackend:
|
||||
"""Construct only the selected backend, keeping cloud imports lazy."""
|
||||
if config.backend == "local":
|
||||
from .backends.local import LocalNemoBackend
|
||||
return LocalNemoBackend(config.local)
|
||||
if config.backend == "meta":
|
||||
from .backends.meta import MetaBackend
|
||||
return MetaBackend(config.meta)
|
||||
if config.backend == "openai":
|
||||
from .backends.openai import OpenAIBackend
|
||||
return OpenAIBackend(config.openai)
|
||||
raise ProviderError("configuration", "unsupported transcription backend")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BackendDescriptor", "LocalNemoBackend", "LocalNemoTurn", "MAX_PCM_QUEUE_BYTES",
|
||||
"MAX_PROVIDER_EVENT_BYTES", "MAX_PROVIDER_EVENT_QUEUE_BYTES", "MAX_PROVIDER_TEXT_BYTES",
|
||||
"PcmQueue", "ProviderError", "QueuedCloudTurn", "RECEIVE_TIMEOUT",
|
||||
"TranscriptionBackend", "TranscriptionTurn", "backend_from_config",
|
||||
"bounded_provider_text", "provider_event",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Optional cloud and local Dictate backend adapters."""
|
||||
|
||||
from ..backend import backend_from_config
|
||||
from .local import LocalNemoBackend
|
||||
from .meta import MetaBackend, MetaTurn
|
||||
from .openai import OpenAIBackend, OpenAITurn
|
||||
|
||||
__all__ = ["LocalNemoBackend", "MetaBackend", "MetaTurn", "OpenAIBackend", "OpenAITurn", "backend_from_config"]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Local NeMo backend factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..backend import LocalNemoBackend as _LocalNemoBackend
|
||||
from ..config import LocalConfig
|
||||
from ..model import NemoParakeetModel
|
||||
|
||||
|
||||
class LocalNemoBackend(_LocalNemoBackend):
|
||||
"""Build the existing lazy NeMo adapter from typed host configuration."""
|
||||
|
||||
def __init__(self, config: LocalConfig | Any) -> None:
|
||||
if isinstance(config, LocalConfig):
|
||||
model = NemoParakeetModel(
|
||||
config.model_path,
|
||||
terms_path=config.terms_path,
|
||||
boost_alpha=config.boost_alpha,
|
||||
expected_sha256=config.model_sha256,
|
||||
)
|
||||
else:
|
||||
model = config
|
||||
super().__init__(model)
|
||||
|
||||
@property
|
||||
def model(self) -> Any:
|
||||
return self._model
|
||||
|
||||
|
||||
__all__ = ["LocalNemoBackend"]
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Meta Muse Voice Transcribe realtime adapter.
|
||||
|
||||
The websocket client is imported only when cloud mode is selected. Tests inject
|
||||
an object implementing ``send``, ``recv`` and ``close``; no network dependency is
|
||||
needed to import or test the service in local mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from queue import Empty
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from ..backend import (
|
||||
BackendDescriptor,
|
||||
MAX_PROVIDER_EVENT_BYTES,
|
||||
ProviderError,
|
||||
QueuedCloudTurn,
|
||||
RECEIVE_TIMEOUT,
|
||||
bounded_provider_text,
|
||||
provider_event,
|
||||
)
|
||||
from ..config import CredentialError, MetaConfig, provider_keywords, read_credential
|
||||
|
||||
META_ENDPOINT = "wss://api.meta.ai/v1/asr/realtime"
|
||||
META_MODEL = "muse-voice-transcribe-1.0"
|
||||
META_SAMPLE_RATE_HZ = 24_000
|
||||
META_MAX_BACKLOG_BYTES = 240_000 # provider's documented five-second limit
|
||||
META_HANDSHAKE_TIMEOUT = 10.0
|
||||
META_EVENT_TIMEOUT = 45.0
|
||||
META_SOCKET_IO_TIMEOUT = 10.0
|
||||
META_URL = META_ENDPOINT
|
||||
|
||||
TransportFactory = Callable[..., Any]
|
||||
|
||||
|
||||
def _default_transport(endpoint: str, **_kwargs: Any) -> Any:
|
||||
try:
|
||||
from websockets.sync.client import connect
|
||||
except ImportError as error:
|
||||
raise ProviderError("meta_dependency", "Meta backend websocket dependency is unavailable") from error
|
||||
try:
|
||||
logger = logging.getLogger("pi_dictate_service.provider.meta")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.propagate = False
|
||||
transport = connect(
|
||||
endpoint,
|
||||
open_timeout=META_HANDSHAKE_TIMEOUT,
|
||||
close_timeout=5,
|
||||
max_size=MAX_PROVIDER_EVENT_BYTES,
|
||||
proxy=None,
|
||||
logger=logger,
|
||||
)
|
||||
transport.socket.settimeout(META_SOCKET_IO_TIMEOUT)
|
||||
return transport
|
||||
except Exception as error:
|
||||
response = getattr(error, "response", None)
|
||||
status = getattr(response, "status_code", getattr(error, "status_code", getattr(error, "status", None)))
|
||||
if status in {401, 403}:
|
||||
raise ProviderError("meta_auth", "Meta authentication failed") from error
|
||||
if status == 429:
|
||||
raise ProviderError("meta_rate_limit", "Meta transcription rate limit reached") from error
|
||||
raise ProviderError("meta_connect", "could not connect to Meta transcription service") from error
|
||||
|
||||
|
||||
def _send_json(transport: Any, event: Mapping[str, Any]) -> None:
|
||||
try:
|
||||
payload = json.dumps(dict(event), separators=(",", ":"), ensure_ascii=False)
|
||||
if len(payload.encode("utf-8")) > MAX_PROVIDER_EVENT_BYTES:
|
||||
raise ProviderError("meta_event_too_large", "Meta transcription event is too large")
|
||||
transport.send(payload)
|
||||
except ProviderError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProviderError("meta_send", "could not send Meta transcription event") from error
|
||||
|
||||
|
||||
def _receive_event(
|
||||
transport: Any, *, timeout: float, tolerate_timeout: bool = False
|
||||
) -> dict[str, Any] | None | object:
|
||||
try:
|
||||
try:
|
||||
raw = transport.recv(timeout=timeout)
|
||||
except TypeError:
|
||||
raw = transport.recv()
|
||||
except (TimeoutError, Empty):
|
||||
if tolerate_timeout:
|
||||
return RECEIVE_TIMEOUT
|
||||
raise ProviderError("meta_timeout", "timed out waiting for Meta transcription service")
|
||||
except Exception as error:
|
||||
code = _close_code(error)
|
||||
if code == 1000:
|
||||
return None
|
||||
if code in {1008, 1011, 1013}:
|
||||
raise ProviderError(f"meta_close_{code}", "Meta transcription service closed the session") from error
|
||||
raise ProviderError("meta_premature_close", "Meta transcription service closed unexpectedly") from error
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
try:
|
||||
return provider_event(raw)
|
||||
except ProviderError as error:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an invalid event") from error
|
||||
if isinstance(raw, bytes):
|
||||
if len(raw) > MAX_PROVIDER_EVENT_BYTES:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an oversized event")
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an invalid event") from error
|
||||
if not isinstance(raw, str):
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an invalid event")
|
||||
try:
|
||||
if len(raw.encode("utf-8")) > MAX_PROVIDER_EVENT_BYTES:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an oversized event")
|
||||
except UnicodeEncodeError as error:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an invalid event") from error
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, ValueError, RecursionError) as error:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent malformed event JSON") from error
|
||||
try:
|
||||
return provider_event(value)
|
||||
except ProviderError as error:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an invalid event") from error
|
||||
|
||||
|
||||
def _close_transport(transport: Any) -> None:
|
||||
try:
|
||||
transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _close_code(error: BaseException) -> int | None:
|
||||
code = getattr(error, "code", None)
|
||||
if isinstance(code, int):
|
||||
return code
|
||||
received = getattr(error, "rcvd", None)
|
||||
code = getattr(received, "code", None)
|
||||
return code if isinstance(code, int) else None
|
||||
|
||||
|
||||
class MetaTurn(QueuedCloudTurn):
|
||||
sample_rate_hz = META_SAMPLE_RATE_HZ
|
||||
retain_pcm = False
|
||||
|
||||
def _receive_provider_event(self, *, timeout: float) -> dict[str, Any] | None | object:
|
||||
event = _receive_event(self._transport, timeout=timeout, tolerate_timeout=True)
|
||||
if event is RECEIVE_TIMEOUT or event is None:
|
||||
return event
|
||||
_validate_meta_event(event)
|
||||
if event.get("type") == "error" or "error" in event:
|
||||
raise _meta_event_error(event)
|
||||
if event.get("type") == "transcript" and event.get("final") is True:
|
||||
return event
|
||||
# Partials, progress, endpointing, and diarization may be frequent.
|
||||
# Validate and discard them during capture instead of retaining an
|
||||
# unbounded session history for finish().
|
||||
return RECEIVE_TIMEOUT
|
||||
|
||||
@property
|
||||
def _provider_prefix(self) -> str:
|
||||
return "meta"
|
||||
|
||||
def _send_audio(self, chunk: bytes) -> None:
|
||||
try:
|
||||
self._transport.send(chunk)
|
||||
except Exception as error:
|
||||
raise ProviderError("meta_send", "could not send Meta audio") from error
|
||||
|
||||
def _send_end(self) -> None:
|
||||
_send_json(self._transport, {"type": "endStream"})
|
||||
|
||||
def _finish_provider(self) -> str:
|
||||
deadline = time.monotonic() + self._timeout
|
||||
transcript: str | None = None
|
||||
while time.monotonic() < deadline:
|
||||
event = self._next_provider_event(max(0.01, deadline - time.monotonic()))
|
||||
if event is None:
|
||||
if transcript is None:
|
||||
raise ProviderError("meta_premature_close", "Meta closed without a final transcript")
|
||||
return transcript
|
||||
_validate_meta_event(event)
|
||||
event_type = event["type"]
|
||||
if event_type == "error":
|
||||
raise _meta_event_error(event)
|
||||
if event_type != "transcript":
|
||||
# Partial, endpoint, and diarization events are intentionally
|
||||
# consumed but never exposed to the Pi protocol.
|
||||
continue
|
||||
if event.get("final") is not True:
|
||||
continue
|
||||
value = event.get("text", event.get("transcript"))
|
||||
if isinstance(value, Mapping):
|
||||
value = value.get("text")
|
||||
try:
|
||||
transcript = bounded_provider_text(value)
|
||||
except ProviderError as error:
|
||||
raise ProviderError("meta_malformed_event", "Meta sent an invalid transcript") from error
|
||||
# Keep reading until the provider's normal close. A subsequent
|
||||
# error or abnormal close therefore cannot be mistaken for success.
|
||||
raise ProviderError("meta_timeout", "timed out waiting for Meta final transcript")
|
||||
|
||||
|
||||
def _validate_meta_event(event: Mapping[str, Any]) -> None:
|
||||
event_type = event.get("type")
|
||||
if not isinstance(event_type, str) or not event_type or len(event_type.encode("utf-8")) > 128:
|
||||
raise ProviderError("meta_malformed_event", "Meta event type is invalid")
|
||||
if event_type == "transcript":
|
||||
if not isinstance(event.get("final"), bool):
|
||||
raise ProviderError("meta_malformed_event", "Meta transcript final flag is invalid")
|
||||
if "text" not in event and "transcript" not in event:
|
||||
raise ProviderError("meta_malformed_event", "Meta transcript text is missing")
|
||||
if event_type == "error" or "error" in event:
|
||||
nested = event.get("error")
|
||||
if nested is not None and not isinstance(nested, Mapping):
|
||||
raise ProviderError("meta_malformed_event", "Meta error event is invalid")
|
||||
value: Any = event.get("code", event.get("errorCode"))
|
||||
if isinstance(nested, Mapping):
|
||||
value = nested.get("code", nested.get("type", value))
|
||||
if not isinstance(value, (str, int)):
|
||||
raise ProviderError("meta_malformed_event", "Meta error code is invalid")
|
||||
|
||||
|
||||
def _meta_event_error(event: Mapping[str, Any]) -> ProviderError:
|
||||
value: Any = event.get("code", event.get("errorCode"))
|
||||
nested = event.get("error")
|
||||
if isinstance(nested, Mapping):
|
||||
value = nested.get("code", nested.get("type", value))
|
||||
normalized = str(value).lower() if isinstance(value, (str, int)) else ""
|
||||
if "auth" in normalized or "token" in normalized or normalized in {"401", "403"}:
|
||||
code = "meta_auth"
|
||||
elif "rate" in normalized or normalized in {"429", "1013"}:
|
||||
code = "meta_rate_limit"
|
||||
else:
|
||||
code = "meta_error"
|
||||
return ProviderError(code, "Meta transcription service reported an error")
|
||||
|
||||
|
||||
class MetaBackend:
|
||||
sample_rate_hz = META_SAMPLE_RATE_HZ
|
||||
descriptor = BackendDescriptor("meta", META_MODEL)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: MetaConfig,
|
||||
*,
|
||||
transport_factory: TransportFactory = _default_transport,
|
||||
timeout: float = META_EVENT_TIMEOUT,
|
||||
max_queue_bytes: int = META_MAX_BACKLOG_BYTES,
|
||||
max_queue_chunks: int = 64,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self._transport_factory = transport_factory
|
||||
self._timeout = timeout
|
||||
if max_queue_bytes > META_MAX_BACKLOG_BYTES:
|
||||
raise ValueError("Meta backlog must not exceed five seconds (240000 bytes)")
|
||||
self._max_queue_bytes = max_queue_bytes
|
||||
self._max_queue_chunks = max_queue_chunks
|
||||
self._prepare_lock = threading.Lock()
|
||||
self._preparing_transport: Any | None = None
|
||||
self._prepare_token = 0
|
||||
self._reserved_prepare_token: int | None = None
|
||||
|
||||
def begin_prepare(self) -> None:
|
||||
with self._prepare_lock:
|
||||
self._prepare_token += 1
|
||||
self._reserved_prepare_token = self._prepare_token
|
||||
|
||||
def cancel_prepare(self) -> None:
|
||||
with self._prepare_lock:
|
||||
self._prepare_token += 1
|
||||
transport = self._preparing_transport
|
||||
self._preparing_transport = None
|
||||
if transport is not None:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-meta-prepare-close",
|
||||
).start()
|
||||
|
||||
def start_turn(self) -> MetaTurn:
|
||||
transport: Any | None = None
|
||||
with self._prepare_lock:
|
||||
if self._reserved_prepare_token is None:
|
||||
self._prepare_token += 1
|
||||
prepare_token = self._prepare_token
|
||||
else:
|
||||
prepare_token = self._reserved_prepare_token
|
||||
self._reserved_prepare_token = None
|
||||
cancelled_before_start = prepare_token != self._prepare_token
|
||||
if cancelled_before_start:
|
||||
raise ProviderError("cancelled", "Meta preparation was cancelled")
|
||||
try:
|
||||
key = read_credential(self.config.api_key_file)
|
||||
keywords = provider_keywords(self.config.terms_path)
|
||||
transport = self._connect()
|
||||
with self._prepare_lock:
|
||||
if prepare_token != self._prepare_token:
|
||||
cancelled = True
|
||||
else:
|
||||
cancelled = False
|
||||
self._preparing_transport = transport
|
||||
if cancelled:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-meta-cancelled-connect-close",
|
||||
).start()
|
||||
raise ProviderError("cancelled", "Meta preparation was cancelled")
|
||||
handshake: dict[str, Any] = {
|
||||
"authorization": {"accessToken": f"Bearer {key}"},
|
||||
"audioEncoding": "PCM_24KHZ",
|
||||
"model": self.config.model,
|
||||
"mode": "PUSH_TO_TALK",
|
||||
"partialMode": "CUMULATIVE",
|
||||
"emitAudioProgress": False,
|
||||
}
|
||||
if keywords:
|
||||
handshake["keywords"] = keywords
|
||||
language_bias = getattr(self.config, "language_bias", ())
|
||||
if language_bias:
|
||||
handshake["languageBias"] = list(language_bias)
|
||||
_send_json(transport, handshake)
|
||||
deadline = time.monotonic() + self._timeout
|
||||
while time.monotonic() < deadline:
|
||||
event = _receive_event(transport, timeout=max(0.01, deadline - time.monotonic()))
|
||||
if event is None:
|
||||
raise ProviderError("meta_premature_close", "Meta closed before session acknowledgement")
|
||||
if not isinstance(event, dict):
|
||||
raise ProviderError("meta_malformed_event", "Meta session acknowledgement is invalid")
|
||||
if event.get("type") == "error" or "error" in event:
|
||||
raise _meta_event_error(event)
|
||||
if set(event) != {"sessionId"} or not isinstance(event.get("sessionId"), str) or not event["sessionId"]:
|
||||
raise ProviderError("meta_malformed_event", "Meta session acknowledgement is invalid")
|
||||
if len(event["sessionId"].encode("utf-8")) > 128:
|
||||
raise ProviderError("meta_malformed_event", "Meta session acknowledgement is too large")
|
||||
if isinstance(event.get("sessionId"), str) and event["sessionId"]:
|
||||
with self._prepare_lock:
|
||||
if prepare_token != self._prepare_token:
|
||||
cancelled = True
|
||||
else:
|
||||
cancelled = False
|
||||
self._preparing_transport = None
|
||||
if cancelled:
|
||||
raise ProviderError("cancelled", "Meta preparation was cancelled")
|
||||
return MetaTurn(
|
||||
transport,
|
||||
timeout=self._timeout,
|
||||
max_queue_bytes=self._max_queue_bytes,
|
||||
max_queue_chunks=self._max_queue_chunks,
|
||||
)
|
||||
raise ProviderError("meta_timeout", "timed out waiting for Meta session acknowledgement")
|
||||
except CredentialError as error:
|
||||
raise ProviderError("meta_credentials", "Meta credential is unavailable") from error
|
||||
except ProviderError:
|
||||
with self._prepare_lock:
|
||||
if prepare_token == self._prepare_token and self._preparing_transport is transport:
|
||||
self._preparing_transport = None
|
||||
if transport is not None:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-meta-prepare-error-close",
|
||||
).start()
|
||||
raise
|
||||
except Exception as error:
|
||||
with self._prepare_lock:
|
||||
if prepare_token == self._prepare_token and self._preparing_transport is transport:
|
||||
self._preparing_transport = None
|
||||
if transport is not None:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-meta-prepare-failure-close",
|
||||
).start()
|
||||
raise ProviderError("meta_connect", "could not prepare Meta transcription session") from error
|
||||
|
||||
def _connect(self) -> Any:
|
||||
try:
|
||||
return self._transport_factory(META_ENDPOINT)
|
||||
except TypeError:
|
||||
# Small test transports often accept no keyword arguments.
|
||||
return self._transport_factory(META_ENDPOINT, headers={})
|
||||
except ProviderError:
|
||||
raise
|
||||
except Exception as error:
|
||||
response = getattr(error, "response", None)
|
||||
status = getattr(response, "status_code", getattr(error, "status_code", getattr(error, "status", None)))
|
||||
if status in {401, 403}:
|
||||
raise ProviderError("meta_auth", "Meta authentication failed") from error
|
||||
if status == 429:
|
||||
raise ProviderError("meta_rate_limit", "Meta transcription rate limit reached") from error
|
||||
raise ProviderError("meta_connect", "could not connect to Meta transcription service") from error
|
||||
|
||||
|
||||
__all__ = ["META_ENDPOINT", "META_URL", "META_MODEL", "META_MAX_BACKLOG_BYTES", "META_HANDSHAKE_TIMEOUT", "META_EVENT_TIMEOUT", "META_SOCKET_IO_TIMEOUT", "MetaBackend", "MetaTurn"]
|
||||
@@ -0,0 +1,461 @@
|
||||
"""OpenAI GA Realtime transcription adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from queue import Empty
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from ..backend import (
|
||||
BackendDescriptor,
|
||||
MAX_PROVIDER_EVENT_BYTES,
|
||||
ProviderError,
|
||||
QueuedCloudTurn,
|
||||
RECEIVE_TIMEOUT,
|
||||
bounded_provider_text,
|
||||
provider_event,
|
||||
)
|
||||
from ..config import OPENAI_DELAYS, CredentialError, OpenAIConfig, provider_keywords, read_credential
|
||||
|
||||
OPENAI_ENDPOINT = "wss://api.openai.com/v1/realtime?intent=transcription"
|
||||
OPENAI_SAMPLE_RATE_HZ = 24_000
|
||||
OPENAI_DEFAULT_MODEL = "gpt-live-transcribe"
|
||||
OPENAI_URL = OPENAI_ENDPOINT
|
||||
# Keep serialized append events comfortably below the provider limit and our
|
||||
# own provider-event bound. Recorder chunks are 4.8 KiB at 24 kHz.
|
||||
MAX_APPEND_BYTES = 32 * 1024
|
||||
OPENAI_SOCKET_IO_TIMEOUT = 10.0
|
||||
|
||||
TransportFactory = Callable[..., Any]
|
||||
|
||||
|
||||
def _default_transport(endpoint: str, *, headers: Mapping[str, str] | None = None, **_kwargs: Any) -> Any:
|
||||
try:
|
||||
from websockets.sync.client import connect
|
||||
except ImportError as error:
|
||||
raise ProviderError("openai_dependency", "OpenAI backend websocket dependency is unavailable") from error
|
||||
try:
|
||||
logger = logging.getLogger("pi_dictate_service.provider.openai")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.propagate = False
|
||||
transport = connect(
|
||||
endpoint,
|
||||
additional_headers=dict(headers or {}),
|
||||
open_timeout=15,
|
||||
close_timeout=5,
|
||||
max_size=MAX_PROVIDER_EVENT_BYTES,
|
||||
proxy=None,
|
||||
logger=logger,
|
||||
)
|
||||
transport.socket.settimeout(OPENAI_SOCKET_IO_TIMEOUT)
|
||||
return transport
|
||||
except Exception as error:
|
||||
response = getattr(error, "response", None)
|
||||
status = getattr(response, "status_code", getattr(error, "status_code", getattr(error, "status", None)))
|
||||
if status in {401, 403}:
|
||||
raise ProviderError("openai_auth", "OpenAI authentication failed") from error
|
||||
if status == 429:
|
||||
raise ProviderError("openai_rate_limit", "OpenAI transcription rate limit reached") from error
|
||||
raise ProviderError("openai_connect", "could not connect to OpenAI transcription service") from error
|
||||
|
||||
|
||||
def _send_json(transport: Any, event: Mapping[str, Any]) -> None:
|
||||
try:
|
||||
payload = json.dumps(dict(event), separators=(",", ":"), ensure_ascii=False)
|
||||
if len(payload.encode("utf-8")) > MAX_PROVIDER_EVENT_BYTES:
|
||||
raise ProviderError("openai_event_too_large", "OpenAI transcription event is too large")
|
||||
transport.send(payload)
|
||||
except ProviderError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProviderError("openai_send", "could not send OpenAI transcription event") from error
|
||||
|
||||
|
||||
def _receive_event(
|
||||
transport: Any, *, timeout: float, tolerate_timeout: bool = False
|
||||
) -> dict[str, Any] | None | object:
|
||||
try:
|
||||
try:
|
||||
raw = transport.recv(timeout=timeout)
|
||||
except TypeError:
|
||||
raw = transport.recv()
|
||||
except (TimeoutError, Empty) as error:
|
||||
if tolerate_timeout:
|
||||
return RECEIVE_TIMEOUT
|
||||
raise ProviderError("openai_timeout", "timed out waiting for OpenAI transcription service") from error
|
||||
except Exception as error:
|
||||
code = _close_code(error)
|
||||
if code == 1000:
|
||||
return None
|
||||
if code in {1008, 1011, 1013}:
|
||||
raise ProviderError(f"openai_close_{code}", "OpenAI transcription service closed the session") from error
|
||||
raise ProviderError("openai_premature_close", "OpenAI transcription service closed unexpectedly") from error
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
try:
|
||||
return provider_event(raw)
|
||||
except ProviderError as error:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an invalid event") from error
|
||||
if isinstance(raw, bytes):
|
||||
if len(raw) > MAX_PROVIDER_EVENT_BYTES:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an oversized event")
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an invalid event") from error
|
||||
if not isinstance(raw, str):
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an invalid event")
|
||||
try:
|
||||
if len(raw.encode("utf-8")) > MAX_PROVIDER_EVENT_BYTES:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an oversized event")
|
||||
except UnicodeEncodeError as error:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an invalid event") from error
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, ValueError, RecursionError) as error:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent malformed event JSON") from error
|
||||
try:
|
||||
return provider_event(value)
|
||||
except ProviderError as error:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an invalid event") from error
|
||||
|
||||
|
||||
def _close_transport(transport: Any) -> None:
|
||||
try:
|
||||
transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _close_code(error: BaseException) -> int | None:
|
||||
code = getattr(error, "code", None)
|
||||
if isinstance(code, int):
|
||||
return code
|
||||
received = getattr(error, "rcvd", None)
|
||||
code = getattr(received, "code", None)
|
||||
return code if isinstance(code, int) else None
|
||||
|
||||
|
||||
class OpenAITurn(QueuedCloudTurn):
|
||||
sample_rate_hz = OPENAI_SAMPLE_RATE_HZ
|
||||
retain_pcm = False
|
||||
|
||||
def __init__(self, transport: Any, *, timeout: float = 15.0, **kwargs: Any) -> None:
|
||||
self._committed_item_id: str | None = None
|
||||
super().__init__(transport, timeout=timeout, **kwargs)
|
||||
|
||||
def _receive_provider_event(self, *, timeout: float) -> dict[str, Any] | None | object:
|
||||
event = _receive_event(self._transport, timeout=timeout, tolerate_timeout=True)
|
||||
if event is RECEIVE_TIMEOUT or event is None:
|
||||
return event
|
||||
_validate_openai_event(event)
|
||||
if event.get("type") == "error" or event.get("type") == "conversation.item.input_audio_transcription.failed":
|
||||
raise _openai_event_error(event)
|
||||
if event.get("type") in {
|
||||
"input_audio_buffer.committed",
|
||||
"conversation.item.input_audio_transcription.completed",
|
||||
}:
|
||||
return event
|
||||
# Deltas and unrelated conversation events can be frequent. Validate
|
||||
# and discard them during capture rather than queueing session history.
|
||||
return RECEIVE_TIMEOUT
|
||||
|
||||
@property
|
||||
def _provider_prefix(self) -> str:
|
||||
return "openai"
|
||||
|
||||
def _send_audio(self, chunk: bytes) -> None:
|
||||
if len(chunk) > MAX_APPEND_BYTES:
|
||||
raise ProviderError("openai_audio_chunk", "OpenAI audio chunk is too large")
|
||||
event = {
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(chunk).decode("ascii"),
|
||||
}
|
||||
_send_json(self._transport, event)
|
||||
|
||||
def _send_end(self) -> None:
|
||||
# Commit starts transcription and deliberately does not create an
|
||||
# assistant response. Never send response.create here.
|
||||
_send_json(self._transport, {"type": "input_audio_buffer.commit"})
|
||||
|
||||
def _finish_provider(self) -> str:
|
||||
deadline = time.monotonic() + self._timeout
|
||||
transcript: str | None = None
|
||||
while time.monotonic() < deadline:
|
||||
event = self._next_provider_event(max(0.01, deadline - time.monotonic()))
|
||||
if event is None:
|
||||
if transcript is None:
|
||||
raise ProviderError("openai_premature_close", "OpenAI closed without a final transcript")
|
||||
return transcript
|
||||
_validate_openai_event(event)
|
||||
event_type = event["type"]
|
||||
if event_type == "error" or event_type == "conversation.item.input_audio_transcription.failed":
|
||||
raise _openai_event_error(event)
|
||||
if event_type == "input_audio_buffer.committed":
|
||||
item_id = event.get("item_id")
|
||||
if not isinstance(item_id, str) or not item_id:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI commit acknowledgement is invalid")
|
||||
if len(item_id.encode("utf-8")) > 128:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI commit acknowledgement is too large")
|
||||
if self._committed_item_id is not None and self._committed_item_id != item_id:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI returned conflicting commit acknowledgements")
|
||||
self._committed_item_id = item_id
|
||||
continue
|
||||
if event_type == "conversation.item.input_audio_transcription.completed":
|
||||
item_id = event.get("item_id")
|
||||
if not isinstance(item_id, str) or not item_id:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI completion correlation is invalid")
|
||||
if "transcript" not in event:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI completion transcript is missing")
|
||||
try:
|
||||
text_value = bounded_provider_text(event["transcript"])
|
||||
except ProviderError as error:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI sent an invalid transcript") from error
|
||||
if self._committed_item_id == item_id:
|
||||
# OpenAI keeps the realtime connection reusable after a
|
||||
# completed transcription. The turn is done as soon as
|
||||
# its correlated final arrives; finish() closes it.
|
||||
return text_value
|
||||
# Unmatched completions are stale events. They are validated
|
||||
# but never allowed to authorize an active generation.
|
||||
continue
|
||||
# Deltas and unrelated conversation events are intentionally ignored.
|
||||
raise ProviderError("openai_timeout", "timed out waiting for OpenAI final transcript")
|
||||
|
||||
|
||||
def _validate_openai_event(event: Mapping[str, Any]) -> None:
|
||||
event_type = event.get("type")
|
||||
if not isinstance(event_type, str) or not event_type or len(event_type.encode("utf-8")) > 128:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI event type is invalid")
|
||||
if event_type == "error" or event_type == "conversation.item.input_audio_transcription.failed":
|
||||
error = event.get("error")
|
||||
if not isinstance(error, Mapping):
|
||||
raise ProviderError("openai_malformed_event", "OpenAI error event is invalid")
|
||||
code = error.get("code", error.get("type"))
|
||||
if not isinstance(code, (str, int)):
|
||||
raise ProviderError("openai_malformed_event", "OpenAI error event has an invalid code")
|
||||
if isinstance(code, str) and len(code.encode("utf-8")) > 128:
|
||||
raise ProviderError("openai_malformed_event", "OpenAI error event code is too large")
|
||||
|
||||
|
||||
def _openai_event_error(event: Mapping[str, Any]) -> ProviderError:
|
||||
error = event.get("error")
|
||||
if not isinstance(error, Mapping):
|
||||
raise ProviderError("openai_malformed_event", "OpenAI error event is invalid")
|
||||
code_value: Any = error.get("code", error.get("type", event.get("code")))
|
||||
normalized = str(code_value).lower() if isinstance(code_value, (str, int)) else ""
|
||||
if "auth" in normalized or "unauthor" in normalized or normalized in {"401", "403"}:
|
||||
code = "openai_auth"
|
||||
elif "rate" in normalized or normalized in {"429"}:
|
||||
code = "openai_rate_limit"
|
||||
else:
|
||||
code = "openai_error"
|
||||
return ProviderError(code, "OpenAI transcription service reported an error")
|
||||
|
||||
|
||||
class OpenAIBackend:
|
||||
sample_rate_hz = OPENAI_SAMPLE_RATE_HZ
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenAIConfig,
|
||||
*,
|
||||
transport_factory: TransportFactory = _default_transport,
|
||||
timeout: float = 15.0,
|
||||
max_queue_bytes: int = 240_000,
|
||||
max_queue_chunks: int = 64,
|
||||
) -> None:
|
||||
if config.delay is not None and config.delay not in OPENAI_DELAYS:
|
||||
raise ValueError("unsupported OpenAI delay")
|
||||
self.config = config
|
||||
self.descriptor = BackendDescriptor("openai", config.model)
|
||||
self._transport_factory = transport_factory
|
||||
self._timeout = timeout
|
||||
if max_queue_bytes > 240_000:
|
||||
raise ValueError("OpenAI PCM backlog must not exceed five seconds (240000 bytes)")
|
||||
self._max_queue_bytes = max_queue_bytes
|
||||
self._max_queue_chunks = max_queue_chunks
|
||||
self._prepare_lock = threading.Lock()
|
||||
self._preparing_transport: Any | None = None
|
||||
self._prepare_token = 0
|
||||
self._reserved_prepare_token: int | None = None
|
||||
|
||||
def begin_prepare(self) -> None:
|
||||
with self._prepare_lock:
|
||||
self._prepare_token += 1
|
||||
self._reserved_prepare_token = self._prepare_token
|
||||
|
||||
def cancel_prepare(self) -> None:
|
||||
with self._prepare_lock:
|
||||
self._prepare_token += 1
|
||||
transport = self._preparing_transport
|
||||
self._preparing_transport = None
|
||||
if transport is not None:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-openai-prepare-close",
|
||||
).start()
|
||||
|
||||
def start_turn(self) -> OpenAITurn:
|
||||
transport: Any | None = None
|
||||
with self._prepare_lock:
|
||||
if self._reserved_prepare_token is None:
|
||||
self._prepare_token += 1
|
||||
prepare_token = self._prepare_token
|
||||
else:
|
||||
prepare_token = self._reserved_prepare_token
|
||||
self._reserved_prepare_token = None
|
||||
cancelled_before_start = prepare_token != self._prepare_token
|
||||
if cancelled_before_start:
|
||||
raise ProviderError("cancelled", "OpenAI preparation was cancelled")
|
||||
try:
|
||||
key = read_credential(self.config.api_key_file)
|
||||
keywords = provider_keywords(self.config.terms_path)
|
||||
transport = self._connect(key)
|
||||
with self._prepare_lock:
|
||||
if prepare_token != self._prepare_token:
|
||||
cancelled = True
|
||||
else:
|
||||
cancelled = False
|
||||
self._preparing_transport = transport
|
||||
if cancelled:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-openai-cancelled-connect-close",
|
||||
).start()
|
||||
raise ProviderError("cancelled", "OpenAI preparation was cancelled")
|
||||
self._wait_for_session_created(transport)
|
||||
_send_json(transport, self._session_update(keywords))
|
||||
self._wait_for_session_updated(transport)
|
||||
with self._prepare_lock:
|
||||
if prepare_token != self._prepare_token:
|
||||
cancelled = True
|
||||
else:
|
||||
cancelled = False
|
||||
self._preparing_transport = None
|
||||
if cancelled:
|
||||
raise ProviderError("cancelled", "OpenAI preparation was cancelled")
|
||||
return OpenAITurn(
|
||||
transport,
|
||||
timeout=self._timeout,
|
||||
max_queue_bytes=self._max_queue_bytes,
|
||||
max_queue_chunks=self._max_queue_chunks,
|
||||
)
|
||||
except CredentialError as error:
|
||||
raise ProviderError("openai_credentials", "OpenAI credential is unavailable") from error
|
||||
except ProviderError:
|
||||
with self._prepare_lock:
|
||||
if prepare_token == self._prepare_token and self._preparing_transport is transport:
|
||||
self._preparing_transport = None
|
||||
if transport is not None:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-openai-prepare-error-close",
|
||||
).start()
|
||||
raise
|
||||
except Exception as error:
|
||||
with self._prepare_lock:
|
||||
if prepare_token == self._prepare_token and self._preparing_transport is transport:
|
||||
self._preparing_transport = None
|
||||
if transport is not None:
|
||||
threading.Thread(
|
||||
target=_close_transport,
|
||||
args=(transport,),
|
||||
daemon=True,
|
||||
name="dictate-openai-prepare-failure-close",
|
||||
).start()
|
||||
raise ProviderError("openai_connect", "could not prepare OpenAI transcription session") from error
|
||||
|
||||
def _connect(self, key: str) -> Any:
|
||||
try:
|
||||
return self._transport_factory(
|
||||
OPENAI_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
except TypeError as error:
|
||||
# Test transports may accept positional headers, but never fall
|
||||
# back to an unauthenticated connection.
|
||||
try:
|
||||
return self._transport_factory(OPENAI_ENDPOINT, {"Authorization": f"Bearer {key}"})
|
||||
except TypeError as positional_error:
|
||||
raise ProviderError("openai_connect", "OpenAI transport does not accept authenticated headers") from positional_error
|
||||
except ProviderError:
|
||||
raise
|
||||
except Exception as error:
|
||||
response = getattr(error, "response", None)
|
||||
status = getattr(response, "status_code", getattr(error, "status_code", getattr(error, "status", None)))
|
||||
if status in {401, 403}:
|
||||
raise ProviderError("openai_auth", "OpenAI authentication failed") from error
|
||||
if status == 429:
|
||||
raise ProviderError("openai_rate_limit", "OpenAI transcription rate limit reached") from error
|
||||
raise ProviderError("openai_connect", "could not connect to OpenAI transcription service") from error
|
||||
|
||||
def _wait_for_session_created(self, transport: Any) -> None:
|
||||
deadline = time.monotonic() + self._timeout
|
||||
while time.monotonic() < deadline:
|
||||
event = _receive_event(transport, timeout=max(0.01, deadline - time.monotonic()))
|
||||
if event is None:
|
||||
raise ProviderError("openai_premature_close", "OpenAI closed before session creation")
|
||||
_validate_openai_event(event)
|
||||
event_type = event["type"]
|
||||
if event_type == "error" or "error" in event:
|
||||
raise _openai_event_error(event)
|
||||
if event_type == "session.created":
|
||||
return
|
||||
raise ProviderError("openai_timeout", "timed out waiting for OpenAI session creation")
|
||||
|
||||
def _wait_for_session_updated(self, transport: Any) -> None:
|
||||
deadline = time.monotonic() + self._timeout
|
||||
while time.monotonic() < deadline:
|
||||
event = _receive_event(transport, timeout=max(0.01, deadline - time.monotonic()))
|
||||
if event is None:
|
||||
raise ProviderError("openai_premature_close", "OpenAI closed before session update")
|
||||
_validate_openai_event(event)
|
||||
event_type = event["type"]
|
||||
if event_type == "error" or "error" in event:
|
||||
raise _openai_event_error(event)
|
||||
if event_type == "session.updated":
|
||||
return
|
||||
raise ProviderError("openai_timeout", "timed out waiting for OpenAI session update")
|
||||
|
||||
def _session_update(self, keywords: list[str]) -> dict[str, Any]:
|
||||
transcription: dict[str, Any] = {"model": self.config.model}
|
||||
prompt = getattr(self.config, "prompt", None)
|
||||
languages = getattr(self.config, "languages", ())
|
||||
if prompt:
|
||||
transcription["prompt"] = prompt
|
||||
if keywords:
|
||||
transcription["keywords"] = keywords
|
||||
if languages:
|
||||
transcription["languages"] = list(languages)
|
||||
if self.config.delay:
|
||||
transcription["delay"] = self.config.delay
|
||||
return {
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"type": "transcription",
|
||||
"audio": {
|
||||
"input": {
|
||||
"format": {"type": "audio/pcm", "rate": OPENAI_SAMPLE_RATE_HZ},
|
||||
"transcription": transcription,
|
||||
"turn_detection": None,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OPENAI_DEFAULT_MODEL", "OPENAI_ENDPOINT", "OPENAI_URL", "OPENAI_SAMPLE_RATE_HZ", "OPENAI_SOCKET_IO_TIMEOUT", "OpenAIBackend", "OpenAITurn",
|
||||
]
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Strict host-side configuration and credential handling for Dictate.
|
||||
|
||||
This module intentionally has no dependency on the provider SDKs. It is loaded by
|
||||
the socket service, outside Pi's sandbox, and never serializes credentials onto
|
||||
the protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
import unicodedata
|
||||
|
||||
from .model import (
|
||||
MAX_CODING_TERMS,
|
||||
MAX_TERMS_FILE_BYTES,
|
||||
parse_coding_terms,
|
||||
)
|
||||
|
||||
CONFIG_VERSION = 1
|
||||
DEFAULT_IDLE_SECONDS = 15 * 60
|
||||
MIN_IDLE_SECONDS = 60
|
||||
MAX_IDLE_SECONDS = 24 * 60 * 60
|
||||
MAX_CONFIG_BYTES = 64 * 1024
|
||||
MAX_CREDENTIAL_BYTES = 4096
|
||||
MAX_PROMPT_BYTES = 4096
|
||||
MAX_LANGUAGE_BYTES = 32
|
||||
MAX_LANGUAGES = 16
|
||||
MAX_KEYWORDS = MAX_CODING_TERMS
|
||||
MAX_PROVIDER_KEYWORDS_BYTES = 16 * 1024
|
||||
MAX_MODEL_BYTES = 128
|
||||
|
||||
_CONFIG_FIELDS = frozenset({"version", "backend", "idleSeconds", "local", "meta", "openai"})
|
||||
_LOCAL_FIELDS = frozenset({"adapter", "modelPath", "modelSha256", "termsPath", "boostAlpha"})
|
||||
_META_FIELDS = frozenset({"model", "apiKeyFile", "termsPath", "languageBias"})
|
||||
# ``delay`` is intentionally not accepted until the conflicting OpenAI
|
||||
# documentation is resolved by a credentialed live spike. It must not be
|
||||
# silently accepted and then omitted from the wire request.
|
||||
_OPENAI_FIELDS = frozenset({"model", "apiKeyFile", "prompt", "termsPath", "languages", "delay"})
|
||||
OPENAI_DELAYS = frozenset({"minimal", "low", "medium", "high", "xhigh"})
|
||||
_HEX_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""A safe, user-facing configuration failure (never includes a key)."""
|
||||
|
||||
|
||||
class CredentialError(ConfigError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalConfig:
|
||||
adapter: str
|
||||
model_path: Path
|
||||
model_sha256: str
|
||||
terms_path: Path
|
||||
boost_alpha: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetaConfig:
|
||||
model: str
|
||||
api_key_file: Path
|
||||
terms_path: Path
|
||||
language_bias: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenAIConfig:
|
||||
model: str
|
||||
api_key_file: Path
|
||||
terms_path: Path
|
||||
delay: str | None = None
|
||||
prompt: str | None = None
|
||||
languages: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DictateConfig:
|
||||
backend: str
|
||||
idle_seconds: float
|
||||
local: LocalConfig | None = None
|
||||
meta: MetaConfig | None = None
|
||||
openai: OpenAIConfig | None = None
|
||||
version: int = CONFIG_VERSION
|
||||
|
||||
@property
|
||||
def selected(self) -> LocalConfig | MetaConfig | OpenAIConfig:
|
||||
selected = getattr(self, self.backend)
|
||||
if selected is None:
|
||||
raise ConfigError("selected backend configuration is missing")
|
||||
return selected
|
||||
|
||||
|
||||
def _string(value: Any, field: str, *, nonempty: bool = True) -> str:
|
||||
if not isinstance(value, str) or (nonempty and not value):
|
||||
raise ConfigError(f"{field} must be a non-empty string")
|
||||
try:
|
||||
value.encode("utf-8")
|
||||
except UnicodeEncodeError as error:
|
||||
raise ConfigError(f"{field} must contain valid UTF-8") from error
|
||||
if "\x00" in value or "\r" in value or "\n" in value:
|
||||
raise ConfigError(f"{field} contains unsupported control characters")
|
||||
return value
|
||||
|
||||
|
||||
def _path(value: Any, field: str) -> Path:
|
||||
path = Path(_string(value, field)).expanduser()
|
||||
if not path.is_absolute():
|
||||
raise ConfigError(f"{field} must be an absolute path")
|
||||
return path
|
||||
|
||||
|
||||
def _section(value: Any, field: str, fields: frozenset[str]) -> Mapping[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ConfigError(f"{field} configuration is required")
|
||||
unknown = set(value) - fields
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown {field} configuration key")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_string(section: Mapping[str, Any], field: str, default: str | None = None) -> str | None:
|
||||
value = section.get(field, default)
|
||||
if value is None:
|
||||
return None
|
||||
return _string(value, field)
|
||||
|
||||
|
||||
def parse_config(value: Any, *, config_path: str | os.PathLike[str] | None = None) -> DictateConfig:
|
||||
"""Parse a strict versioned configuration mapping without reading credentials."""
|
||||
|
||||
if not isinstance(value, Mapping):
|
||||
raise ConfigError("configuration root must be an object")
|
||||
unknown = set(value) - _CONFIG_FIELDS
|
||||
if unknown:
|
||||
raise ConfigError("unknown configuration key")
|
||||
version = value.get("version")
|
||||
if version != CONFIG_VERSION or isinstance(version, bool):
|
||||
raise ConfigError(f"unsupported configuration version (expected {CONFIG_VERSION})")
|
||||
backend = value.get("backend")
|
||||
if not isinstance(backend, str) or backend not in {"local", "meta", "openai"}:
|
||||
raise ConfigError("backend must be local, meta, or openai")
|
||||
idle = value.get("idleSeconds", DEFAULT_IDLE_SECONDS)
|
||||
if isinstance(idle, bool) or not isinstance(idle, int):
|
||||
raise ConfigError("idleSeconds must be an integer")
|
||||
if not MIN_IDLE_SECONDS <= float(idle) <= MAX_IDLE_SECONDS:
|
||||
raise ConfigError("idleSeconds is out of bounds")
|
||||
|
||||
local: LocalConfig | None = None
|
||||
meta: MetaConfig | None = None
|
||||
openai: OpenAIConfig | None = None
|
||||
if backend == "local":
|
||||
section = _section(value.get("local"), "local", _LOCAL_FIELDS)
|
||||
adapter = _string(section.get("adapter"), "local.adapter")
|
||||
if adapter != "nemo-parakeet":
|
||||
raise ConfigError("unsupported local adapter")
|
||||
model_path = _path(section.get("modelPath"), "local.modelPath")
|
||||
if model_path.suffix != ".nemo":
|
||||
raise ConfigError("local.modelPath must name a .nemo archive")
|
||||
model_sha = _string(section.get("modelSha256"), "local.modelSha256")
|
||||
if not _HEX_SHA256.fullmatch(model_sha):
|
||||
raise ConfigError("local.modelSha256 must be a SHA-256 digest")
|
||||
terms_path = _path(section.get("termsPath"), "local.termsPath")
|
||||
boost = section.get("boostAlpha")
|
||||
if isinstance(boost, bool) or not isinstance(boost, (int, float)) or not math.isfinite(float(boost)):
|
||||
raise ConfigError("local.boostAlpha must be a finite number")
|
||||
if not 0 < float(boost) <= 10:
|
||||
raise ConfigError("local.boostAlpha is out of bounds")
|
||||
local = LocalConfig(adapter, model_path, model_sha.lower(), terms_path, float(boost))
|
||||
elif backend == "meta":
|
||||
section = _section(value.get("meta"), "meta", _META_FIELDS)
|
||||
model = _string(section.get("model"), "meta.model")
|
||||
if model != "muse-voice-transcribe-1.0":
|
||||
raise ConfigError("unsupported Meta model")
|
||||
api_key_file = _path(section.get("apiKeyFile"), "meta.apiKeyFile")
|
||||
terms_path = _path(section.get("termsPath"), "meta.termsPath")
|
||||
language_bias_value = section.get("languageBias", ())
|
||||
if not isinstance(language_bias_value, (list, tuple)) or len(language_bias_value) > MAX_LANGUAGES:
|
||||
raise ConfigError("meta.languageBias must be a bounded array")
|
||||
language_bias: list[str] = []
|
||||
for language in language_bias_value:
|
||||
language_value = _string(language, "meta.languageBias entry")
|
||||
if len(language_value.encode("utf-8")) > MAX_LANGUAGE_BYTES:
|
||||
raise ConfigError("Meta language bias is too long")
|
||||
language_bias.append(language_value)
|
||||
meta = MetaConfig(model, api_key_file, terms_path, tuple(language_bias))
|
||||
else:
|
||||
section = _section(value.get("openai"), "openai", _OPENAI_FIELDS)
|
||||
model = _string(section.get("model"), "openai.model")
|
||||
if len(model.encode("utf-8")) > MAX_MODEL_BYTES:
|
||||
raise ConfigError("openai.model is too long")
|
||||
if model == "whisper-1":
|
||||
raise ConfigError("whisper-1 is not supported by the Realtime transcription backend")
|
||||
delay = _optional_string(section, "delay")
|
||||
if delay is not None and delay not in OPENAI_DELAYS:
|
||||
raise ConfigError("openai.delay is unsupported")
|
||||
prompt = _optional_string(section, "prompt")
|
||||
if prompt is not None and len(prompt.encode("utf-8")) > MAX_PROMPT_BYTES:
|
||||
raise ConfigError("openai.prompt is too large")
|
||||
languages_value = section.get("languages", ())
|
||||
if not isinstance(languages_value, (list, tuple)) or ("languages" in section and not languages_value):
|
||||
raise ConfigError("openai.languages must be a non-empty array when provided")
|
||||
if len(languages_value) > MAX_LANGUAGES:
|
||||
raise ConfigError("openai.languages has too many entries")
|
||||
languages: list[str] = []
|
||||
for language in languages_value:
|
||||
language_value = _string(language, "openai.languages entry")
|
||||
if len(language_value.encode("utf-8")) > MAX_LANGUAGE_BYTES:
|
||||
raise ConfigError("openai language is too long")
|
||||
languages.append(language_value)
|
||||
openai = OpenAIConfig(
|
||||
model=model,
|
||||
api_key_file=_path(section.get("apiKeyFile"), "openai.apiKeyFile"),
|
||||
terms_path=_path(section.get("termsPath"), "openai.termsPath"),
|
||||
delay=delay,
|
||||
prompt=prompt,
|
||||
languages=tuple(languages),
|
||||
)
|
||||
|
||||
config = DictateConfig(backend, float(idle), local, meta, openai)
|
||||
_validate_managed_credential_path(config)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_managed_credential_path(config: DictateConfig) -> None:
|
||||
selected = config.selected
|
||||
if isinstance(selected, LocalConfig):
|
||||
return
|
||||
expected = Path("~/.config/pi-dictate/credentials").expanduser()
|
||||
# A test or service may use a different HOME. The fixed directory is
|
||||
# therefore checked after expansion, not by string matching the JSON.
|
||||
path = selected.api_key_file
|
||||
if path.parent != expected or path.name != f"{config.backend}-api-key":
|
||||
raise ConfigError("credential path must use the managed credential directory")
|
||||
|
||||
|
||||
def load_config(path: str | os.PathLike[str] | None = None) -> DictateConfig:
|
||||
"""Read and parse the service configuration, failing closed on file issues."""
|
||||
|
||||
config_path = Path(path or os.environ.get("PI_DICTATE_CONFIG_PATH", "~/.config/pi-dictate/config.json")).expanduser().absolute()
|
||||
try:
|
||||
info = config_path.lstat()
|
||||
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
||||
raise ConfigError("configuration is not a regular file")
|
||||
if info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise ConfigError("configuration ownership or permissions are insecure")
|
||||
if info.st_size > 64 * 1024:
|
||||
raise ConfigError("configuration is too large")
|
||||
raw = _read_bounded_descriptor(config_path, MAX_CONFIG_BYTES, require_private=True)
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except ConfigError:
|
||||
raise
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error:
|
||||
raise ConfigError("could not read valid configuration") from error
|
||||
return parse_config(value, config_path=config_path)
|
||||
|
||||
|
||||
def _read_bounded_descriptor(
|
||||
path: Path,
|
||||
limit: int,
|
||||
*,
|
||||
require_private: bool = False,
|
||||
require_mode: int | None = None,
|
||||
) -> bytes:
|
||||
"""Read one regular file through an O_NOFOLLOW descriptor.
|
||||
|
||||
The descriptor is checked before reading, so a path replacement after the
|
||||
check cannot redirect the data consumed by the service.
|
||||
"""
|
||||
|
||||
descriptor: int | None = None
|
||||
try:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ConfigError("file is not a regular file")
|
||||
if info.st_uid != os.getuid():
|
||||
raise ConfigError("file ownership is insecure")
|
||||
if require_private and info.st_mode & 0o077:
|
||||
raise ConfigError("file permissions are insecure")
|
||||
if require_mode is not None and stat.S_IMODE(info.st_mode) != require_mode:
|
||||
raise ConfigError("file permissions are insecure")
|
||||
if info.st_size > limit:
|
||||
raise ConfigError("file is too large")
|
||||
chunks: list[bytes] = []
|
||||
remaining = limit + 1
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(4096, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
if len(raw) > limit:
|
||||
raise ConfigError("file is too large")
|
||||
return raw
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _reject_symlink_ancestry(path: Path) -> None:
|
||||
current = path.absolute()
|
||||
while current != current.parent:
|
||||
try:
|
||||
if current.is_symlink():
|
||||
raise CredentialError("credential path ancestry contains a symlink")
|
||||
except OSError as error:
|
||||
raise CredentialError("credential path ancestry could not be checked") from error
|
||||
current = current.parent
|
||||
|
||||
|
||||
def read_credential(path: str | os.PathLike[str]) -> str:
|
||||
"""Read one selected, mode-0600 regular credential without logging its value."""
|
||||
|
||||
credential_path = Path(path).expanduser()
|
||||
_reject_symlink_ancestry(credential_path.parent)
|
||||
try:
|
||||
raw = _read_bounded_descriptor(
|
||||
credential_path, MAX_CREDENTIAL_BYTES, require_mode=0o600
|
||||
)
|
||||
value = raw.decode("utf-8")
|
||||
except CredentialError:
|
||||
raise
|
||||
except (OSError, UnicodeError, ConfigError) as error:
|
||||
if isinstance(error, ConfigError):
|
||||
raise CredentialError(str(error)) from error
|
||||
raise CredentialError("could not read credential") from error
|
||||
if value.endswith("\n"):
|
||||
value = value[:-1]
|
||||
if not value or "\r" in value or "\n" in value or "\x00" in value:
|
||||
raise CredentialError("credential must be one non-empty line")
|
||||
return value
|
||||
|
||||
|
||||
def provider_keywords(path: str | os.PathLike[str]) -> list[str]:
|
||||
"""Load terms once through a bounded no-follow descriptor."""
|
||||
|
||||
try:
|
||||
raw = _read_bounded_descriptor(Path(path).expanduser(), MAX_TERMS_FILE_BYTES)
|
||||
except (OSError, ConfigError) as error:
|
||||
raise ConfigError("coding dictionary could not be read") from error
|
||||
# LF separates dictionary entries; CR is never accepted because it is
|
||||
# normalized by splitlines() and would otherwise silently alter a keyword.
|
||||
if b"\r" in raw:
|
||||
raise ConfigError("coding terms contain unsupported provider keyword characters")
|
||||
try:
|
||||
terms = parse_coding_terms(raw)
|
||||
except (OSError, UnicodeError, ValueError) as error:
|
||||
raise ConfigError("coding dictionary is invalid") from error
|
||||
if len(terms) > MAX_KEYWORDS:
|
||||
raise ConfigError("coding dictionary has too many provider keywords")
|
||||
if sum(len(term.encode("utf-8")) for term in terms) > MAX_PROVIDER_KEYWORDS_BYTES:
|
||||
raise ConfigError("coding dictionary provider keywords are too large")
|
||||
for term in terms:
|
||||
if (
|
||||
"<" in term
|
||||
or ">" in term
|
||||
or any(unicodedata.category(character) in {"Cc", "Cf"} for character in term)
|
||||
):
|
||||
raise ConfigError("coding terms contain unsupported provider keyword characters")
|
||||
return terms
|
||||
|
||||
|
||||
# Short aliases make the host-facing API convenient without exposing any raw
|
||||
# configuration dictionary to the service core.
|
||||
Config = DictateConfig
|
||||
read_api_key = read_credential
|
||||
|
||||
__all__ = [
|
||||
"CONFIG_VERSION", "Config", "ConfigError", "CredentialError", "DictateConfig",
|
||||
"MAX_CONFIG_BYTES",
|
||||
"LocalConfig", "MetaConfig", "OpenAIConfig", "parse_config", "load_config",
|
||||
"read_credential", "read_api_key", "provider_keywords", "MAX_PROVIDER_KEYWORDS_BYTES",
|
||||
"DEFAULT_IDLE_SECONDS",
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Opt-in live cloud contract smoke test using a short raw PCM fixture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
from .backend import backend_from_config
|
||||
from .config import load_config
|
||||
|
||||
MAX_SAMPLE_BYTES = 240_000 # five seconds of 24 kHz mono PCM16
|
||||
CHUNK_BYTES = 4_800
|
||||
|
||||
|
||||
def _read_sample(path: Path) -> bytes:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0),
|
||||
)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ValueError("sample must be a regular file")
|
||||
if not 0 < info.st_size <= MAX_SAMPLE_BYTES or info.st_size % 2:
|
||||
raise ValueError("sample must contain at most five seconds of even-length PCM16")
|
||||
sample = bytearray()
|
||||
while len(sample) <= MAX_SAMPLE_BYTES:
|
||||
chunk = os.read(descriptor, min(64 * 1024, MAX_SAMPLE_BYTES + 1 - len(sample)))
|
||||
if not chunk:
|
||||
break
|
||||
sample.extend(chunk)
|
||||
if len(sample) != info.st_size:
|
||||
raise ValueError("sample changed while it was being read")
|
||||
return bytes(sample)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Send one non-sensitive 24 kHz mono PCM16 sample to the configured cloud backend."
|
||||
)
|
||||
parser.add_argument("sample", type=Path, help="raw 24 kHz mono signed-16 little-endian PCM")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=Path("~/.config/pi-dictate/config.json").expanduser(),
|
||||
)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
config = load_config(arguments.config)
|
||||
if config.backend not in {"meta", "openai"}:
|
||||
parser.error("the configured backend must be meta or openai")
|
||||
backend = backend_from_config(config)
|
||||
sample = _read_sample(arguments.sample)
|
||||
turn = backend.start_turn()
|
||||
try:
|
||||
for offset in range(0, len(sample), CHUNK_BYTES):
|
||||
turn.enqueue_pcm(sample[offset : offset + CHUNK_BYTES])
|
||||
transcript = turn.finish()
|
||||
finally:
|
||||
turn.close()
|
||||
print(f"{backend.descriptor.backend}/{backend.descriptor.model}: {transcript!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,6 +6,7 @@ from copy import deepcopy
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
MAX_TERMS_FILE_BYTES = 64 * 1024
|
||||
@@ -19,18 +20,19 @@ class TranscriptionModel(Protocol):
|
||||
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."""
|
||||
def parse_coding_terms(raw: bytes) -> list[str]:
|
||||
"""Parse one bounded, already-read host-owned phrase-list descriptor."""
|
||||
|
||||
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:
|
||||
if len(raw) > MAX_TERMS_FILE_BYTES:
|
||||
raise ValueError(f"coding dictionary exceeds {MAX_TERMS_FILE_BYTES} bytes")
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError("coding dictionary is not valid UTF-8") from error
|
||||
|
||||
terms: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_line in terms_path.read_text(encoding="utf-8").splitlines():
|
||||
for raw_line in text.splitlines():
|
||||
term = raw_line.strip()
|
||||
if not term or term.startswith("#"):
|
||||
continue
|
||||
@@ -44,9 +46,47 @@ def load_coding_terms(path: str | os.PathLike[str]) -> list[str]:
|
||||
return terms
|
||||
|
||||
|
||||
def load_coding_terms(path: str | os.PathLike[str]) -> list[str]:
|
||||
"""Load a bounded dictionary from a no-follow descriptor, avoiding TOCTOU."""
|
||||
|
||||
terms_path = Path(path).expanduser()
|
||||
descriptor: int | None = None
|
||||
try:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(terms_path, flags)
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ValueError("PI_DICTATE_TERMS_PATH must name a regular dictionary")
|
||||
if info.st_size > MAX_TERMS_FILE_BYTES:
|
||||
raise ValueError(f"coding dictionary exceeds {MAX_TERMS_FILE_BYTES} bytes")
|
||||
chunks: list[bytes] = []
|
||||
remaining = MAX_TERMS_FILE_BYTES + 1
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(4096, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
if len(raw) > MAX_TERMS_FILE_BYTES:
|
||||
raise ValueError(f"coding dictionary exceeds {MAX_TERMS_FILE_BYTES} bytes")
|
||||
return parse_coding_terms(raw)
|
||||
except OSError as error:
|
||||
raise ValueError("PI_DICTATE_TERMS_PATH must name an existing local dictionary") from error
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class NemoParakeetModel:
|
||||
"""Load NeMo only when requested so core tests require neither CUDA nor NeMo."""
|
||||
|
||||
sample_rate_hz = 16_000
|
||||
max_pcm_bytes = 5 * 60 * sample_rate_hz * 2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str | os.PathLike[str],
|
||||
@@ -56,7 +96,11 @@ class NemoParakeetModel:
|
||||
expected_sha256: str = PINNED_MODEL_SHA256,
|
||||
) -> None:
|
||||
path = Path(model_path).expanduser()
|
||||
if path.suffix != ".nemo" or not path.is_file():
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as error:
|
||||
raise ValueError("PI_DICTATE_MODEL_PATH must name an existing local .nemo file") from error
|
||||
if path.suffix != ".nemo" or stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
||||
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")
|
||||
@@ -74,48 +118,68 @@ class NemoParakeetModel:
|
||||
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"
|
||||
descriptor: int | None = None
|
||||
try:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(self._model_path, flags)
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ValueError("model archive is not a regular file")
|
||||
with os.fdopen(descriptor, "rb", closefd=False) 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.lseek(descriptor, 0, os.SEEK_SET)
|
||||
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
os.environ["NEMO_OFFLINE"] = "1"
|
||||
|
||||
import numpy
|
||||
import torch
|
||||
from nemo.collections.asr.models import EncDecRNNTBPEModel
|
||||
from omegaconf import open_dict
|
||||
|
||||
# Keep the verified inode open while NeMo restores it. Reopening
|
||||
# the configured pathname here would allow a replacement between
|
||||
# checksum verification and model loading.
|
||||
model = EncDecRNNTBPEModel.restore_from(
|
||||
restore_path=f"/proc/self/fd/{descriptor}",
|
||||
map_location=torch.device("cuda"),
|
||||
)
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
os.environ["HF_DATASETS_OFFLINE"] = "1"
|
||||
os.environ["NEMO_OFFLINE"] = "1"
|
||||
|
||||
import numpy
|
||||
import torch
|
||||
from nemo.collections.asr.models import EncDecRNNTBPEModel
|
||||
from omegaconf import open_dict
|
||||
|
||||
model = EncDecRNNTBPEModel.restore_from(
|
||||
restore_path=str(self._model_path),
|
||||
map_location=torch.device("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
|
||||
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
|
||||
except OSError as error:
|
||||
raise ValueError("model archive could not be read") from error
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def transcribe(self, samples: Sequence[float]) -> str:
|
||||
if self._model is None or self._numpy is None:
|
||||
|
||||
@@ -51,9 +51,9 @@ _REQUEST_FIELDS = {
|
||||
|
||||
|
||||
def _bounded_utf8(value: str, limit: int) -> str:
|
||||
# Normalize replacement characters even when the original string contains
|
||||
# lone surrogates. This keeps error output serializable and bounded.
|
||||
raw = value.encode("utf-8", errors="replace")
|
||||
if len(raw) <= limit:
|
||||
return value
|
||||
return raw[:limit].decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
@@ -100,7 +100,11 @@ def parse_request(frame: Mapping[str, Any]) -> Request:
|
||||
raise ProtocolError("invalid_request", "unknown request type", request_id=request_id)
|
||||
|
||||
expected_fields = _REQUEST_FIELDS[request_type]
|
||||
if frozenset(frame) != expected_fields:
|
||||
try:
|
||||
actual_fields = frozenset(frame)
|
||||
except TypeError as error:
|
||||
raise ProtocolError("invalid_request", "request contains invalid field names", request_id=request_id) from error
|
||||
if actual_fields != expected_fields:
|
||||
raise ProtocolError(
|
||||
"invalid_request",
|
||||
f"{request_type} requires exactly: {', '.join(sorted(expected_fields))}",
|
||||
@@ -125,28 +129,38 @@ class FrameDecoder:
|
||||
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)
|
||||
if not isinstance(data, bytes):
|
||||
self._fail("malformed_json", "frame data is invalid")
|
||||
frames: list[dict[str, Any]] = []
|
||||
remaining = data
|
||||
|
||||
while True:
|
||||
newline = self._buffer.find(b"\n")
|
||||
# Consume each newline-delimited frame before considering the next one.
|
||||
# The size limit applies to an individual frame, not to a recv batch:
|
||||
# clients may pipeline any number of bounded frames in one feed call.
|
||||
while remaining:
|
||||
newline = remaining.find(b"\n")
|
||||
if newline < 0:
|
||||
if len(self._buffer) > MAX_FRAME_BYTES:
|
||||
if len(self._buffer) + len(remaining) > MAX_FRAME_BYTES:
|
||||
self._fail("frame_too_large", f"frame exceeds {MAX_FRAME_BYTES} bytes")
|
||||
self._buffer.extend(remaining)
|
||||
return frames
|
||||
if newline > MAX_FRAME_BYTES:
|
||||
if len(self._buffer) + 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]
|
||||
self._buffer.extend(remaining[:newline])
|
||||
raw = bytes(self._buffer)
|
||||
self._buffer.clear()
|
||||
remaining = remaining[newline + 1 :]
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
except (UnicodeDecodeError, ValueError, RecursionError):
|
||||
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)
|
||||
|
||||
return frames
|
||||
|
||||
def _fail(self, code: str, detail: str) -> None:
|
||||
self._failed = True
|
||||
self._buffer.clear()
|
||||
@@ -161,8 +175,11 @@ def encode_frame(frame: Mapping[str, Any]) -> bytes:
|
||||
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")
|
||||
try:
|
||||
if len(transcript.encode("utf-8")) > MAX_TRANSCRIPT_BYTES:
|
||||
raise ProtocolError("transcript_too_large", "transcript exceeds output limit")
|
||||
except UnicodeEncodeError as error:
|
||||
raise ProtocolError("invalid_event", "result transcript contains invalid Unicode") from error
|
||||
if payload.get("type") == "error":
|
||||
detail = payload.get("detail", "")
|
||||
if not isinstance(detail, str):
|
||||
@@ -175,7 +192,10 @@ def encode_frame(frame: Mapping[str, Any]) -> bytes:
|
||||
else:
|
||||
payload.pop("generationId", None)
|
||||
|
||||
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
try:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
except (TypeError, ValueError, UnicodeError, RecursionError, OverflowError) as error:
|
||||
raise ProtocolError("invalid_event", "event contains unsupported values") from error
|
||||
if len(encoded) > MAX_FRAME_BYTES:
|
||||
raise ProtocolError("frame_too_large", "encoded frame exceeds output limit")
|
||||
return encoded + b"\n"
|
||||
@@ -186,8 +206,8 @@ def error_event(error: ProtocolError) -> 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),
|
||||
"code": _bounded_utf8(error.code if isinstance(error.code, str) else "service_error", MAX_ID_BYTES),
|
||||
"detail": _bounded_utf8(error.detail if isinstance(error.detail, str) else str(error.detail), MAX_ERROR_DETAIL_BYTES),
|
||||
}
|
||||
generation_id = _safe_id(error.generation_id)
|
||||
if generation_id:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""PipeWire recorder adapter with bounded in-memory output."""
|
||||
"""PipeWire recorder adapter with bounded buffering and optional streaming."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,29 +13,42 @@ 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",
|
||||
"-",
|
||||
"pw-record", "--raw", "--format", "s16", "--rate", "16000",
|
||||
"--channels", "1", "-",
|
||||
]
|
||||
MAX_STDERR_BYTES = 64 * 1024
|
||||
AUDIO_LEVEL_WINDOW_BYTES = 3_200 # 100 ms of mono 16 kHz signed-16 PCM
|
||||
|
||||
|
||||
def audio_level_window_bytes(sample_rate_hz: int) -> int:
|
||||
"""Return one 100 ms mono PCM16 meter window for a supported rate."""
|
||||
if sample_rate_hz not in {16_000, 24_000}:
|
||||
raise ValueError("unsupported recorder sample rate")
|
||||
return sample_rate_hz * 2 // 10
|
||||
|
||||
|
||||
def pw_record_command(sample_rate_hz: int) -> list[str]:
|
||||
"""Build the explicit mono PCM16 PipeWire command for a backend rate."""
|
||||
if sample_rate_hz not in {16_000, 24_000}:
|
||||
raise ValueError("unsupported recorder sample rate")
|
||||
command = list(PW_RECORD_COMMAND)
|
||||
command[command.index("16000")] = str(sample_rate_hz)
|
||||
return command
|
||||
|
||||
|
||||
class PcmSink(Protocol):
|
||||
"""Nonblocking sink used by realtime turns from the recorder drain thread."""
|
||||
def enqueue_pcm(self, chunk: bytes) -> None: ...
|
||||
def close_input(self) -> None: ...
|
||||
def cancel(self) -> None: ...
|
||||
|
||||
|
||||
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: ...
|
||||
@@ -52,17 +65,11 @@ class Capture:
|
||||
returncode: int
|
||||
signal: int | None
|
||||
stderr_tail: bytes
|
||||
sample_rate_hz: int = 16_000
|
||||
|
||||
|
||||
class RecorderError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
detail: str,
|
||||
*,
|
||||
returncode: int | None = None,
|
||||
signal_number: int | None = None,
|
||||
) -> None:
|
||||
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
|
||||
@@ -71,20 +78,40 @@ class RecorderError(RuntimeError):
|
||||
|
||||
|
||||
class PwRecorder:
|
||||
"""Capture raw PCM while continuously draining both child pipes."""
|
||||
"""Capture raw PCM while continuously draining both child pipes.
|
||||
|
||||
``pcm_sink`` receives each stdout chunk using a nonblocking call. The sink
|
||||
is closed only after stdout reaches EOF and the child has exited, providing
|
||||
the no-truncated-tail ordering required by realtime providers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sample_rate_hz: int = 16_000,
|
||||
pcm_sink: PcmSink | None = None,
|
||||
sample_rate: int | None = None,
|
||||
retain_pcm: bool = True,
|
||||
process_factory: ProcessFactory = subprocess.Popen,
|
||||
max_bytes: int = MAX_PCM_BYTES,
|
||||
max_bytes: int | None = None,
|
||||
stderr_limit: int = MAX_STDERR_BYTES,
|
||||
stop_timeout: float = 3.0,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
process_group_signal: ProcessGroupSignal = os.killpg,
|
||||
) -> None:
|
||||
if sample_rate is not None:
|
||||
if sample_rate_hz != 16_000 and sample_rate_hz != sample_rate:
|
||||
raise ValueError("conflicting recorder sample rates")
|
||||
sample_rate_hz = sample_rate
|
||||
if sample_rate_hz not in {16_000, 24_000}:
|
||||
raise ValueError("unsupported recorder sample rate")
|
||||
self.sample_rate_hz = sample_rate_hz
|
||||
self._pcm_sink = pcm_sink
|
||||
self._retain_pcm = retain_pcm
|
||||
self._process_factory = process_factory
|
||||
self._max_bytes = max_bytes
|
||||
self._max_bytes = max_bytes if max_bytes is not None else 5 * 60 * sample_rate_hz * 2
|
||||
if self._max_bytes <= 0:
|
||||
raise ValueError("recorder byte limit must be positive")
|
||||
self._stderr_limit = stderr_limit
|
||||
self._stop_timeout = stop_timeout
|
||||
self._clock = clock
|
||||
@@ -100,6 +127,20 @@ class PwRecorder:
|
||||
self._duration_stop_at: float | None = None
|
||||
self._reported = False
|
||||
self._audio_level = 0.0
|
||||
self._sink_error: RecorderError | None = None
|
||||
self._drainer_error: RecorderError | None = None
|
||||
self._sink_closed = False
|
||||
self._audio_level_window_bytes = audio_level_window_bytes(sample_rate_hz)
|
||||
self._captured_bytes = 0
|
||||
self._cleanup_pending = False
|
||||
self._cleanup_complete = False
|
||||
self._cleanup_error: RecorderError | None = None
|
||||
|
||||
@property
|
||||
def cleanup_complete(self) -> bool:
|
||||
"""Whether the child process and both pipe drainers have been reaped."""
|
||||
with self._lock:
|
||||
return self._cleanup_complete
|
||||
|
||||
@property
|
||||
def buffered_bytes(self) -> int:
|
||||
@@ -116,17 +157,22 @@ class PwRecorder:
|
||||
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,
|
||||
pw_record_command(self.sample_rate_hz), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
self._abort_sink()
|
||||
self._mark_cleanup_complete()
|
||||
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
|
||||
if process.stdout is None or process.stderr is None:
|
||||
try:
|
||||
self.cancel()
|
||||
except RecorderError:
|
||||
# A timed-out child remains cleanup-pending and is retried by
|
||||
# the service before another generation can begin.
|
||||
pass
|
||||
raise RecorderError("recorder_spawn", "pw-record pipes were not created")
|
||||
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()
|
||||
@@ -141,119 +187,257 @@ class PwRecorder:
|
||||
finally:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
self._abort_sink()
|
||||
|
||||
def poll_unexpected(self) -> RecorderError | None:
|
||||
process = self._require_process()
|
||||
if self._cleanup_pending:
|
||||
try:
|
||||
self._retry_pending_cleanup(process)
|
||||
except RecorderError:
|
||||
# The pending terminal error was already reported. Keep the
|
||||
# recorder tracked so a later poll can retry the reap.
|
||||
return None
|
||||
return None
|
||||
with self._lock:
|
||||
sink_error = self._sink_error
|
||||
drainer_error = self._drainer_error
|
||||
if sink_error is not None and not self._reported:
|
||||
self._reported = True
|
||||
if process.poll() is None:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
return sink_error
|
||||
if drainer_error is not None and not self._reported:
|
||||
self._reported = True
|
||||
if process.poll() is None:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
return drainer_error
|
||||
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
|
||||
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()
|
||||
try:
|
||||
process.wait(timeout=self._stop_timeout)
|
||||
except subprocess.TimeoutExpired as kill_error:
|
||||
error = RecorderError(
|
||||
"recorder_timeout",
|
||||
"pw-record did not exit after the duration-limit kill",
|
||||
returncode=process.returncode,
|
||||
signal_number=signal.SIGKILL,
|
||||
)
|
||||
self._mark_cleanup_pending(error)
|
||||
return error
|
||||
try:
|
||||
self._join_and_close_sink()
|
||||
except RecorderError as error:
|
||||
self._mark_cleanup_pending(error)
|
||||
raise
|
||||
self._abort_sink()
|
||||
self._mark_cleanup_complete()
|
||||
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,
|
||||
returncode=process.returncode, signal_number=signal.SIGKILL,
|
||||
)
|
||||
return None
|
||||
if self._intentional or self._reported:
|
||||
return None
|
||||
self._reported = True
|
||||
self._join_drainers()
|
||||
try:
|
||||
self._join_and_close_sink()
|
||||
except RecorderError as error:
|
||||
self._mark_cleanup_pending(error)
|
||||
raise
|
||||
self._abort_sink()
|
||||
self._mark_cleanup_complete()
|
||||
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,
|
||||
"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
|
||||
if self._cleanup_pending:
|
||||
self._retry_pending_cleanup(process)
|
||||
if discard:
|
||||
return self._discarded_capture(process)
|
||||
with self._lock:
|
||||
cleanup_error = self._cleanup_error
|
||||
if cleanup_error is not None:
|
||||
raise cleanup_error
|
||||
raise RecorderError("recorder_timeout", "pw-record cleanup did not complete")
|
||||
|
||||
# Check the child before claiming the exit was caused by this stop.
|
||||
# A clean exit observed here is still an unexpected recorder exit: it
|
||||
# may have left only a partial clip in the bounded buffer.
|
||||
terminated_running_process = process.poll() is None
|
||||
if terminated_running_process:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
if not terminated_running_process:
|
||||
self._reported = True
|
||||
self._intentional = terminated_running_process
|
||||
if terminated_running_process and not self._signal_group(process, signal.SIGTERM):
|
||||
if process.poll() is not None:
|
||||
terminated_running_process = False
|
||||
self._intentional = False
|
||||
self._reported = True
|
||||
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()
|
||||
try:
|
||||
process.wait(timeout=self._stop_timeout)
|
||||
except subprocess.TimeoutExpired as kill_error:
|
||||
timeout_error = RecorderError(
|
||||
"recorder_timeout",
|
||||
"pw-record did not exit after SIGKILL",
|
||||
returncode=process.returncode,
|
||||
signal_number=signal.SIGKILL,
|
||||
)
|
||||
self._mark_cleanup_pending(timeout_error)
|
||||
raise timeout_error from kill_error
|
||||
try:
|
||||
self._join_and_close_sink()
|
||||
except RecorderError as join_error:
|
||||
self._mark_cleanup_pending(join_error)
|
||||
raise
|
||||
self._abort_sink()
|
||||
self._mark_cleanup_complete()
|
||||
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,
|
||||
"recorder_timeout", "pw-record did not stop after SIGTERM and was killed",
|
||||
returncode=process.returncode, signal_number=signal.SIGKILL,
|
||||
) from error
|
||||
|
||||
self._join_drainers()
|
||||
# Joining both drainers before closing the sink is the critical
|
||||
# tail-ordering guarantee. Providers cannot be finalized before the
|
||||
# last PCM chunk has been enqueued.
|
||||
try:
|
||||
self._join_and_close_sink()
|
||||
except RecorderError as error:
|
||||
self._mark_cleanup_pending(error)
|
||||
raise
|
||||
self._mark_cleanup_complete()
|
||||
stderr_tail = self._stderr_bytes()
|
||||
with self._lock:
|
||||
sink_error = self._sink_error
|
||||
if sink_error is not None:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
self._abort_sink()
|
||||
raise sink_error
|
||||
if self._duration_limited:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
self._abort_sink()
|
||||
raise RecorderError(
|
||||
"duration_limit",
|
||||
"recording exceeded the five-minute duration limit",
|
||||
returncode=returncode,
|
||||
signal_number=-returncode if returncode < 0 else None,
|
||||
"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 self._captured_bytes % 2:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
self._abort_sink()
|
||||
raise RecorderError("invalid_audio", "PCM byte length must be even", returncode=returncode)
|
||||
if not terminated_running_process:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
self._abort_sink()
|
||||
if discard:
|
||||
return self._discarded_capture(process)
|
||||
raise self._exit_error(returncode, "pw-record exited unexpectedly")
|
||||
clean_pipewire_sigterm = returncode == 1 and not stderr_tail
|
||||
if returncode not in (0, -signal.SIGTERM) and not clean_pipewire_sigterm:
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
self._abort_sink()
|
||||
raise self._exit_error(returncode, "pw-record failed while stopping")
|
||||
if discard:
|
||||
self._abort_sink()
|
||||
else:
|
||||
self._close_sink()
|
||||
with self._lock:
|
||||
pcm = b"" if discard else bytes(self._pcm)
|
||||
pcm = b"" if discard or not self._retain_pcm else bytes(self._pcm)
|
||||
self._pcm.clear()
|
||||
return Capture(
|
||||
pcm=pcm,
|
||||
returncode=returncode,
|
||||
pcm=pcm, returncode=returncode,
|
||||
signal=-returncode if returncode < 0 else None,
|
||||
stderr_tail=stderr_tail,
|
||||
stderr_tail=stderr_tail, sample_rate_hz=self.sample_rate_hz,
|
||||
)
|
||||
|
||||
def _drain_stdout(self) -> None:
|
||||
try:
|
||||
self._drain_stdout_inner()
|
||||
except Exception as error:
|
||||
self._record_drainer_error("stdout", error)
|
||||
|
||||
def _drain_stdout_inner(self) -> None:
|
||||
process = self._require_process()
|
||||
level_window = bytearray()
|
||||
remainder = b""
|
||||
while True:
|
||||
chunk = process.stdout.read(AUDIO_LEVEL_WINDOW_BYTES)
|
||||
if not chunk:
|
||||
raw_chunk = process.stdout.read(self._audio_level_window_bytes)
|
||||
if not raw_chunk:
|
||||
if remainder:
|
||||
self._record_drainer_error("stdout", ValueError("PCM byte length must be even"))
|
||||
return
|
||||
chunk = remainder + raw_chunk
|
||||
remainder = b""
|
||||
if len(chunk) % 2:
|
||||
remainder, chunk = chunk[-1:], chunk[:-1]
|
||||
if not chunk:
|
||||
continue
|
||||
level_window.extend(chunk)
|
||||
while len(level_window) >= AUDIO_LEVEL_WINDOW_BYTES:
|
||||
self._update_audio_level(level_window[:AUDIO_LEVEL_WINDOW_BYTES])
|
||||
del level_window[:AUDIO_LEVEL_WINDOW_BYTES]
|
||||
while len(level_window) >= self._audio_level_window_bytes:
|
||||
self._update_audio_level(level_window[: self._audio_level_window_bytes])
|
||||
del level_window[: self._audio_level_window_bytes]
|
||||
with self._lock:
|
||||
if self._duration_limited:
|
||||
if self._duration_limited or self._sink_error is not None:
|
||||
continue
|
||||
if len(self._pcm) + len(chunk) > self._max_bytes:
|
||||
if self._captured_bytes + len(chunk) > self._max_bytes:
|
||||
self._duration_limited = True
|
||||
self._duration_stop_at = self._clock()
|
||||
self._pcm.clear()
|
||||
over_duration = True
|
||||
else:
|
||||
self._pcm.extend(chunk)
|
||||
self._captured_bytes += len(chunk)
|
||||
over_duration = False
|
||||
if over_duration:
|
||||
if process.poll() is None:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
continue
|
||||
try:
|
||||
if self._pcm_sink is not None:
|
||||
self._pcm_sink.enqueue_pcm(chunk)
|
||||
except Exception as error:
|
||||
with self._lock:
|
||||
if self._sink_error is None:
|
||||
code = getattr(error, "code", "stream_failure")
|
||||
detail = getattr(error, "detail", "provider audio queue is full")
|
||||
if not isinstance(code, str) or not code.startswith(("meta_", "openai_")):
|
||||
code = f"provider_{code}"
|
||||
self._sink_error = RecorderError(code, str(detail)[:2048])
|
||||
self._pcm.clear()
|
||||
if process.poll() is None:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
# Continue draining so a failed provider cannot deadlock pw-record.
|
||||
continue
|
||||
with self._lock:
|
||||
if self._duration_limited or self._sink_error is not None:
|
||||
continue
|
||||
if process.poll() is None:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
if self._retain_pcm:
|
||||
self._pcm.extend(chunk)
|
||||
|
||||
def _update_audio_level(self, chunk: bytes) -> None:
|
||||
sample_bytes = len(chunk) - (len(chunk) % 2)
|
||||
@@ -269,6 +453,12 @@ class PwRecorder:
|
||||
self._audio_level = max(level, self._audio_level * 0.65)
|
||||
|
||||
def _drain_stderr(self) -> None:
|
||||
try:
|
||||
self._drain_stderr_inner()
|
||||
except Exception as error:
|
||||
self._record_drainer_error("stderr", error)
|
||||
|
||||
def _drain_stderr_inner(self) -> None:
|
||||
process = self._require_process()
|
||||
while True:
|
||||
chunk = process.stderr.read(16 * 1024)
|
||||
@@ -280,22 +470,130 @@ class PwRecorder:
|
||||
if overflow > 0:
|
||||
del self._stderr[:overflow]
|
||||
|
||||
def _join_drainers(self) -> None:
|
||||
def _retry_pending_cleanup(self, process: Process) -> None:
|
||||
"""Make another bounded attempt to reap a timed-out child and pipes."""
|
||||
if process.poll() is None:
|
||||
self._signal_group(process, signal.SIGKILL)
|
||||
try:
|
||||
process.wait(timeout=self._stop_timeout)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
self._abort_sink()
|
||||
with self._lock:
|
||||
cleanup_error = self._cleanup_error
|
||||
self._pcm.clear()
|
||||
if cleanup_error is None:
|
||||
cleanup_error = RecorderError(
|
||||
"recorder_timeout", "pw-record cleanup did not complete",
|
||||
returncode=process.returncode, signal_number=signal.SIGKILL,
|
||||
)
|
||||
raise cleanup_error from error
|
||||
try:
|
||||
# A drainer error is terminal for the recording, but a completed
|
||||
# thread is no longer a resource leak and can be marked reaped.
|
||||
self._join_drainers(check_errors=False)
|
||||
except RecorderError:
|
||||
self._abort_sink()
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
raise
|
||||
self._abort_sink()
|
||||
self._mark_cleanup_complete()
|
||||
|
||||
def _mark_cleanup_pending(self, error: RecorderError) -> None:
|
||||
with self._lock:
|
||||
self._cleanup_pending = True
|
||||
self._cleanup_complete = False
|
||||
self._cleanup_error = error
|
||||
# Abort provider input before a later retry. A failed recording must
|
||||
# never send the normal end/commit marker.
|
||||
self._abort_sink()
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
|
||||
def _mark_cleanup_complete(self) -> None:
|
||||
with self._lock:
|
||||
self._cleanup_pending = False
|
||||
self._cleanup_complete = True
|
||||
|
||||
def _discarded_capture(self, process: Process) -> Capture:
|
||||
returncode = process.returncode
|
||||
if returncode is None:
|
||||
returncode = -signal.SIGKILL
|
||||
return Capture(
|
||||
pcm=b"", returncode=returncode,
|
||||
signal=-returncode if returncode < 0 else None,
|
||||
stderr_tail=self._stderr_bytes(), sample_rate_hz=self.sample_rate_hz,
|
||||
)
|
||||
|
||||
def _join_and_close_sink(self) -> None:
|
||||
try:
|
||||
self._join_drainers()
|
||||
except RecorderError:
|
||||
# A dead or stuck drainer must abort the streaming sink so its
|
||||
# worker cannot wait indefinitely or finalize partial audio.
|
||||
self._abort_sink()
|
||||
with self._lock:
|
||||
self._pcm.clear()
|
||||
raise
|
||||
|
||||
def _record_drainer_error(self, stream: str, _error: BaseException) -> None:
|
||||
with self._lock:
|
||||
if self._drainer_error is None:
|
||||
self._drainer_error = RecorderError(
|
||||
"recorder_pipe", f"pw-record {stream} pipe could not be drained"
|
||||
)
|
||||
process = self._process
|
||||
if process is not None and process.poll() is None:
|
||||
self._signal_group(process, signal.SIGTERM)
|
||||
|
||||
def _join_drainers(self, *, check_errors: bool = True) -> 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",
|
||||
)
|
||||
raise RecorderError("recorder_timeout", "pw-record pipe did not close after the process group stopped")
|
||||
if check_errors:
|
||||
with self._lock:
|
||||
drainer_error = self._drainer_error
|
||||
if drainer_error is not None:
|
||||
raise drainer_error
|
||||
|
||||
def _signal_group(self, process: Process, signal_number: int) -> None:
|
||||
def _abort_sink(self) -> None:
|
||||
if self._pcm_sink is None or self._sink_closed:
|
||||
return
|
||||
self._sink_closed = True
|
||||
cancel = getattr(self._pcm_sink, "cancel", None)
|
||||
if callable(cancel):
|
||||
try:
|
||||
cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _close_sink(self) -> None:
|
||||
if self._pcm_sink is None or self._sink_closed:
|
||||
return
|
||||
self._sink_closed = True
|
||||
close_input = getattr(self._pcm_sink, "close_input", None)
|
||||
if not callable(close_input):
|
||||
return
|
||||
try:
|
||||
close_input()
|
||||
except Exception as error:
|
||||
with self._lock:
|
||||
if self._sink_error is None:
|
||||
code = getattr(error, "code", "stream_failure")
|
||||
detail = getattr(error, "detail", "provider audio queue could not be finalized")
|
||||
if not isinstance(code, str) or not code.startswith(("meta_", "openai_")):
|
||||
code = f"provider_{code}"
|
||||
self._sink_error = RecorderError(code, str(detail)[:2048])
|
||||
|
||||
def _signal_group(self, process: Process, signal_number: int) -> bool:
|
||||
try:
|
||||
self._process_group_signal(process.pid, signal_number)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
def _stderr_bytes(self) -> bytes:
|
||||
with self._lock:
|
||||
@@ -303,21 +601,14 @@ class PwRecorder:
|
||||
|
||||
def _exit_error(self, returncode: int, prefix: str) -> RecorderError:
|
||||
if returncode < 0:
|
||||
classification = f"signal {-returncode}"
|
||||
signal_number = -returncode
|
||||
classification, signal_number = f"signal {-returncode}", -returncode
|
||||
else:
|
||||
classification = f"code {returncode}"
|
||||
signal_number = None
|
||||
classification, signal_number = f"code {returncode}", 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,
|
||||
)
|
||||
return RecorderError("recorder_exit", detail, returncode=returncode, signal_number=signal_number)
|
||||
|
||||
def _require_process(self) -> Process:
|
||||
if self._process is None:
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import Executor, Future, ThreadPoolExecutor
|
||||
from concurrent.futures import Executor, Future
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import os
|
||||
from queue import Empty, SimpleQueue
|
||||
from queue import Empty, Queue, SimpleQueue
|
||||
import selectors
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from .audio import AudioError, pcm_s16le_to_floats
|
||||
from .backend import ProviderError, TranscriptionBackend, TranscriptionTurn, backend_from_config
|
||||
from .config import ConfigError, load_config
|
||||
from .model import NemoParakeetModel, TranscriptionModel
|
||||
from .protocol import (
|
||||
MAX_FRAME_BYTES,
|
||||
@@ -34,6 +37,19 @@ MAX_CONNECTION_OUTPUT_BYTES = MAX_FRAME_BYTES
|
||||
LEVEL_EVENT_INTERVAL_SECONDS = 0.1
|
||||
|
||||
|
||||
def _looks_like_turn(value: object) -> bool:
|
||||
"""Keep injected test backends source-compatible while validating turns."""
|
||||
return all(callable(getattr(value, name, None)) for name in ("enqueue_pcm", "finish", "poll_error", "cancel", "close"))
|
||||
|
||||
|
||||
def _turn_cleanup_complete(turn: object) -> bool:
|
||||
return bool(getattr(turn, "cleanup_complete", True))
|
||||
|
||||
|
||||
def _recorder_cleanup_complete(recorder: object) -> bool:
|
||||
return bool(getattr(recorder, "cleanup_complete", True))
|
||||
|
||||
|
||||
class Recorder(Protocol):
|
||||
@property
|
||||
def audio_level(self) -> float: ...
|
||||
@@ -57,7 +73,12 @@ class _Generation:
|
||||
request_id: str
|
||||
state: str
|
||||
recorder: Recorder | None = None
|
||||
recorder_cleanup_started: bool = False
|
||||
turn: TranscriptionTurn | None = None
|
||||
tombstoned: bool = False
|
||||
failure_reported: bool = False
|
||||
cleanup_pending: bool = False
|
||||
cleanup_notify_idle: bool = False
|
||||
last_level_event_at: float | None = None
|
||||
|
||||
|
||||
@@ -68,28 +89,84 @@ class _Completion:
|
||||
future: Future[object]
|
||||
|
||||
|
||||
class _DaemonExecutor(Executor):
|
||||
"""Single-worker executor that cannot hold interpreter shutdown hostage."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: Queue[tuple[Future[object], Callable[..., object], tuple[object, ...], dict[str, object]] | None] = Queue()
|
||||
self._lock = threading.Lock()
|
||||
self._shutdown = False
|
||||
self._worker = threading.Thread(target=self._run, daemon=True, name="dictate-worker")
|
||||
self._worker.start()
|
||||
|
||||
def submit(self, fn: Callable[..., object], /, *args: object, **kwargs: object) -> Future[object]:
|
||||
future: Future[object] = Future()
|
||||
with self._lock:
|
||||
if self._shutdown:
|
||||
raise RuntimeError("cannot schedule new futures after shutdown")
|
||||
self._queue.put((future, fn, args, kwargs))
|
||||
return future
|
||||
|
||||
def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
|
||||
with self._lock:
|
||||
if not self._shutdown:
|
||||
self._shutdown = True
|
||||
if cancel_futures:
|
||||
while True:
|
||||
try:
|
||||
pending = self._queue.get_nowait()
|
||||
except Empty:
|
||||
break
|
||||
if pending is not None:
|
||||
pending[0].cancel()
|
||||
self._queue.put(None)
|
||||
if wait:
|
||||
self._worker.join()
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
work = self._queue.get()
|
||||
if work is None:
|
||||
return
|
||||
future, fn, args, kwargs = work
|
||||
if not future.set_running_or_notify_cancel():
|
||||
continue
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
except BaseException as error:
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(result)
|
||||
|
||||
|
||||
class DictateServiceCore:
|
||||
"""Single-generation protocol state machine with injected host boundaries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: TranscriptionModel,
|
||||
recorder_factory: Callable[[], Recorder] = PwRecorder,
|
||||
model: TranscriptionModel | None = None,
|
||||
backend: TranscriptionBackend | None = None,
|
||||
recorder_factory: Callable[..., Recorder] = PwRecorder,
|
||||
executor: Executor | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
idle_seconds: float = DEFAULT_IDLE_SECONDS,
|
||||
) -> None:
|
||||
if model is None and backend is None:
|
||||
raise ValueError("either model or backend is required")
|
||||
if model is not None and backend is not None:
|
||||
raise ValueError("model and backend are mutually exclusive")
|
||||
self._model = model
|
||||
self._backend = backend
|
||||
self._recorder_factory = recorder_factory
|
||||
self._executor = executor or ThreadPoolExecutor(max_workers=1, thread_name_prefix="dictate")
|
||||
self._executor = executor or _DaemonExecutor()
|
||||
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._model_loaded = bool(getattr(model, "loaded", False)) if model is not None else False
|
||||
self._worker_kind: str | None = None
|
||||
self._completions: SimpleQueue[_Completion] = SimpleQueue()
|
||||
self._events: list[Outbound] = []
|
||||
@@ -98,6 +175,19 @@ class DictateServiceCore:
|
||||
def active_generation_id(self) -> str | None:
|
||||
return self._active.generation_id if self._active else None
|
||||
|
||||
def _ready(self) -> bool:
|
||||
if self._backend is None:
|
||||
return self._model_loaded
|
||||
return bool(getattr(self._backend, "ready", False))
|
||||
|
||||
def _diagnostics(self) -> dict[str, str]:
|
||||
descriptor = getattr(self._backend, "descriptor", None)
|
||||
if descriptor is None:
|
||||
return {}
|
||||
# Descriptors are fixed, bounded values supplied by the host backend,
|
||||
# never paths, endpoint URLs, or credential state.
|
||||
return {"backend": str(descriptor.backend)[:64], "model": str(descriptor.model)[:128]}
|
||||
|
||||
def accepted(self, client_id: str) -> None:
|
||||
"""Track a socket without treating acceptance as protocol activity."""
|
||||
self._clients.setdefault(client_id, False)
|
||||
@@ -116,7 +206,8 @@ class DictateServiceCore:
|
||||
"type": "pong",
|
||||
"requestId": request.request_id,
|
||||
"service": "pi-dictate",
|
||||
"ready": self._model_loaded,
|
||||
"ready": self._ready(),
|
||||
**self._diagnostics(),
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -133,8 +224,9 @@ class DictateServiceCore:
|
||||
"type": "pong",
|
||||
"requestId": request.request_id,
|
||||
"service": "pi-dictate",
|
||||
"ready": self._model_loaded,
|
||||
"ready": self._ready(),
|
||||
"busy": self._active is not None,
|
||||
**self._diagnostics(),
|
||||
},
|
||||
)
|
||||
elif request.type == "start":
|
||||
@@ -154,14 +246,38 @@ class DictateServiceCore:
|
||||
self._apply_completion(completion)
|
||||
|
||||
active = self._active
|
||||
if active and active.cleanup_pending and self._worker_kind is None:
|
||||
if active.recorder is not None and not _recorder_cleanup_complete(active.recorder):
|
||||
self._submit("cleanup", active, active.recorder.poll_unexpected)
|
||||
return
|
||||
self._finish_generation(active, notify_idle=active.cleanup_notify_idle)
|
||||
if self._active is active:
|
||||
return
|
||||
active = self._active
|
||||
if active and active.tombstoned and self._worker_kind is None:
|
||||
if active.recorder is not None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
else:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
return
|
||||
if active and active.state == "listening" and active.recorder and self._worker_kind is None:
|
||||
if active.turn is not None:
|
||||
failure_from_turn = active.turn.poll_error()
|
||||
if failure_from_turn is not None:
|
||||
active.failure_reported = True
|
||||
self._async_error(active, failure_from_turn.code, failure_from_turn.detail)
|
||||
self._abort_active(active)
|
||||
return
|
||||
try:
|
||||
failure = active.recorder.poll_unexpected()
|
||||
except RecorderError as error:
|
||||
failure = error
|
||||
if failure is not None:
|
||||
active.failure_reported = True
|
||||
self._async_error(active, failure.code, failure.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
self._abort_active(active)
|
||||
return
|
||||
self._emit_audio_level(active)
|
||||
|
||||
@@ -175,9 +291,15 @@ class DictateServiceCore:
|
||||
if active is None or active.owner != client_id:
|
||||
return
|
||||
active.tombstoned = True
|
||||
if active.state == "listening" and active.recorder is not None:
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None and self._worker_kind is None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
elif self._worker_kind is None:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
|
||||
def should_exit(self) -> bool:
|
||||
if self._active is not None or self._worker_kind is not None:
|
||||
@@ -186,14 +308,30 @@ class DictateServiceCore:
|
||||
|
||||
def close(self) -> None:
|
||||
active = self._active
|
||||
if active and active.recorder and active.state == "listening":
|
||||
try:
|
||||
active.recorder.cancel()
|
||||
except RecorderError:
|
||||
pass
|
||||
if active:
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None:
|
||||
try:
|
||||
active.recorder.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
if active.turn is not None:
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not _turn_cleanup_complete(active.turn) and time.monotonic() < deadline:
|
||||
try:
|
||||
active.turn.close()
|
||||
except Exception:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
self._active = None
|
||||
if self._owns_executor:
|
||||
self._executor.shutdown(wait=True, cancel_futures=False)
|
||||
# A recorder/provider worker may be stuck in native or network I/O.
|
||||
# The owned worker is daemonized and isn't registered with Python's
|
||||
# ThreadPoolExecutor exit hook, so this bounded shutdown also permits
|
||||
# normal interpreter exit.
|
||||
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def _start(self, client_id: str, request: Request) -> None:
|
||||
assert request.generation_id is not None
|
||||
@@ -202,28 +340,70 @@ class DictateServiceCore:
|
||||
return
|
||||
active = _Generation(client_id, request.generation_id, request.request_id, "loading")
|
||||
self._active = active
|
||||
if self._backend is not None:
|
||||
begin_prepare = getattr(self._backend, "begin_prepare", None)
|
||||
if callable(begin_prepare):
|
||||
begin_prepare()
|
||||
self._state(active, "loading")
|
||||
self._submit("prepare", active, self._backend.start_turn)
|
||||
return
|
||||
assert self._model is not None
|
||||
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:
|
||||
def _begin_recording(self, active: _Generation, turn: TranscriptionTurn | None = None) -> None:
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
return
|
||||
try:
|
||||
recorder = self._recorder_factory()
|
||||
active.turn = turn
|
||||
if turn is None:
|
||||
recorder = self._recorder_factory()
|
||||
else:
|
||||
# PwRecorder accepts these options. Keep injected legacy test
|
||||
# factories source-compatible while requiring a sink for cloud.
|
||||
sink = None if turn.retain_pcm else turn
|
||||
try:
|
||||
recorder = self._recorder_factory(
|
||||
sample_rate_hz=turn.sample_rate_hz,
|
||||
pcm_sink=sink,
|
||||
retain_pcm=turn.retain_pcm,
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
recorder = self._recorder_factory(
|
||||
sample_rate_hz=turn.sample_rate_hz, sink=sink
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
recorder = self._recorder_factory(turn.sample_rate_hz, sink)
|
||||
except TypeError:
|
||||
if not turn.retain_pcm:
|
||||
raise RecorderError(
|
||||
"recorder_config",
|
||||
"realtime recorder factory must accept a PCM sink",
|
||||
)
|
||||
recorder = self._recorder_factory()
|
||||
active.recorder = recorder
|
||||
recorder.start()
|
||||
except RecorderError as error:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except OSError as error:
|
||||
self._async_error(active, "recorder_spawn", str(error))
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except Exception as error:
|
||||
self._async_error(active, "recorder_failure", str(error))
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
active.recorder = recorder
|
||||
active.last_level_event_at = self._clock()
|
||||
self._state(active, "listening")
|
||||
|
||||
@@ -263,11 +443,15 @@ class DictateServiceCore:
|
||||
failure = error
|
||||
if failure is not None:
|
||||
self._async_error(active, failure.code, failure.detail)
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
active.state = "stopping"
|
||||
self._state(active, "stopping", assign=False)
|
||||
self._submit("stop", active, self._stop_and_convert, active.recorder)
|
||||
if active.turn is None:
|
||||
self._submit("stop", active, self._stop_and_convert, active.recorder)
|
||||
else:
|
||||
self._submit("stop", active, self._stop_capture, active.recorder)
|
||||
|
||||
def _cancel(self, client_id: str, request: Request) -> None:
|
||||
active = self._owned_generation(client_id, request)
|
||||
@@ -278,6 +462,9 @@ class DictateServiceCore:
|
||||
return
|
||||
active.tombstoned = True
|
||||
active.request_id = request.request_id
|
||||
# Emit the delivery tombstone before potentially blocking provider or
|
||||
# recorder cleanup. The client must learn cancellation promptly while
|
||||
# the host remains busy reaping the old generation.
|
||||
self._emit(
|
||||
client_id,
|
||||
{
|
||||
@@ -287,9 +474,49 @@ class DictateServiceCore:
|
||||
"generationId": active.generation_id,
|
||||
},
|
||||
)
|
||||
if active.state == "listening" and active.recorder is not None:
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None and self._worker_kind is None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
elif active.recorder is None and self._worker_kind is None:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
|
||||
def _cancel_preparation(self, active: _Generation) -> None:
|
||||
if self._worker_kind != "prepare" or self._backend is None:
|
||||
return
|
||||
cancel_prepare = getattr(self._backend, "cancel_prepare", None)
|
||||
if callable(cancel_prepare):
|
||||
try:
|
||||
cancel_prepare()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _cancel_turn(active: _Generation) -> None:
|
||||
if active.turn is None:
|
||||
return
|
||||
try:
|
||||
active.turn.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _abort_active(self, active: _Generation) -> None:
|
||||
"""Tombstone a failed generation and always reap recorder/provider work."""
|
||||
active.tombstoned = True
|
||||
self._cancel_preparation(active)
|
||||
if active.turn is not None:
|
||||
self._cancel_turn(active)
|
||||
if active.recorder is not None and getattr(active.recorder, "cleanup_complete", False) is True:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
elif active.recorder is not None and self._worker_kind is None and not active.recorder_cleanup_started:
|
||||
active.recorder_cleanup_started = True
|
||||
active.state = "stopping"
|
||||
self._submit("cancel", active, active.recorder.cancel)
|
||||
elif active.recorder is None and self._worker_kind is None:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
|
||||
def _owned_generation(self, client_id: str, request: Request) -> _Generation | None:
|
||||
assert request.generation_id is not None
|
||||
@@ -315,6 +542,10 @@ class DictateServiceCore:
|
||||
capture = recorder.stop()
|
||||
return pcm_s16le_to_floats(capture.pcm)
|
||||
|
||||
@staticmethod
|
||||
def _stop_capture(recorder: Recorder) -> Capture:
|
||||
return recorder.stop()
|
||||
|
||||
def _submit(
|
||||
self,
|
||||
kind: str,
|
||||
@@ -339,23 +570,47 @@ class DictateServiceCore:
|
||||
try:
|
||||
result = completion.future.result()
|
||||
except RecorderError as error:
|
||||
if not active.tombstoned:
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except AudioError as error:
|
||||
if not active.tombstoned:
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
self._async_error(active, "invalid_audio", str(error))
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except ProviderError as error:
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
self._async_error(active, error.code, error.detail)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
except BaseException as error:
|
||||
if not active.tombstoned:
|
||||
code = "model_failure" if completion.kind in ("load", "infer") else "recorder_failure"
|
||||
if not active.tombstoned and not active.failure_reported:
|
||||
code = "model_failure" if completion.kind in ("load", "prepare", "infer") else "recorder_failure"
|
||||
self._async_error(active, code, str(error))
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
|
||||
if completion.kind == "load":
|
||||
if completion.kind == "prepare":
|
||||
if active.tombstoned:
|
||||
if _looks_like_turn(result):
|
||||
active.turn = result
|
||||
try:
|
||||
result.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
elif _looks_like_turn(result):
|
||||
self._begin_recording(active, result)
|
||||
else:
|
||||
self._async_error(active, "backend_failure", "backend returned an invalid transcription turn")
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
elif completion.kind == "load":
|
||||
self._model_loaded = True
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
@@ -363,14 +618,25 @@ class DictateServiceCore:
|
||||
self._begin_recording(active)
|
||||
elif completion.kind == "stop":
|
||||
if active.tombstoned:
|
||||
self._finish_generation(active, notify_idle=False)
|
||||
self._abort_active(active)
|
||||
return
|
||||
if active.turn is not None:
|
||||
if not isinstance(result, Capture):
|
||||
self._async_error(active, "invalid_audio", "recorder returned invalid capture")
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
self._state(active, "transcribing")
|
||||
self._submit("infer", active, active.turn.finish, result)
|
||||
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)
|
||||
active.failure_reported = True
|
||||
self._abort_active(active)
|
||||
return
|
||||
self._state(active, "transcribing")
|
||||
assert self._model is not None
|
||||
self._submit("infer", active, self._model.transcribe, samples)
|
||||
elif completion.kind == "infer":
|
||||
if active.tombstoned:
|
||||
@@ -378,29 +644,55 @@ class DictateServiceCore:
|
||||
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,
|
||||
},
|
||||
)
|
||||
try:
|
||||
transcript_bytes = len(result.encode("utf-8"))
|
||||
except UnicodeEncodeError:
|
||||
self._async_error(active, "model_failure", "model returned invalid Unicode")
|
||||
self._finish_generation(active, notify_idle=True)
|
||||
return
|
||||
if transcript_bytes > 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)
|
||||
self._finish_generation(active, notify_idle=not active.tombstoned)
|
||||
elif completion.kind == "cleanup":
|
||||
self._finish_generation(active, notify_idle=active.cleanup_notify_idle)
|
||||
|
||||
def _finish_generation(self, active: _Generation, *, notify_idle: bool) -> None:
|
||||
if self._active is not active:
|
||||
return
|
||||
active.cleanup_pending = True
|
||||
active.cleanup_notify_idle = active.cleanup_notify_idle or notify_idle
|
||||
if active.recorder is not None and not _recorder_cleanup_complete(active.recorder):
|
||||
# Cleanup retries run through the executor so a stubborn child never
|
||||
# blocks the socket loop. The pending generation remains exclusive.
|
||||
return
|
||||
if active.turn is not None:
|
||||
try:
|
||||
active.turn.close()
|
||||
except Exception:
|
||||
pass
|
||||
# A provider close can be blocked by a wedged transport. Keep the
|
||||
# tombstoned generation busy until both provider workers have
|
||||
# actually exited, so no thread or transport crosses generations.
|
||||
if not _turn_cleanup_complete(active.turn):
|
||||
return
|
||||
active.cleanup_pending = False
|
||||
self._active = None
|
||||
self._last_activity = self._clock()
|
||||
if notify_idle and active.owner in self._clients:
|
||||
if (notify_idle or active.failure_reported) and active.owner in self._clients:
|
||||
self._state(active, "idle", assign=False)
|
||||
|
||||
def _state(self, active: _Generation, state: str, *, assign: bool = True) -> None:
|
||||
@@ -448,8 +740,12 @@ class _Connection:
|
||||
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."""
|
||||
def inherited_listener(
|
||||
environ: dict[str, str] | None = None,
|
||||
*,
|
||||
expected_path: str | os.PathLike[str] | None = None,
|
||||
) -> socket.socket:
|
||||
"""Return systemd's fd 3 only when it is the expected Unix stream socket."""
|
||||
environment = os.environ if environ is None else environ
|
||||
try:
|
||||
listen_pid = int(environment.get("LISTEN_PID", "0"))
|
||||
@@ -458,9 +754,65 @@ def inherited_listener(environ: dict[str, str] | None = None) -> socket.socket:
|
||||
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
|
||||
listener: socket.socket | None = None
|
||||
try:
|
||||
listener = socket.socket(fileno=3)
|
||||
if listener.family != socket.AF_UNIX:
|
||||
raise RuntimeError("inherited listener must be AF_UNIX")
|
||||
socket_type = listener.type & getattr(socket, "SOCK_TYPE_MASK", 0xF)
|
||||
if socket_type != socket.SOCK_STREAM:
|
||||
raise RuntimeError("inherited listener must be SOCK_STREAM")
|
||||
try:
|
||||
accepting = listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN)
|
||||
except OSError as error:
|
||||
raise RuntimeError("could not validate inherited listener state") from error
|
||||
if accepting != 1:
|
||||
raise RuntimeError("inherited listener is not listening")
|
||||
actual = listener.getsockname()
|
||||
if isinstance(actual, bytes):
|
||||
actual = actual.decode("utf-8", errors="strict")
|
||||
if not isinstance(actual, str):
|
||||
raise RuntimeError("inherited listener has an invalid socket path")
|
||||
expected = expected_path
|
||||
if expected is None:
|
||||
runtime_dir = environment.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||
expected = os.path.join(runtime_dir, "pi-dictate.sock")
|
||||
if os.path.abspath(actual) != os.path.abspath(os.fspath(expected)):
|
||||
raise RuntimeError("inherited listener is not the expected pi-dictate socket")
|
||||
listener.setblocking(False)
|
||||
return listener
|
||||
except Exception:
|
||||
if listener is not None:
|
||||
listener.close()
|
||||
raise
|
||||
|
||||
|
||||
def _queue_connection_frame(
|
||||
selector: selectors.BaseSelector,
|
||||
connections: dict[str, _Connection],
|
||||
core: DictateServiceCore,
|
||||
client_id: str,
|
||||
frame: dict[str, object],
|
||||
) -> bool:
|
||||
"""Encode and append one frame without exceeding the per-client bound."""
|
||||
connection = connections.get(client_id)
|
||||
if connection is None:
|
||||
return False
|
||||
try:
|
||||
encoded = encode_frame(frame)
|
||||
except ProtocolError:
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
return False
|
||||
if len(connection.output) + len(encoded) > MAX_CONNECTION_OUTPUT_BYTES:
|
||||
_close_connection(selector, connections, core, client_id)
|
||||
return False
|
||||
connection.output.extend(encoded)
|
||||
selector.modify(
|
||||
connection.socket,
|
||||
selectors.EVENT_READ | selectors.EVENT_WRITE,
|
||||
client_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def serve(listener: socket.socket, core: DictateServiceCore) -> None:
|
||||
@@ -477,19 +829,8 @@ def serve(listener: socket.socket, core: DictateServiceCore) -> None:
|
||||
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,
|
||||
_queue_connection_frame(
|
||||
selector, connections, core, outbound.client_id, outbound.frame
|
||||
)
|
||||
for key, mask in selector.select(timeout=0.1):
|
||||
if key.data is None:
|
||||
@@ -527,9 +868,19 @@ def serve(listener: socket.socket, core: DictateServiceCore) -> None:
|
||||
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:
|
||||
if _queue_connection_frame(
|
||||
selector,
|
||||
connections,
|
||||
core,
|
||||
client_id,
|
||||
error_event(error),
|
||||
) and error.close_connection:
|
||||
connection.close_after_write = True
|
||||
# Queueing a protocol error can close this connection when its
|
||||
# output bound is exceeded. The selector event was created for
|
||||
# the old socket, so do not send to or modify that stale socket.
|
||||
if connections.get(client_id) is not connection:
|
||||
continue
|
||||
if mask & selectors.EVENT_WRITE and connection.output:
|
||||
try:
|
||||
sent = connection.socket.send(connection.output)
|
||||
@@ -573,26 +924,44 @@ def _close_connection(
|
||||
|
||||
|
||||
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)
|
||||
# Installed services use the strict host config. The environment fallback
|
||||
# keeps direct development invocations and existing local deployments
|
||||
# compatible until install.sh has written config.json.
|
||||
config_path = os.environ.get("PI_DICTATE_CONFIG_PATH")
|
||||
default_config = os.path.expanduser("~/.config/pi-dictate/config.json")
|
||||
if config_path or os.path.isfile(default_config):
|
||||
try:
|
||||
config = load_config(config_path)
|
||||
network_mode = os.environ.get("PI_DICTATE_NETWORK_MODE")
|
||||
expected_network_mode = "offline" if config.backend == "local" else "cloud"
|
||||
if network_mode is not None and network_mode != expected_network_mode:
|
||||
raise ConfigError("configured backend does not match service network mode")
|
||||
backend = backend_from_config(config)
|
||||
except (ConfigError, ValueError) as error:
|
||||
raise SystemExit(f"Invalid pi-dictate configuration: {error}") from error
|
||||
core = DictateServiceCore(backend=backend, idle_seconds=config.idle_seconds)
|
||||
else:
|
||||
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 = int(idle_value)
|
||||
if str(idle_seconds) != idle_value:
|
||||
raise ValueError
|
||||
boost_alpha = float(boost_value)
|
||||
except ValueError as error:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS must be an integer; boost alpha must be numeric") from error
|
||||
if not 60 <= idle_seconds <= 86_400:
|
||||
raise SystemExit("PI_DICTATE_IDLE_SECONDS must be between 60 and 86400")
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Fail-closed checks for the installed systemd hardening contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
from .config import ConfigError, DictateConfig, load_config
|
||||
|
||||
MAX_STARTUP_FILE_BYTES = 64 * 1024
|
||||
EXPECTED_BASE_UNIT_SHA256 = "345c79165029e52b446087e780c982961e9478da74b5ac5680ac27f5231ff4ce"
|
||||
EXPECTED_SOCKET_UNIT_SHA256 = "4868f54e2ec25fad132df858d5dfb9bf8337e9d5930998e7af83e58e57f737d0"
|
||||
|
||||
|
||||
def _read_regular(path: Path, limit: int, *, mode: int | None = None) -> bytes:
|
||||
descriptor: int | None = None
|
||||
try:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0),
|
||||
)
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
|
||||
raise ConfigError("managed systemd file is not a service-owned regular file")
|
||||
if mode is not None and stat.S_IMODE(info.st_mode) != mode:
|
||||
raise ConfigError("managed systemd file permissions are insecure")
|
||||
if info.st_size > limit:
|
||||
raise ConfigError("managed systemd file is too large")
|
||||
chunks: list[bytes] = []
|
||||
remaining = limit + 1
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(4096, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
if len(raw) > limit:
|
||||
raise ConfigError("managed systemd file is too large")
|
||||
return raw
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _reject_symlink_ancestry(path: Path) -> None:
|
||||
current = path.absolute()
|
||||
while True:
|
||||
try:
|
||||
info = current.lstat()
|
||||
except FileNotFoundError:
|
||||
current = current.parent
|
||||
if current == current.parent:
|
||||
return
|
||||
continue
|
||||
except OSError as error:
|
||||
raise ConfigError("systemd drop-in ancestry could not be checked") from error
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
raise ConfigError("systemd drop-in ancestry contains a symlink")
|
||||
if current == current.parent:
|
||||
return
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _bind_line(path: Path) -> str:
|
||||
expanded = str(path.expanduser()).replace("%", "%%")
|
||||
return "BindReadOnlyPaths=" + json.dumps(expanded, ensure_ascii=False)
|
||||
|
||||
|
||||
def _expected_dropin(network_mode: str, config: DictateConfig) -> str:
|
||||
if network_mode == "offline":
|
||||
return "\n".join(
|
||||
[
|
||||
"# Managed by pi-dictate install.sh",
|
||||
"[Service]",
|
||||
"Environment=PI_DICTATE_NETWORK_MODE=offline",
|
||||
"Environment=HF_HUB_OFFLINE=1",
|
||||
"Environment=TRANSFORMERS_OFFLINE=1",
|
||||
"Environment=NEMO_HOME=/tmp/pi-dictate-nemo",
|
||||
"Environment=HF_HOME=/tmp/pi-dictate-huggingface",
|
||||
"Environment=TRITON_CACHE_DIR=/tmp/pi-dictate-triton",
|
||||
"Environment=NUMBA_CACHE_DIR=/tmp/pi-dictate-numba",
|
||||
"RestrictAddressFamilies=AF_UNIX",
|
||||
"IPAddressDeny=any",
|
||||
_bind_line(config.selected.model_path),
|
||||
_bind_line(config.selected.terms_path),
|
||||
]
|
||||
) + "\n"
|
||||
if network_mode == "cloud":
|
||||
return "\n".join(
|
||||
[
|
||||
"# Managed by pi-dictate install.sh",
|
||||
"[Service]",
|
||||
"Environment=PI_DICTATE_NETWORK_MODE=cloud",
|
||||
"# Cloud backends need outbound DNS and TLS, but never a listening IP socket.",
|
||||
"Environment=HF_HUB_OFFLINE=",
|
||||
"Environment=TRANSFORMERS_OFFLINE=",
|
||||
"Environment=NEMO_HOME=",
|
||||
"Environment=HF_HOME=",
|
||||
"Environment=TRITON_CACHE_DIR=",
|
||||
"Environment=NUMBA_CACHE_DIR=",
|
||||
"Environment=PI_DICTATE_MODEL_PATH=",
|
||||
"Environment=PI_DICTATE_BOOST_ALPHA=",
|
||||
"RestrictAddressFamilies=",
|
||||
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
|
||||
"IPAddressDeny=",
|
||||
_bind_line(config.selected.api_key_file),
|
||||
_bind_line(config.selected.terms_path),
|
||||
]
|
||||
) + "\n"
|
||||
raise ConfigError("invalid systemd network mode")
|
||||
|
||||
|
||||
def validate_startup() -> None:
|
||||
"""Validate config, marker, base unit, and effective generated drop-in."""
|
||||
|
||||
config_path = Path(os.environ.get("PI_DICTATE_CONFIG_PATH", "~/.config/pi-dictate/config.json")).expanduser()
|
||||
config = load_config(config_path)
|
||||
network_mode = os.environ.get("PI_DICTATE_NETWORK_MODE", "")
|
||||
expected_mode = "offline" if config.backend == "local" else "cloud"
|
||||
if network_mode != expected_mode:
|
||||
raise ConfigError("configured backend does not match systemd network mode")
|
||||
|
||||
marker = Path(os.environ.get("PI_DICTATE_DEPENDENCY_PROFILE_PATH", "~/.local/share/pi-dictate/.dependency-profile")).expanduser()
|
||||
marker_raw = _read_regular(marker, 32, mode=0o600)
|
||||
if marker_raw not in {b"local\n", b"cloud\n"} or marker_raw.rstrip(b"\n").decode("ascii") != expected_mode.replace("offline", "local"):
|
||||
raise ConfigError("installed dependency profile does not match configured backend")
|
||||
|
||||
unit_root = Path("~/.config/systemd/user").expanduser()
|
||||
_reject_symlink_ancestry(unit_root)
|
||||
dropin_dir = unit_root / "pi-dictate.service.d"
|
||||
dropin_path = dropin_dir / "10-pi-dictate-network.conf"
|
||||
_reject_symlink_ancestry(dropin_dir)
|
||||
if not dropin_dir.is_dir():
|
||||
raise ConfigError("managed systemd drop-in directory is missing")
|
||||
try:
|
||||
entries = list(dropin_dir.iterdir())
|
||||
except OSError as error:
|
||||
raise ConfigError("managed systemd drop-in directory cannot be read") from error
|
||||
if any(entry.name != dropin_path.name for entry in entries):
|
||||
raise ConfigError("unexpected systemd drop-in is present")
|
||||
if _read_regular(dropin_path, MAX_STARTUP_FILE_BYTES).decode("utf-8") != _expected_dropin(network_mode, config):
|
||||
raise ConfigError("effective systemd network hardening is not the expected managed policy")
|
||||
|
||||
unit_path = unit_root / "pi-dictate.service"
|
||||
unit_raw = _read_regular(unit_path, MAX_STARTUP_FILE_BYTES)
|
||||
if hashlib.sha256(unit_raw).hexdigest() != EXPECTED_BASE_UNIT_SHA256:
|
||||
raise ConfigError("base systemd hardening is incomplete or tampered")
|
||||
|
||||
socket_unit_path = unit_root / "pi-dictate.socket"
|
||||
socket_unit_raw = _read_regular(socket_unit_path, MAX_STARTUP_FILE_BYTES)
|
||||
if hashlib.sha256(socket_unit_raw).hexdigest() != EXPECTED_SOCKET_UNIT_SHA256:
|
||||
raise ConfigError("socket systemd policy is incomplete or tampered")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
validate_startup()
|
||||
except (ConfigError, OSError, UnicodeError, ValueError) as error:
|
||||
raise SystemExit(f"Invalid pi-dictate startup policy: {error}") from error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user