feat(dictate): support configurable ASR backends

This commit is contained in:
jb
2026-09-03 00:08:00 -04:00
parent 67877297d0
commit 2c3ee1f014
40 changed files with 7055 additions and 489 deletions
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,13 +1,19 @@
[project]
name = "pi-dictate-service"
version = "0.1.0"
description = "Socket-activated local transcription service for the Pi dictate extension"
description = "Socket-activated transcription service for the Pi dictate extension"
requires-python = ">=3.12,<3.13"
dependencies = [
dependencies = []
[project.optional-dependencies]
local = [
"nemo-toolkit[asr]==3.0.0",
"numpy==2.5.2",
"torch==2.13.0",
]
cloud = [
"websockets==15.0.1",
]
[project.scripts]
pi-dictate-service = "pi_dictate_service.server:main"
@@ -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()
+113 -49
View File
@@ -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:
+457 -88
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
{"type":"transcript","final":false,"text":"partial"}
{"type":"transcript","final":true,"text":"final fixture transcript"}
+1
View File
@@ -0,0 +1 @@
{"sessionId":"fixture-session-1"}
+4
View File
@@ -0,0 +1,4 @@
{"type":"session.created"}
{"type":"session.updated"}
{"type":"input_audio_buffer.committed","event_id":"event-fixture-1","previous_item_id":null,"item_id":"fixture-item-1"}
{"type":"conversation.item.input_audio_transcription.completed","item_id":"fixture-item-1","transcript":"final fixture transcript"}
+507
View File
@@ -0,0 +1,507 @@
from __future__ import annotations
import base64
import json
from pathlib import Path
import tempfile
import threading
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from pi_dictate_service.backend import PcmQueue, ProviderError, provider_event
from pi_dictate_service.backends.meta import (
META_ENDPOINT,
META_MAX_BACKLOG_BYTES,
META_SOCKET_IO_TIMEOUT,
MetaBackend,
_default_transport as default_meta_transport,
)
from pi_dictate_service.backends.openai import (
OPENAI_ENDPOINT,
OPENAI_SOCKET_IO_TIMEOUT,
OpenAIBackend,
_default_transport as default_openai_transport,
)
from pi_dictate_service.config import MetaConfig, OpenAIConfig
FIXTURE_ROOT = Path(__file__).parent / "fixtures"
def fixture_events(provider: str) -> list[str]:
return (FIXTURE_ROOT / provider / "events.jsonl").read_text(encoding="utf-8").splitlines()
def _capture_start_error(backend: object, errors: list[BaseException]) -> None:
try:
backend.start_turn()
except BaseException as error:
errors.append(error)
class FakeTransport:
def __init__(self, events: list[str | bytes | None]) -> None:
self.events = list(events)
self.sent: list[str | bytes] = []
self.closed = False
def send(self, value: str | bytes) -> None:
self.sent.append(value)
def recv(self, timeout: float | None = None) -> str | bytes | None:
if not self.events:
return None
return self.events.pop(0)
def close(self) -> None:
self.closed = True
class BlockingCloseTransport(FakeTransport):
def __init__(self, events: list[str | bytes | None]) -> None:
super().__init__(events)
self.close_started = threading.Event()
self.allow_close = threading.Event()
def close(self) -> None:
self.close_started.set()
self.allow_close.wait(1)
super().close()
class HttpFailure(RuntimeError):
def __init__(self, status_code: int) -> None:
super().__init__(f"HTTP {status_code}")
self.response = SimpleNamespace(status_code=status_code)
class BackendTests(unittest.TestCase):
def test_production_transports_apply_a_socket_io_deadline(self) -> None:
for factory, timeout, arguments in (
(default_meta_transport, META_SOCKET_IO_TIMEOUT, (META_ENDPOINT,)),
(
default_openai_transport,
OPENAI_SOCKET_IO_TIMEOUT,
(OPENAI_ENDPOINT,),
),
):
with self.subTest(factory=factory.__module__):
transport = SimpleNamespace(socket=Mock())
with patch("websockets.sync.client.connect", return_value=transport):
if factory is default_openai_transport:
factory(*arguments, headers={"Authorization": "Bearer test"})
else:
factory(*arguments)
transport.socket.settimeout.assert_called_once_with(timeout)
def make_files(self) -> tuple[Path, Path, tempfile.TemporaryDirectory[str]]:
temporary = tempfile.TemporaryDirectory()
root = Path(temporary.name)
key = root / "key"
key.write_text("secret", encoding="utf-8")
key.chmod(0o600)
terms = root / "terms"
terms.write_text("JSON\n", encoding="utf-8")
return key, terms, temporary
def test_meta_waits_for_ack_sends_documented_handshake_and_finalizes_after_tail(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
acknowledgement = (FIXTURE_ROOT / "meta" / "session_ack.json").read_text(encoding="utf-8").strip()
transport = FakeTransport([acknowledgement, *fixture_events("meta"), None])
connections: list[str] = []
def connect(endpoint: str) -> FakeTransport:
connections.append(endpoint)
return transport
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms, language_bias=("en",)),
transport_factory=connect,
timeout=1,
)
turn = backend.start_turn()
turn.enqueue_pcm(b"pcm")
self.assertEqual(turn.finish(), "final fixture transcript")
self.assertEqual(connections, [META_ENDPOINT])
handshake = json.loads(transport.sent[0])
self.assertEqual(handshake["authorization"]["accessToken"], "Bearer secret")
self.assertEqual(handshake["audioEncoding"], "PCM_24KHZ")
self.assertEqual(handshake["mode"], "PUSH_TO_TALK")
self.assertFalse(handshake["emitAudioProgress"])
self.assertEqual(handshake["languageBias"], ["en"])
self.assertEqual(handshake["keywords"], ["JSON"])
self.assertEqual(transport.sent[1], b"pcm")
self.assertEqual(json.loads(transport.sent[2]), {"type": "endStream"})
def test_openai_uses_ga_session_shape_and_correlates_committed_item(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(fixture_events("openai"))
connections: list[tuple[str, dict[str, str]]] = []
def connect(endpoint: str, *, headers: dict[str, str]) -> FakeTransport:
connections.append((endpoint, headers))
return transport
backend = OpenAIBackend(
OpenAIConfig(
"gpt-live-transcribe",
key,
terms,
delay="low",
prompt="Coding",
languages=("en",),
),
transport_factory=connect,
timeout=1,
)
turn = backend.start_turn()
turn.enqueue_pcm(b"pcm")
self.assertEqual(turn.finish(), "final fixture transcript")
self.assertEqual(
connections,
[(OPENAI_ENDPOINT, {"Authorization": "Bearer secret"})],
)
update = json.loads(transport.sent[0])
session = update["session"]
self.assertEqual(session["type"], "transcription")
input_audio = session["audio"]["input"]
self.assertEqual(input_audio["format"], {"type": "audio/pcm", "rate": 24000})
self.assertEqual(input_audio["transcription"]["languages"], ["en"])
self.assertEqual(input_audio["transcription"]["delay"], "low")
self.assertEqual(input_audio["transcription"]["keywords"], ["JSON"])
self.assertIsNone(input_audio["turn_detection"])
self.assertEqual(json.loads(transport.sent[-1]), {"type": "input_audio_buffer.commit"})
self.assertFalse(any(isinstance(item, str) and "response.create" in item for item in transport.sent))
def test_meta_rejects_malformed_ack_and_auth_error(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
malformed = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: FakeTransport(['{"sessionId":"s","unexpected":1}']),
timeout=0.1,
)
with self.assertRaisesRegex(ProviderError, "acknowledgement"):
malformed.start_turn()
auth = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: FakeTransport(['{"type":"error","code":"401"}']),
timeout=0.1,
)
with self.assertRaisesRegex(ProviderError, "Meta transcription service reported") as error:
auth.start_turn()
self.assertEqual(error.exception.code, "meta_auth")
with self.assertRaises(ValueError):
MetaBackend(MetaConfig("muse-voice-transcribe-1.0", key, terms), max_queue_bytes=META_MAX_BACKLOG_BYTES + 1)
def test_cloud_turn_rejects_partial_only_close_and_queue_overflow(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(['{"sessionId":"s"}', '{"type":"transcript","final":false,"text":"partial"}', None])
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: transport,
timeout=0.1,
)
turn = backend.start_turn()
with self.assertRaisesRegex(ProviderError, "final transcript"):
turn.finish()
queue = PcmQueue(max_bytes=2, max_chunks=1)
queue.put(b"12")
with self.assertRaisesRegex(ProviderError, "full"):
queue.put(b"3")
def test_cancel_before_prepare_worker_runs_prevents_connection(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
for backend in (
MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda *_args, **_kwargs: self.fail("Meta connected"),
),
OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda *_args, **_kwargs: self.fail("OpenAI connected"),
),
):
with self.subTest(backend=backend.descriptor.backend):
backend.begin_prepare()
backend.cancel_prepare()
with self.assertRaises(ProviderError) as error:
backend.start_turn()
self.assertEqual(error.exception.code, "cancelled")
def test_cancel_during_connect_prevents_authenticated_handshake(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
for backend_name in ("meta", "openai"):
with self.subTest(backend=backend_name):
entered = threading.Event()
release = threading.Event()
transport = FakeTransport([])
errors: list[BaseException] = []
def connect(*_args: object, **_kwargs: object) -> FakeTransport:
entered.set()
release.wait(1)
return transport
if backend_name == "meta":
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=connect,
timeout=0.1,
)
else:
backend = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=connect,
timeout=0.1,
)
worker = threading.Thread(
target=lambda: _capture_start_error(backend, errors), daemon=True
)
worker.start()
self.assertTrue(entered.wait(1))
backend.cancel_prepare()
release.set()
worker.join(1)
self.assertFalse(worker.is_alive())
self.assertEqual(getattr(errors[0], "code", None), "cancelled")
self.assertEqual(transport.sent, [])
def test_meta_discards_high_volume_partials_instead_of_overflowing_event_queue(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
partial = '{"type":"transcript","final":false,"text":"partial"}'
transport = FakeTransport([
'{"sessionId":"s"}',
*([partial] * 200),
'{"type":"transcript","final":true,"text":"final"}',
None,
])
turn = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: transport,
timeout=1,
).start_turn()
self.assertEqual(turn.finish(), "final")
def test_cancel_never_sends_a_provider_end_marker(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transports_and_turns = []
meta_transport = FakeTransport(['{"sessionId":"s"}'])
transports_and_turns.append((
meta_transport,
MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: meta_transport,
timeout=1,
).start_turn(),
))
openai_transport = FakeTransport([
'{"type":"session.created"}', '{"type":"session.updated"}'
])
transports_and_turns.append((
openai_transport,
OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: openai_transport,
timeout=1,
).start_turn(),
))
for transport, turn in transports_and_turns:
turn.cancel()
deadline = time.monotonic() + 1
while not turn.cleanup_complete and time.monotonic() < deadline:
turn.close()
sent_types = [
value.get("type")
for raw in transport.sent
if isinstance(raw, str) and isinstance((value := json.loads(raw)), dict)
]
self.assertNotIn("endStream", sent_types)
self.assertNotIn("input_audio_buffer.commit", sent_types)
def test_cancel_does_not_block_on_a_slow_transport_close(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = BlockingCloseTransport(['{"sessionId":"s"}'])
turn = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: transport,
timeout=0.1,
).start_turn()
started = time.monotonic()
turn.cancel()
elapsed = time.monotonic() - started
self.assertLess(elapsed, 0.05)
self.assertTrue(transport.close_started.wait(1))
self.assertFalse(turn.cleanup_complete)
transport.allow_close.set()
deadline = time.monotonic() + 1
while not turn.cleanup_complete and time.monotonic() < deadline:
turn.close()
self.assertTrue(turn.cleanup_complete)
def test_http_failures_use_websockets_response_status(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
meta = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: (_ for _ in ()).throw(HttpFailure(401)),
)
with self.assertRaises(ProviderError) as meta_error:
meta.start_turn()
self.assertEqual(meta_error.exception.code, "meta_auth")
openai = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: (_ for _ in ()).throw(HttpFailure(429)),
)
with self.assertRaises(ProviderError) as openai_error:
openai.start_turn()
self.assertEqual(openai_error.exception.code, "openai_rate_limit")
def test_cloud_turn_reports_provider_close_during_recording(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
backend = MetaBackend(
MetaConfig("muse-voice-transcribe-1.0", key, terms),
transport_factory=lambda _endpoint: FakeTransport([
'{"sessionId":"s"}',
None,
]),
timeout=0.1,
)
turn = backend.start_turn()
deadline = time.monotonic() + 1
failure = turn.poll_error()
while failure is None and time.monotonic() < deadline:
time.sleep(0.001)
failure = turn.poll_error()
self.assertIsNotNone(failure)
self.assertEqual(failure.code, "meta_premature_close")
def test_provider_event_rejects_recursive_values(self) -> None:
recursive: dict[str, object] = {}
recursive["self"] = recursive
with self.assertRaises(ProviderError):
provider_event(recursive)
def test_openai_rejects_malformed_error_and_unmatched_completion(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
malformed = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: FakeTransport([
'{"type":"session.created"}',
'{"type":"error","error":{"message":"bad"}}',
]),
timeout=0.1,
)
with self.assertRaisesRegex(ProviderError, "error event"):
malformed.start_turn()
transport = FakeTransport([
'{"type":"session.created"}',
'{"type":"session.updated"}',
'{"type":"input_audio_buffer.committed","item_id":"new"}',
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"old","transcript":"stale"}',
None,
])
backend = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=0.1,
)
turn = backend.start_turn()
with self.assertRaises(ProviderError) as error:
turn.finish()
self.assertIn(error.exception.code, {"openai_premature_close", "openai_timeout"})
def test_openai_commit_oriented_alternative_uses_explicit_commit(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(fixture_events("openai"))
turn = OpenAIBackend(
OpenAIConfig("gpt-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=1,
).start_turn()
turn.enqueue_pcm(b"pcm")
self.assertEqual(turn.finish(), "final fixture transcript")
update = json.loads(transport.sent[0])
self.assertEqual(
update["session"]["audio"]["input"]["transcription"]["model"],
"gpt-transcribe",
)
self.assertEqual(json.loads(transport.sent[-1]), {"type": "input_audio_buffer.commit"})
def test_openai_rejects_unknown_delay_from_direct_callers(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
with self.assertRaisesRegex(ValueError, "delay"):
OpenAIBackend(OpenAIConfig("gpt-live-transcribe", key, terms, delay="fast"))
def test_openai_receiver_failure_overrides_an_already_queued_completion(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport([
'{"type":"session.created"}',
'{"type":"session.updated"}',
'{"type":"input_audio_buffer.committed","item_id":"i"}',
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":"unsafe"}',
'{}',
])
turn = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=1,
).start_turn()
deadline = time.monotonic() + 1
while turn._receiver_error is None and time.monotonic() < deadline:
time.sleep(0.001)
with self.assertRaises(ProviderError) as error:
turn.finish()
self.assertEqual(error.exception.code, "openai_malformed_event")
def test_openai_audio_is_bounded_base64(self) -> None:
key, terms, temporary = self.make_files()
self.addCleanup(temporary.cleanup)
transport = FakeTransport(
['{"type":"session.created"}', '{"type":"session.updated"}',
'{"type":"input_audio_buffer.committed","item_id":"i"}',
'{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":""}']
)
backend = OpenAIBackend(
OpenAIConfig("gpt-live-transcribe", key, terms),
transport_factory=lambda _endpoint, **_kwargs: transport,
timeout=1,
)
turn = backend.start_turn()
turn.enqueue_pcm(b"\x01\x02")
self.assertEqual(turn.finish(), "")
append = json.loads(transport.sent[1])
self.assertEqual(base64.b64decode(append["audio"]), b"\x01\x02")
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from pi_dictate_service.config import (
ConfigError,
CredentialError,
load_config,
parse_config,
provider_keywords,
read_credential,
)
from pi_dictate_service.recorder import audio_level_window_bytes, pw_record_command
class ConfigTests(unittest.TestCase):
def test_recorder_supports_both_backend_sample_rates(self) -> None:
self.assertEqual(pw_record_command(16_000)[5], "16000")
self.assertEqual(pw_record_command(24_000)[5], "24000")
self.assertEqual(audio_level_window_bytes(24_000), 4_800)
def test_rejects_unknown_selected_fields_and_invalid_backend_values(self) -> None:
for invalid_backend in ([], {}, 1, True, None):
with self.subTest(backend=invalid_backend):
with self.assertRaisesRegex(ConfigError, "backend must be"):
parse_config({"version": 1, "backend": invalid_backend})
base = {
"version": 1,
"backend": "openai",
"openai": {
"model": "gpt-live-transcribe",
"apiKeyFile": "~/.config/pi-dictate/credentials/openai-api-key",
"termsPath": "/tmp/terms",
},
}
with patch.dict(os.environ, {"HOME": "/tmp/pi-dictate-test"}):
invalid = {**base, "openai": {**base["openai"], "endpoint": "https://bad"}}
with self.assertRaises(ConfigError):
parse_config(invalid)
with self.assertRaises(ConfigError):
parse_config({**base, "version": 2})
configured = parse_config(
{**base, "openai": {**base["openai"], "delay": "low"}}
)
self.assertEqual(configured.openai.delay, "low")
with self.assertRaises(ConfigError):
parse_config({**base, "openai": {**base["openai"], "delay": "fast"}})
with self.assertRaises(ConfigError):
parse_config({**base, "openai": {**base["openai"], "termsPath": "relative/terms"}})
def test_reads_only_one_trailing_newline_from_a_private_credential(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "key"
path.write_text("secret\n", encoding="utf-8")
path.chmod(0o600)
self.assertEqual(read_credential(path), "secret")
path.chmod(0o640)
with self.assertRaises(CredentialError):
read_credential(path)
path.chmod(0o600)
path.write_text("secret\nextra", encoding="utf-8")
with self.assertRaises(CredentialError):
read_credential(path)
def test_rejects_a_credential_reached_through_a_symlinked_directory(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
actual = root / "actual"
actual.mkdir()
key = actual / "key"
key.write_text("secret", encoding="utf-8")
key.chmod(0o600)
linked = root / "credentials"
linked.symlink_to(actual, target_is_directory=True)
with self.assertRaisesRegex(CredentialError, "ancestry"):
read_credential(linked / "key")
def test_config_file_is_regular_private_and_provider_terms_reject_cr(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config_path = root / "config.json"
config_path.write_text(
json.dumps(
{
"version": 1,
"backend": "local",
"local": {
"adapter": "nemo-parakeet",
"modelPath": "/tmp/model.nemo",
"modelSha256": "0" * 64,
"termsPath": "/tmp/terms",
"boostAlpha": 1.0,
},
}
),
encoding="utf-8",
)
config_path.chmod(0o600)
self.assertEqual(load_config(config_path).backend, "local")
terms = root / "terms"
terms.write_bytes(b"bad\rterm")
with self.assertRaises(ConfigError):
provider_keywords(terms)
terms.write_bytes(b"safe\nbad\x00term\n")
with self.assertRaises(ConfigError):
provider_keywords(terms)
if __name__ == "__main__":
unittest.main()
+496
View File
@@ -0,0 +1,496 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "install.sh"
SYSTEMD_ROOT = SCRIPT.parents[2] / "systemd/user"
LEGACY_SERVICE = """# Managed by pi-dictate install.sh
[Unit]
Description=Pi local Parakeet dictation service
Requires=pi-dictate.socket
After=pipewire.service
[Service]
Type=simple
ExecStart=%h/.local/share/pi-dictate/venv/bin/pi-dictate-service
Environment=PI_DICTATE_MODEL_PATH=%h/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo
Environment=PI_DICTATE_TERMS_PATH=%h/.local/share/pi-dictate/config/coding-terms.txt
Environment=PI_DICTATE_BOOST_ALPHA=1.0
Environment=PI_DICTATE_IDLE_SECONDS=1800
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
Environment=PYTHONDONTWRITEBYTECODE=1
UMask=0077
LimitCORE=0
MemorySwapMax=0
NoNewPrivileges=yes
PrivateTmp=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectSystem=strict
ProtectHome=read-only
RestrictAddressFamilies=AF_UNIX
IPAddressDeny=any
LockPersonality=yes
# The socket unit owns %t/pi-dictate.sock. The service must not unlink it.
"""
class InstallFlowTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.root = Path(self.directory.name)
self.home = self.root / "home"
self.home.mkdir()
self.bin = self.root / "bin"
self.bin.mkdir()
uv = self.bin / "uv"
# A real uv sync creates the requested environment. Keep the fake
# faithful to that contract so transactional environment swapping is
# exercised without installing dependencies.
uv.write_text("#!/bin/sh\nmkdir -p \"$UV_PROJECT_ENVIRONMENT\"\nexit 0\n", encoding="utf-8")
uv.chmod(0o755)
systemctl = self.bin / "systemctl"
systemctl.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
systemctl.chmod(0o755)
def tearDown(self) -> None:
self.directory.cleanup()
def run_installer(self, *arguments: str) -> subprocess.CompletedProcess[str]:
environment = {
**os.environ,
"HOME": str(self.home),
"XDG_CONFIG_HOME": str(self.home / ".config"),
"PATH": f"{self.bin}:{os.environ['PATH']}",
}
return subprocess.run(
[str(SCRIPT), *arguments],
env=environment,
capture_output=True,
text=True,
check=False,
)
def test_real_staged_cloud_venv_python_runs_after_transactional_move(self) -> None:
real_uv = shutil.which("uv")
self.assertIsNotNone(real_uv)
(self.bin / "uv").unlink()
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
result = self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
)
self.assertEqual(result.returncode, 0, result.stderr)
python = self.home / ".local/share/pi-dictate/venv/bin/python"
executed = subprocess.run(
[str(python), "-c", "import pi_dictate_service"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(executed.returncode, 0, executed.stderr)
def test_upgrades_the_previous_service_unit_and_preserves_its_idle_setting(self) -> None:
unit_dir = self.home / ".config/systemd/user"
unit_dir.mkdir(parents=True)
(unit_dir / "pi-dictate.service").write_text(LEGACY_SERVICE, encoding="utf-8")
(unit_dir / "pi-dictate.socket").write_bytes(
(SYSTEMD_ROOT / "pi-dictate.socket").read_bytes()
)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
(unit_dir / "pi-dictate.service").read_bytes(),
(SYSTEMD_ROOT / "pi-dictate.service").read_bytes(),
)
config = json.loads(
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
)
self.assertEqual(config["idleSeconds"], 1800)
def test_rejects_symlinked_install_ancestry(self) -> None:
outside = self.root / "outside"
outside.mkdir()
(self.home / ".local").symlink_to(outside, target_is_directory=True)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("symlinked path ancestry", result.stderr)
self.assertEqual(list(outside.iterdir()), [])
def test_cloud_install_uses_only_cloud_profile_and_managed_key(self) -> None:
source = self.root / "meta-key"
source.write_text("test-key\n", encoding="utf-8")
result = self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("test-key", result.stdout + result.stderr)
self.assertNotIn("nvidia", result.stdout)
self.assertFalse(
(self.home / ".local/share/pi-dictate/models").exists()
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(config["backend"], "meta")
self.assertEqual(config["meta"]["model"], "muse-voice-transcribe-1.0")
self.assertEqual(config["meta"]["languageBias"], [])
self.assertEqual(
(self.home / ".config/pi-dictate/credentials/meta-api-key").read_text(
encoding="utf-8"
),
"test-key",
)
self.assertEqual(stat.S_IMODE(config_path.stat().st_mode), 0o600)
self.assertEqual(
stat.S_IMODE(
(self.home / ".config/pi-dictate/credentials/meta-api-key").stat().st_mode
),
0o600,
)
self.assertEqual(
(self.home / ".local/share/pi-dictate/.dependency-profile").read_text(
encoding="utf-8"
),
"cloud\n",
)
def test_check_rejects_values_that_runtime_configuration_rejects(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
valid = json.loads(config_path.read_text(encoding="utf-8"))
invalid_configs = []
boolean_version = {**valid, "version": True}
invalid_configs.append(boolean_version)
unhashable_backend = {**valid, "backend": []}
invalid_configs.append(unhashable_backend)
long_model = json.loads(json.dumps(valid))
long_model["openai"]["model"] = "m" * 129
invalid_configs.append(long_model)
unhashable_delay = json.loads(json.dumps(valid))
unhashable_delay["openai"]["delay"] = []
invalid_configs.append(unhashable_delay)
relative_terms = json.loads(json.dumps(valid))
relative_terms["openai"]["termsPath"] = "relative/terms.txt"
invalid_configs.append(relative_terms)
for invalid in invalid_configs:
with self.subTest(invalid=invalid):
config_path.write_text(json.dumps(invalid), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_installer("check")
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Traceback", result.stderr)
def test_check_rejects_extra_dependency_marker_newlines(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
marker = self.home / ".local/share/pi-dictate/.dependency-profile"
marker.write_bytes(b"cloud\n\n")
marker.chmod(0o600)
result = self.run_installer("check")
self.assertNotEqual(result.returncode, 0)
self.assertIn("dependency profile", result.stderr)
def test_uninstall_removes_an_exact_openai_dropin(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
result = self.run_installer("uninstall")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(
(self.home / ".config/systemd/user/pi-dictate.service.d/10-pi-dictate-network.conf").exists()
)
def test_openai_configure_preserves_live_verified_delay(self) -> None:
source = self.root / "openai-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "openai", "--api-key-file", str(source)
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["openai"]["delay"] = "low"
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_installer("configure", "--backend", "openai")
self.assertEqual(result.returncode, 0, result.stderr)
configured = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(configured["openai"]["delay"], "low")
def test_meta_configure_preserves_valid_language_bias(self) -> None:
source = self.root / "meta-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["meta"]["languageBias"] = ["en", "fr"]
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_installer("configure", "--backend", "meta")
self.assertEqual(result.returncode, 0, result.stderr)
configured = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(configured["meta"]["languageBias"], ["en", "fr"])
def test_local_custom_archive_requires_and_records_explicit_digest(self) -> None:
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
config = json.loads(
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
)
self.assertEqual(config["local"]["modelSha256"], digest)
installed = self.home / (
".local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/"
"parakeet-tdt-0.6b-v3.nemo"
)
self.assertEqual(installed.read_bytes(), source.read_bytes())
def test_replacing_a_custom_local_model_preserves_its_configured_path(self) -> None:
first = self.root / "first.nemo"
first.write_bytes(b"first archive")
first_digest = hashlib.sha256(first.read_bytes()).hexdigest()
self.assertEqual(
self.run_installer(
"install", "--backend", "local", "--model-source", str(first),
"--model-sha256", first_digest,
).returncode,
0,
)
config_path = self.home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
custom_model = self.root / "custom-models/model.nemo"
custom_model.parent.mkdir()
custom_model.write_bytes(first.read_bytes())
config["local"]["modelPath"] = str(custom_model)
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
second = self.root / "second.nemo"
second.write_bytes(b"second archive")
second_digest = hashlib.sha256(second.read_bytes()).hexdigest()
result = self.run_installer(
"configure", "--backend", "local", "--model-source", str(second),
"--model-sha256", second_digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
configured = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(configured["local"]["modelPath"], str(custom_model))
self.assertEqual(custom_model.read_bytes(), second.read_bytes())
self.assertFalse(list(custom_model.parent.glob("*.pi-dictate-rollback.*")))
def test_switching_from_cloud_to_local_accepts_a_local_archive(self) -> None:
key = self.root / "meta-key"
key.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(key)
).returncode,
0,
)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"configure",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
config = json.loads(
(self.home / ".config/pi-dictate/config.json").read_text(encoding="utf-8")
)
self.assertEqual(config["backend"], "local")
self.assertEqual(config["local"]["modelSha256"], digest)
self.assertIn("meta", config)
def test_systemd_failure_rolls_back_staged_install(self) -> None:
old_venv = self.home / ".local/share/pi-dictate/venv"
old_venv.mkdir(parents=True)
(old_venv / "previous-runtime").write_text("old", encoding="utf-8")
source = self.root / "meta-key"
source.write_text("test-key", encoding="utf-8")
(self.bin / "systemctl").write_text(
"#!/bin/sh\necho \"$*\" >> \"$HOME/systemctl.log\"\n"
"case \"$*\" in *'is-enabled'*) exit 1;; "
"*'stop pi-dictate.service'*) exit 9;; esac\nexit 0\n",
encoding="utf-8",
)
(self.bin / "systemctl").chmod(0o755)
result = self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.home / ".config/pi-dictate/config.json").exists())
self.assertFalse((self.home / ".local/share/pi-dictate/.dependency-profile").exists())
self.assertFalse((self.home / ".local/share/pi-dictate/config/coding-terms.txt").exists())
self.assertEqual((old_venv / "previous-runtime").read_text(encoding="utf-8"), "old")
systemctl_calls = (self.home / "systemctl.log").read_text(encoding="utf-8")
self.assertGreaterEqual(systemctl_calls.count("daemon-reload"), 1)
self.assertIn("disable pi-dictate.socket", systemctl_calls)
def test_invalid_cloud_key_does_not_commit_a_dependency_marker(self) -> None:
source = self.root / "invalid-key"
source.write_text("two\nlines\n", encoding="utf-8")
result = self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(
(self.home / ".local/share/pi-dictate/.dependency-profile").exists()
)
def test_dictionary_symlink_is_rejected(self) -> None:
dictionary = self.home / ".local/share/pi-dictate/config/coding-terms.txt"
dictionary.parent.mkdir(parents=True)
target = self.root / "unexpected-terms.txt"
target.write_text("unexpected", encoding="utf-8")
dictionary.symlink_to(target)
source = self.root / "custom.nemo"
source.write_bytes(b"custom archive")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_installer(
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("non-regular coding dictionary", result.stderr)
def test_uninstall_preserves_config_unless_purge_is_requested(self) -> None:
source = self.root / "meta-key"
source.write_text("test-key", encoding="utf-8")
self.assertEqual(
self.run_installer(
"install", "--backend", "meta", "--api-key-file", str(source)
).returncode,
0,
)
result = self.run_installer("uninstall")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((self.home / ".config/pi-dictate/config.json").exists())
self.assertFalse((self.home / ".local/share/pi-dictate").exists())
# Reinstalling from the preserved key is possible without passing a
# literal secret or a second key source.
result = self.run_installer("install", "--backend", "meta")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
self.run_installer("uninstall", "--purge-config").returncode, 0
)
self.assertFalse((self.home / ".config/pi-dictate").exists())
if __name__ == "__main__":
unittest.main()
+26 -1
View File
@@ -1,3 +1,4 @@
import hashlib
from pathlib import Path
import os
import subprocess
@@ -24,8 +25,11 @@ class InstallerTests(unittest.TestCase):
home / ".local/share/pi-dictate"
),
}
command = [str(SCRIPT), *arguments, "--dry-run"]
if not any(action in arguments for action in ("uninstall", "check")):
command.extend(("--model-source", str(model), "--model-sha256", hashlib.sha256(model.read_bytes()).hexdigest()))
return subprocess.run(
[str(SCRIPT), *arguments, "--dry-run", "--model-source", str(model)],
command,
env=environment,
capture_output=True,
text=True,
@@ -47,7 +51,9 @@ class InstallerTests(unittest.TestCase):
self.assertNotIn("ln -s", result.stdout)
self.assertIn(".nemo.install.", result.stdout)
self.assertIn("mv -f", result.stdout)
self.assertIn("uv venv --relocatable", result.stdout)
self.assertIn("uv sync --frozen --no-dev --no-editable", result.stdout)
self.assertIn("--reinstall-package pi-dictate-service", result.stdout)
self.assertIn("Would install pi-dictate", result.stdout)
self.assertFalse((home / ".local/share/pi-dictate").exists())
@@ -87,6 +93,25 @@ class InstallerTests(unittest.TestCase):
"[Socket]\nListenStream=/tmp/unrelated.sock\n",
)
def test_uninstall_refuses_a_modified_managed_unit(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
model = home / "model.nemo"
model.write_bytes(b"model")
unit = home / ".config/systemd/user/pi-dictate.socket"
unit.parent.mkdir(parents=True)
unit.write_text(
"# Managed by pi-dictate install.sh\n[Socket]\nListenStream=/tmp/tampered.sock\n",
encoding="utf-8",
)
result = self.run_installer(home, model, "uninstall")
self.assertEqual(result.returncode, 1)
self.assertIn("unmanaged systemd unit", result.stderr)
self.assertNotIn("systemctl --user disable", result.stdout)
self.assertTrue(unit.exists())
def test_reinstall_preserves_an_existing_host_dictionary(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from pi_dictate_service.live_smoke import MAX_SAMPLE_BYTES, _read_sample
class LiveSmokeTests(unittest.TestCase):
def test_reads_one_bounded_even_pcm_fixture(self) -> None:
with tempfile.TemporaryDirectory() as directory:
sample = Path(directory) / "sample.pcm"
sample.write_bytes(b"\x00\x01" * 100)
self.assertEqual(_read_sample(sample), sample.read_bytes())
def test_rejects_symlink_odd_and_oversized_fixtures(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
valid = root / "valid.pcm"
valid.write_bytes(b"\x00\x00")
link = root / "link.pcm"
link.symlink_to(valid)
odd = root / "odd.pcm"
odd.write_bytes(b"x")
oversized = root / "oversized.pcm"
oversized.write_bytes(b"x" * (MAX_SAMPLE_BYTES + 2))
for path in (link, odd, oversized):
with self.subTest(path=path), self.assertRaises((OSError, ValueError)):
_read_sample(path)
if __name__ == "__main__":
unittest.main()
+33 -1
View File
@@ -38,12 +38,20 @@ class FakeNemoModel:
class FakeConcreteParakeetModel:
restored_path: str | None = None
restored_location: object | None = None
restored_bytes: bytes | None = None
replace_before_restore: tuple[Path, bytes] | None = None
restored: FakeNemoModel | None = None
@classmethod
def restore_from(cls, restore_path: str, map_location: object) -> FakeNemoModel:
cls.restored_path = restore_path
cls.restored_location = map_location
if cls.replace_before_restore is not None:
path, replacement = cls.replace_before_restore
replacement_path = path.with_suffix(".replacement")
replacement_path.write_bytes(replacement)
replacement_path.replace(path)
cls.restored_bytes = Path(restore_path).read_bytes()
self = FakeNemoModel()
cls.restored = self
return self
@@ -57,6 +65,13 @@ class FakeNumpy(ModuleType):
class NemoParakeetModelTests(unittest.TestCase):
def setUp(self) -> None:
FakeConcreteParakeetModel.restored_path = None
FakeConcreteParakeetModel.restored_location = None
FakeConcreteParakeetModel.restored_bytes = None
FakeConcreteParakeetModel.replace_before_restore = None
FakeConcreteParakeetModel.restored = None
def test_load_is_lazy_offline_applies_coding_terms_and_transcribes_in_memory_audio(self) -> None:
with tempfile.TemporaryDirectory() as directory:
model_path = Path(directory) / "parakeet.nemo"
@@ -78,7 +93,8 @@ class NemoParakeetModelTests(unittest.TestCase):
self.assertEqual(os.environ["HF_HUB_OFFLINE"], "1")
self.assertEqual(os.environ["TRANSFORMERS_OFFLINE"], "1")
self.assertEqual(FakeConcreteParakeetModel.restored_path, str(model_path))
self.assertRegex(FakeConcreteParakeetModel.restored_path or "", r"^/proc/self/fd/\d+$")
self.assertEqual(FakeConcreteParakeetModel.restored_bytes, b"model")
self.assertEqual(FakeConcreteParakeetModel.restored_location, "device:cuda")
self.assertTrue(FakeConcreteParakeetModel.restored.eval_called)
self.assertEqual(FakeConcreteParakeetModel.restored.to_device, "cuda")
@@ -107,6 +123,22 @@ class NemoParakeetModelTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "128 UTF-8 bytes"):
load_coding_terms(terms_path)
def test_load_uses_the_verified_inode_if_the_configured_path_is_replaced(self) -> None:
with tempfile.TemporaryDirectory() as directory:
model_path = Path(directory) / "parakeet.nemo"
model_path.write_bytes(b"verified model")
FakeConcreteParakeetModel.replace_before_restore = (model_path, b"replacement")
with patch.dict(sys.modules, self.fake_modules(), clear=False):
adapter = NemoParakeetModel(
model_path,
expected_sha256=hashlib.sha256(b"verified model").hexdigest(),
)
adapter.load()
self.assertEqual(model_path.read_bytes(), b"replacement")
self.assertEqual(FakeConcreteParakeetModel.restored_bytes, b"verified model")
def test_refuses_a_model_that_no_longer_matches_the_pinned_hash(self) -> None:
with tempfile.TemporaryDirectory() as directory:
model_path = Path(directory) / "parakeet.nemo"
+20
View File
@@ -0,0 +1,20 @@
import unittest
from pi_dictate_service.protocol import MAX_FRAME_BYTES, FrameDecoder
class FrameDecoderRegressionTests(unittest.TestCase):
def test_accepts_pipelined_bounded_frames_larger_than_aggregate_limit(self) -> None:
decoder = FrameDecoder()
frame = b'{"version":2,"type":"ping","requestId":"request"}\n'
payload = frame * ((MAX_FRAME_BYTES // len(frame)) + 1)
self.assertGreater(len(payload), MAX_FRAME_BYTES + 1)
decoded = decoder.feed(payload)
self.assertEqual(len(decoded), payload.count(b"\n"))
self.assertEqual(decoded[0]["requestId"], "request")
if __name__ == "__main__":
unittest.main()
+156 -3
View File
@@ -36,6 +36,18 @@ class TrackingStream(io.BytesIO):
return value
class BlockingTrackingStream(TrackingStream):
def __init__(self, value: bytes, events: list[str], name: str) -> None:
super().__init__(value, events, name)
self.allow_eof = threading.Event()
def read(self, size: int = -1) -> bytes:
value = super().read(size)
if not value:
self.allow_eof.wait(1)
return value
class FakeProcess:
def __init__(
self,
@@ -45,6 +57,7 @@ class FakeProcess:
returncode: int | None = None,
timeout: bool = False,
terminate_returncode: int = -15,
kill_stalls: bool = False,
) -> None:
self.pid = 1234
self.stdout = stdout
@@ -52,6 +65,7 @@ class FakeProcess:
self.returncode = returncode
self._timeout = timeout
self._terminate_returncode = terminate_returncode
self._kill_stalls = kill_stalls
self.events = stdout._events
def poll(self) -> int | None:
@@ -64,8 +78,9 @@ class FakeProcess:
def kill(self) -> None:
self.events.append("kill")
self.returncode = -9
self._timeout = False
if not self._kill_stalls:
self.returncode = -9
self._timeout = False
def wait(self, timeout: float | None = None) -> int:
self.events.append("wait")
@@ -86,6 +101,22 @@ class ProcessFactory:
return self.process
class StreamingSink:
def __init__(self) -> None:
self.chunks: list[bytes] = []
self.finalized = False
self.cancelled = False
def enqueue_pcm(self, chunk: bytes) -> None:
self.chunks.append(chunk)
def close_input(self) -> None:
self.finalized = True
def cancel(self) -> None:
self.cancelled = True
class Clock:
def __init__(self) -> None:
self.now = 0.0
@@ -95,6 +126,16 @@ class Clock:
class PwRecorderTests(unittest.TestCase):
def test_spawn_failure_is_already_cleanup_complete(self) -> None:
def fail_spawn(*_args: object, **_kwargs: object) -> FakeProcess:
raise OSError("spawn failed")
recorder = PwRecorder(process_factory=fail_spawn)
with self.assertRaisesRegex(RecorderError, "could not start"):
recorder.start()
self.assertTrue(recorder.cleanup_complete)
def make_recorder(
self,
pcm: bytes = b"\x00\x00\x01\x00",
@@ -107,14 +148,23 @@ class PwRecorderTests(unittest.TestCase):
clock: Clock | None = None,
terminate_returncode: int = -15,
stdout_read_size: int | None = None,
kill_stalls: bool = False,
block_stdout_at_eof: bool = False,
pcm_sink: StreamingSink | None = None,
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
events: list[str] = []
stdout = (
BlockingTrackingStream(pcm, events, "stdout")
if block_stdout_at_eof
else TrackingStream(pcm, events, "stdout", stdout_read_size)
)
process = FakeProcess(
TrackingStream(pcm, events, "stdout", stdout_read_size),
stdout,
TrackingStream(stderr, events, "stderr"),
returncode=returncode,
timeout=timeout,
terminate_returncode=terminate_returncode,
kill_stalls=kill_stalls,
)
factory = ProcessFactory(process)
def signal_group(pid: int, signal_number: int) -> None:
@@ -133,9 +183,44 @@ class PwRecorderTests(unittest.TestCase):
stop_timeout=0.01,
clock=clock or Clock(),
process_group_signal=signal_group,
pcm_sink=pcm_sink,
)
return recorder, process, factory, events
def test_successful_stop_finalizes_streaming_sink(self) -> None:
sink = StreamingSink()
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
recorder.stop()
self.assertTrue(sink.finalized)
self.assertFalse(sink.cancelled)
def test_cancel_aborts_streaming_sink_without_finalizing(self) -> None:
sink = StreamingSink()
recorder, process, _factory, _events = self.make_recorder(pcm_sink=sink)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
recorder.cancel()
self.assertTrue(sink.cancelled)
self.assertFalse(sink.finalized)
def test_unexpected_exit_aborts_streaming_sink_without_finalizing(self) -> None:
sink = StreamingSink()
recorder, process, _factory, _events = self.make_recorder(returncode=3, pcm_sink=sink)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
error = recorder.poll_unexpected()
self.assertIsNotNone(error)
self.assertTrue(sink.cancelled)
self.assertFalse(sink.finalized)
def test_spawns_exact_pw_record_command_and_drains_bounded_streams(self) -> None:
recorder, process, factory, events = self.make_recorder(
pcm=b"\x00\x00\x01\x00", stderr=b"0123456789", stderr_limit=8
@@ -230,6 +315,39 @@ class PwRecorderTests(unittest.TestCase):
with self.assertRaises(RecorderError):
noisy.stop()
def test_shutdown_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
timeout=True,
kill_stalls=True,
)
recorder.start()
with self.assertRaisesRegex(RecorderError, "after SIGKILL") as error:
recorder.stop()
self.assertEqual(error.exception.code, "recorder_timeout")
self.assertIn("kill", process.events)
def test_shutdown_retries_process_and_drainer_cleanup_after_double_timeout(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
timeout=True,
kill_stalls=True,
block_stdout_at_eof=True,
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
with self.assertRaises(RecorderError):
recorder.stop()
self.assertFalse(recorder.cleanup_complete)
process._kill_stalls = False
process.stdout.allow_eof.set()
recorder.cancel()
self.assertTrue(recorder.cleanup_complete)
def test_shutdown_timeout_escalates_to_kill_and_fails(self) -> None:
recorder, _process, _factory, events = self.make_recorder(timeout=True)
recorder.start()
@@ -240,6 +358,22 @@ class PwRecorderTests(unittest.TestCase):
self.assertEqual(failed.exception.code, "recorder_timeout")
self.assertLess(events.index("terminate"), events.index("kill"))
def test_stop_rejects_an_exit_observed_after_the_last_listening_poll(self) -> None:
recorder, process, _factory, _events = self.make_recorder()
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
# The process was alive during the listening poll, then exited cleanly
# before stop could signal it. A partial buffer is not a valid capture.
self.assertIsNone(recorder.poll_unexpected())
process.returncode = 0
with self.assertRaises(RecorderError) as failed:
recorder.stop()
self.assertEqual(failed.exception.code, "recorder_exit")
self.assertEqual(recorder.buffered_bytes, 0)
def test_classifies_unexpected_exit_code_and_signal(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
stderr=b"pipewire failed", returncode=7
@@ -288,6 +422,25 @@ class PwRecorderTests(unittest.TestCase):
self.assertEqual(failed.exception.code, "duration_limit")
self.assertEqual(recorder.buffered_bytes, 0)
def test_duration_limit_reports_a_bounded_error_when_sigkill_does_not_reap(self) -> None:
clock = Clock()
recorder, process, _factory, _events = self.make_recorder(
pcm=b"\x00\x00" * 3,
max_bytes=4,
timeout=True,
kill_stalls=True,
clock=clock,
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
clock.now = 1
failure = recorder.poll_unexpected()
self.assertIsNotNone(failure)
self.assertEqual(failure.code, "recorder_timeout")
self.assertIn("kill", process.events)
def test_duration_limit_kills_a_recorder_that_ignores_sigterm(self) -> None:
clock = Clock()
recorder, process, _factory, events = self.make_recorder(
+552 -2
View File
@@ -1,11 +1,22 @@
from concurrent.futures import Future
from collections.abc import Sequence
from dataclasses import dataclass
import errno
import selectors
import subprocess
import sys
import unittest
from unittest.mock import patch
from pi_dictate_service.protocol import MAX_TRANSCRIPT_BYTES, PROTOCOL_VERSION, parse_request
from pi_dictate_service.protocol import (
MAX_FRAME_BYTES,
MAX_TRANSCRIPT_BYTES,
PROTOCOL_VERSION,
encode_frame,
parse_request,
)
from pi_dictate_service.recorder import Capture, RecorderError
from pi_dictate_service.server import DictateServiceCore, Outbound
from pi_dictate_service.server import DictateServiceCore, Outbound, serve
class Clock:
@@ -93,6 +104,154 @@ class FakeRecorder:
return failure
class SpawnFailRecorder(FakeRecorder):
cleanup_complete = True
def start(self) -> None:
raise RecorderError("recorder_spawn", "could not start pw-record")
class SpawnFailRecorderFactory:
def __call__(self) -> SpawnFailRecorder:
return SpawnFailRecorder()
class DelayedCleanupRecorder(FakeRecorder):
def __init__(self) -> None:
super().__init__()
self.cleanup_complete = False
self.cleanup_attempts = 0
self.cleanup_started = False
def stop(self) -> Capture:
self.cleanup_started = True
raise RecorderError("recorder_timeout", "pw-record cleanup did not complete")
def poll_unexpected(self) -> RecorderError | None:
if not self.cleanup_started:
return None
self.cleanup_attempts += 1
if self.cleanup_attempts >= 3:
self.cleanup_complete = True
return None
class DelayedCleanupRecorderFactory:
def __init__(self) -> None:
self.instance = DelayedCleanupRecorder()
def __call__(self) -> DelayedCleanupRecorder:
return self.instance
class DelayedCloseTurn:
sample_rate_hz = 16_000
retain_pcm = True
def __init__(self) -> None:
self.close_calls = 0
@property
def cleanup_complete(self) -> bool:
return self.close_calls >= 3
def enqueue_pcm(self, chunk: bytes) -> None:
return None
def finish(self, capture: Capture | None = None) -> str:
return "cloud result"
def poll_error(self) -> None:
return None
def cancel(self) -> None:
return None
def close(self) -> None:
self.close_calls += 1
class StreamingTurn:
sample_rate_hz = 24_000
retain_pcm = False
def __init__(self, events: list[str]) -> None:
self.events = events
self.closed = False
@property
def cleanup_complete(self) -> bool:
return self.closed
def enqueue_pcm(self, chunk: bytes) -> None:
self.events.append(f"audio-{chunk.decode('ascii')}")
def finish(self, capture: Capture | None = None) -> str:
self.events.append("finish")
self.assert_capture = capture
return "cloud result"
def poll_error(self) -> None:
return None
def cancel(self) -> None:
self.events.append("cancel")
def close(self) -> None:
self.closed = True
class StreamingRecorder(FakeRecorder):
def __init__(self, sink: StreamingTurn, events: list[str]) -> None:
super().__init__()
self.sink = sink
self.events = events
self.capture = Capture(b"", -15, 15, b"")
def start(self) -> None:
super().start()
self.sink.enqueue_pcm(b"a")
self.sink.enqueue_pcm(b"b")
def stop(self) -> Capture:
self.events.append("recorder-stop")
return super().stop()
class StreamingRecorderFactory:
def __init__(self, events: list[str]) -> None:
self.events = events
self.sample_rate_hz: int | None = None
self.instance: StreamingRecorder | None = None
def __call__(
self,
*,
sample_rate_hz: int,
pcm_sink: StreamingTurn,
retain_pcm: bool,
) -> StreamingRecorder:
self.sample_rate_hz = sample_rate_hz
if retain_pcm:
raise AssertionError("cloud capture must not retain PCM")
self.instance = StreamingRecorder(pcm_sink, self.events)
return self.instance
class FakeBackend:
sample_rate_hz = 16_000
descriptor = type("Descriptor", (), {"backend": "fake", "model": "fake-model"})()
def __init__(self, turn: DelayedCloseTurn | StreamingTurn) -> None:
self.turn = turn
def start_turn(self) -> DelayedCloseTurn:
return self.turn
def cancel_prepare(self) -> None:
return None
class RecorderFactory:
def __init__(self) -> None:
self.instances: list[FakeRecorder] = []
@@ -271,6 +430,121 @@ class StopAndInferenceTests(CoreTestCase):
self.assertEqual(completed[0]["transcript"], "dictated text")
self.assertEqual(completed[1]["state"], "idle")
def test_realtime_backend_streams_ordered_pcm_and_finalizes_after_recorder_stop(self) -> None:
events: list[str] = []
turn = StreamingTurn(events)
recorders = StreamingRecorderFactory(events)
core = DictateServiceCore(
backend=FakeBackend(turn),
recorder_factory=recorders,
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.assertEqual(recorders.sample_rate_hz, 24_000)
self.assertEqual(events, ["audio-a", "audio-b"])
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "stop",
"requestId": "stop",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.executor.run_next()
core.poll()
self.assertEqual(events, ["audio-a", "audio-b", "recorder-stop", "finish"])
result = [event.frame for event in core.drain_events() if event.frame["type"] == "result"]
self.assertEqual(result[0]["transcript"], "cloud result")
self.assertIsNone(core.active_generation_id)
def test_cancel_after_prepare_completion_tracks_turn_cleanup_before_release(self) -> None:
events: list[str] = []
turn = StreamingTurn(events)
core = DictateServiceCore(
backend=FakeBackend(turn),
recorder_factory=StreamingRecorderFactory(events),
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
# The future has completed, but the socket loop has not yet applied it.
core.handle_request("a", parse_request({
"version": 2,
"type": "cancel",
"requestId": "cancel",
"generationId": "g1",
}))
core.poll()
self.assertIn("cancel", events)
self.assertTrue(turn.cleanup_complete)
self.assertIsNone(core.active_generation_id)
def test_completed_turn_retries_bounded_cleanup_before_releasing_generation(self) -> None:
turn = DelayedCloseTurn()
core = DictateServiceCore(
backend=FakeBackend(turn),
recorder_factory=self.recorders,
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({"version": 2, "type": "hello", "requestId": "hello"}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "stop",
"requestId": "stop",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.executor.run_next()
core.poll()
self.assertEqual(core.active_generation_id, "g1")
self.assertEqual(turn.close_calls, 2)
core.poll()
self.assertIsNone(core.active_generation_id)
self.assertEqual(turn.close_calls, 3)
def test_empty_transcript_is_a_result(self) -> None:
self.model.transcript = ""
self.load_to_listening()
@@ -321,6 +595,8 @@ class StopAndInferenceTests(CoreTestCase):
recorder = self.load_to_listening()
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 3")
self.core.poll()
self.executor.run_next()
self.core.poll()
failed = self.frames(self.core.drain_events(), "a")
@@ -332,12 +608,286 @@ class StopAndInferenceTests(CoreTestCase):
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 0")
failed = self.request("a", "stop", "stop", "g1")
self.executor.run_next()
self.core.poll()
failed.extend(self.frames(self.core.drain_events(), "a"))
self.assertEqual(failed[0]["code"], "recorder_exit")
self.assertEqual(failed[-1]["state"], "idle")
self.assertEqual(self.executor.pending, [])
def test_recorder_spawn_failure_releases_generation_without_cleanup_loop(self) -> None:
core = DictateServiceCore(
model=self.model,
recorder_factory=SpawnFailRecorderFactory(),
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({
"version": 2, "type": "hello", "requestId": "hello"
}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
events = self.frames(core.drain_events(), "a")
self.assertEqual(events[-2]["code"], "recorder_spawn")
self.assertEqual(events[-1]["state"], "idle")
self.assertIsNone(core.active_generation_id)
self.assertEqual(self.executor.pending, [])
def test_unreaped_recorder_keeps_generation_busy_until_cleanup_completes(self) -> None:
recorders = DelayedCleanupRecorderFactory()
core = DictateServiceCore(
model=self.model,
recorder_factory=recorders,
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
core.accepted("a")
core.handle_request("a", parse_request({
"version": 2, "type": "hello", "requestId": "hello"
}))
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "start",
"requestId": "start",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
core.drain_events()
core.handle_request("a", parse_request({
"version": 2,
"type": "stop",
"requestId": "stop",
"generationId": "g1",
}))
self.executor.run_next()
core.poll()
self.assertEqual(core.active_generation_id, "g1")
# First reap the cancellation worker scheduled by the terminal stop.
self.executor.run_next()
core.poll()
for attempt in range(1, 4):
self.executor.run_next()
core.poll()
if attempt < 3:
self.assertEqual(core.active_generation_id, "g1")
self.assertIsNone(core.active_generation_id)
self.assertEqual(recorders.instance.cleanup_attempts, 3)
class SocketLoopRegressionTests(unittest.TestCase):
def test_output_overflow_does_not_use_the_closed_socket_from_same_selector_event(self) -> None:
frame = {"padding": "x" * (MAX_FRAME_BYTES - len(encode_frame({"padding": ""})))}
self.assertEqual(len(encode_frame(frame)), MAX_FRAME_BYTES)
client = _FakeClientSocket()
listener = _FakeListener(client)
selector = _FakeSelector(client)
core = _FakeLoopCore(
Outbound("client-1", frame),
)
with patch("pi_dictate_service.server.selectors.DefaultSelector", return_value=selector):
serve(listener, core)
self.assertEqual(client.send_calls, 0)
self.assertTrue(client.closed)
self.assertTrue(listener.closed)
def test_close_does_not_wait_for_owned_executor_workers(self) -> None:
executor = _ShutdownRecordingExecutor()
with patch("pi_dictate_service.server._DaemonExecutor", return_value=executor):
core = DictateServiceCore(model=FakeModel())
core.accepted("a")
core.handle_request(
"a", parse_request({"version": PROTOCOL_VERSION, "type": "hello", "requestId": "h"})
)
core.drain_events()
core.handle_request(
"a",
parse_request(
{
"version": PROTOCOL_VERSION,
"type": "start",
"requestId": "s",
"generationId": "g",
}
),
)
core.close()
self.assertEqual(executor.shutdown_args, (False, True))
def test_real_owned_executor_does_not_block_interpreter_exit(self) -> None:
program = """
import threading
import time
from pi_dictate_service.protocol import parse_request
from pi_dictate_service.server import DictateServiceCore
class StuckModel:
loaded = False
def load(self):
threading.Event().wait()
def transcribe(self, _samples):
return ''
core = DictateServiceCore(model=StuckModel())
core.accepted('a')
core.handle_request('a', parse_request({'version': 2, 'type': 'hello', 'requestId': 'h'}))
core.drain_events()
core.handle_request('a', parse_request({
'version': 2, 'type': 'start', 'requestId': 's', 'generationId': 'g'
}))
time.sleep(0.05)
core.close()
print('closed')
"""
result = subprocess.run(
[sys.executable, "-c", program],
text=True,
capture_output=True,
timeout=2,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "closed")
@dataclass
class _FakeSelectorKey:
data: str | None
class _FakeSelector:
def __init__(self, client: "_FakeClientSocket") -> None:
self._events = [
[(_FakeSelectorKey(None), selectors.EVENT_READ)],
[(_FakeSelectorKey("client-1"), selectors.EVENT_READ | selectors.EVENT_WRITE)],
]
self.client = client
self.unregistered: list[object] = []
self.closed = False
def register(self, fileobj: object, _events: int, _data: object = None) -> None:
return None
def unregister(self, fileobj: object) -> None:
self.unregistered.append(fileobj)
def modify(self, fileobj: object, _events: int, _data: object = None) -> None:
if fileobj is self.client and self.client.closed:
raise OSError(errno.EBADF, "bad file descriptor")
def select(self, timeout: float | None = None) -> list[tuple[_FakeSelectorKey, int]]:
return self._events.pop(0)
def close(self) -> None:
self.closed = True
class _FakeClientSocket:
def __init__(self) -> None:
self.closed = False
self.send_calls = 0
self._read = True
def setblocking(self, _value: bool) -> None:
return None
def recv(self, _size: int) -> bytes:
if self._read:
self._read = False
return b"{broken}\n"
return b""
def send(self, _data: bytearray) -> int:
self.send_calls += 1
raise OSError(errno.EBADF, "bad file descriptor")
def close(self) -> None:
self.closed = True
class _FakeListener:
def __init__(self, client: _FakeClientSocket) -> None:
self.client = client
self.closed = False
self._accepted = False
def setblocking(self, _value: bool) -> None:
return None
def accept(self) -> tuple[_FakeClientSocket, object]:
if self._accepted:
raise BlockingIOError
self._accepted = True
return self.client, None
def close(self) -> None:
self.closed = True
class _FakeLoopCore:
def __init__(self, outbound: Outbound) -> None:
self.outbound = outbound
self.accepted_client: str | None = None
self._outbound_sent = False
self._exit_checks = 0
def should_exit(self) -> bool:
self._exit_checks += 1
return self._exit_checks >= 3
def poll(self) -> None:
return None
def drain_events(self) -> list[Outbound]:
if self.accepted_client is not None and not self._outbound_sent:
self._outbound_sent = True
return [self.outbound]
return []
def accepted(self, client_id: str) -> None:
self.accepted_client = client_id
def disconnect(self, _client_id: str) -> None:
return None
def close(self) -> None:
return None
class _ShutdownRecordingExecutor:
def __init__(self) -> None:
self.shutdown_args: tuple[bool, bool] | None = None
def submit(self, _function: object, *_args: object) -> Future[object]:
return Future()
def shutdown(self, *, wait: bool = True, cancel_futures: bool = False) -> None:
self.shutdown_args = (wait, cancel_futures)
if wait:
raise AssertionError("shutdown waited for a hung worker")
class IdleTests(CoreTestCase):
def test_only_valid_requests_reset_idle_and_workers_suspend_then_restart_timer(self) -> None:
self.core.accepted("a")
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from pi_dictate_service.config import ConfigError
from pi_dictate_service.startup import validate_startup
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPOSITORY_ROOT / "services/dictate/install.sh"
class SystemdProfileTests(unittest.TestCase):
def run_install(self, home: Path, fake_bin: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
environment = {
**os.environ,
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PATH": f"{fake_bin}:{os.environ['PATH']}",
}
return subprocess.run(
[str(SCRIPT), *arguments],
env=environment,
capture_output=True,
text=True,
check=False,
)
def make_fake_bin(self, root: Path) -> Path:
fake_bin = root / "bin"
fake_bin.mkdir()
uv = fake_bin / "uv"
# Match uv sync's contract so transactional environment swapping is
# exercised without installing dependencies.
uv.write_text(
"#!/bin/sh\nmkdir -p \"$UV_PROJECT_ENVIRONMENT\"\nexit 0\n",
encoding="utf-8",
)
uv.chmod(0o755)
systemctl = fake_bin / "systemctl"
systemctl.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
systemctl.chmod(0o755)
return fake_bin
def test_generated_cloud_dropin_replaces_offline_network_restrictions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "openai", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
dropin = (
home / ".config/systemd/user/pi-dictate.service.d/"
"10-pi-dictate-network.conf"
).read_text(encoding="utf-8")
self.assertIn("Environment=PI_DICTATE_NETWORK_MODE=cloud", dropin)
self.assertIn(
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", dropin
)
self.assertIn("IPAddressDeny=", dropin)
self.assertNotIn("IPAddressDeny=any", dropin)
self.assertNotIn("HF_HUB_OFFLINE=1", dropin)
self.assertIn(
f'BindReadOnlyPaths="{home}/.config/pi-dictate/credentials/openai-api-key"',
dropin,
)
self.assertIn(
f'BindReadOnlyPaths="{home}/.local/share/pi-dictate/config/coding-terms.txt"',
dropin,
)
def test_check_rejects_tampered_network_dropin(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
dropin = (
home / ".config/systemd/user/pi-dictate.service.d/"
"10-pi-dictate-network.conf"
)
with dropin.open("a", encoding="utf-8") as stream:
stream.write("IPAddressAllow=any\\n")
result = self.run_install(home, fake_bin, "check")
self.assertNotEqual(result.returncode, 0)
self.assertIn("expected managed policy", result.stderr)
def test_startup_requires_the_exact_validation_command(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
environment = {
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PI_DICTATE_CONFIG_PATH": str(home / ".config/pi-dictate/config.json"),
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
home / ".local/share/pi-dictate/.dependency-profile"
),
"PI_DICTATE_NETWORK_MODE": "cloud",
}
with patch.dict(os.environ, environment, clear=False):
validate_startup()
service = home / ".config/systemd/user/pi-dictate.service"
text = service.read_text(encoding="utf-8")
service.write_text(
"\n".join(
"ExecStartPre=/bin/true" if line.startswith("ExecStartPre=") else line
for line in text.splitlines()
) + "\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ConfigError, "hardening"):
validate_startup()
def test_startup_rejects_a_world_writable_socket_unit(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
key = root / "key"
key.write_text("test-key", encoding="utf-8")
result = self.run_install(
home, fake_bin, "install", "--backend", "meta", "--api-key-file", str(key)
)
self.assertEqual(result.returncode, 0, result.stderr)
environment = {
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PI_DICTATE_CONFIG_PATH": str(home / ".config/pi-dictate/config.json"),
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
home / ".local/share/pi-dictate/.dependency-profile"
),
"PI_DICTATE_NETWORK_MODE": "cloud",
}
socket_unit = home / ".config/systemd/user/pi-dictate.socket"
socket_unit.write_text(
socket_unit.read_text(encoding="utf-8").replace(
"SocketMode=0600", "SocketMode=0666"
),
encoding="utf-8",
)
with patch.dict(os.environ, environment, clear=False):
with self.assertRaisesRegex(ConfigError, "socket systemd policy"):
validate_startup()
def test_local_dropin_binds_custom_model_and_dictionary_paths(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
source = root / "source.nemo"
source.write_bytes(b"model")
digest = hashlib.sha256(source.read_bytes()).hexdigest()
result = self.run_install(
home,
fake_bin,
"install",
"--backend",
"local",
"--model-source",
str(source),
"--model-sha256",
digest,
)
self.assertEqual(result.returncode, 0, result.stderr)
custom_model = home / "custom model.nemo"
custom_terms = home / "custom terms.txt"
custom_model.write_bytes(source.read_bytes())
custom_terms.write_text("JSON\n", encoding="utf-8")
config_path = home / ".config/pi-dictate/config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["local"]["modelPath"] = str(custom_model)
config["local"]["termsPath"] = str(custom_terms)
config_path.write_text(json.dumps(config), encoding="utf-8")
config_path.chmod(0o600)
result = self.run_install(home, fake_bin, "configure", "--backend", "local")
self.assertEqual(result.returncode, 0, result.stderr)
dropin = (home / ".config/systemd/user/pi-dictate.service.d/10-pi-dictate-network.conf").read_text(encoding="utf-8")
self.assertIn(f'BindReadOnlyPaths="{custom_model}"', dropin)
self.assertIn(f'BindReadOnlyPaths="{custom_terms}"', dropin)
environment = {
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PI_DICTATE_CONFIG_PATH": str(config_path),
"PI_DICTATE_DEPENDENCY_PROFILE_PATH": str(
home / ".local/share/pi-dictate/.dependency-profile"
),
"PI_DICTATE_NETWORK_MODE": "offline",
}
with patch.dict(os.environ, environment, clear=False):
validate_startup()
def test_local_dropin_and_profile_deny_are_restrictive(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
home = root / "home"
home.mkdir()
fake_bin = self.make_fake_bin(root)
model = root / "model"
model.write_bytes(b"model")
# Dry-run keeps this test independent of the pinned model bytes.
result = self.run_install(
home,
fake_bin,
"install",
"--backend",
"local",
"--model-source",
str(model),
"--model-sha256",
hashlib.sha256(model.read_bytes()).hexdigest(),
"--dry-run",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("offline network hardening", result.stdout)
profile = json.loads(
(REPOSITORY_ROOT / "profiles/nono/pi-local.json").read_text(
encoding="utf-8"
)
)
self.assertIn("$HOME/.local/share/pi-dictate", profile["filesystem"]["deny"])
# User decision: the profile is intentionally unchanged from HEAD.
self.assertNotIn("$HOME/.config/pi-dictate", profile["filesystem"]["deny"])
if __name__ == "__main__":
unittest.main()
+19 -1
View File
@@ -1,12 +1,21 @@
import hashlib
from pathlib import Path
import unittest
from pi_dictate_service.startup import EXPECTED_BASE_UNIT_SHA256
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
UNIT_DIR = REPOSITORY_ROOT / "systemd/user"
class SystemdUnitTests(unittest.TestCase):
def test_startup_hash_matches_the_checked_in_service_unit(self) -> None:
self.assertEqual(
hashlib.sha256((UNIT_DIR / "pi-dictate.service").read_bytes()).hexdigest(),
EXPECTED_BASE_UNIT_SHA256,
)
def test_socket_unit_owns_the_exact_private_runtime_path(self) -> None:
socket_unit = (UNIT_DIR / "pi-dictate.socket").read_text(encoding="utf-8")
self.assertIn("ListenStream=%t/pi-dictate.sock", socket_unit)
@@ -24,11 +33,20 @@ class SystemdUnitTests(unittest.TestCase):
"TRANSFORMERS_OFFLINE=1",
"TRITON_CACHE_DIR=/tmp/pi-dictate-triton",
"NUMBA_CACHE_DIR=/tmp/pi-dictate-numba",
"TimeoutStopSec=5s",
"LimitCORE=0",
"MemorySwapMax=0",
"ProtectSystem=strict",
"ProtectHome=read-only",
"ProtectHome=tmpfs",
"BindReadOnlyPaths=%h/.local/share/pi-dictate",
"BindReadOnlyPaths=%h/.local/share/uv/python",
"BindReadOnlyPaths=%h/.config/pi-dictate/config.json",
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.service",
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.service.d",
"BindReadOnlyPaths=%h/.config/systemd/user/pi-dictate.socket",
"BindReadOnlyPaths=%t/pipewire-0",
"RestrictAddressFamilies=AF_UNIX",
"SystemCallFilter=~bind listen",
"IPAddressDeny=any",
):
self.assertIn(expected, service_unit)
+12 -4
View File
@@ -173,6 +173,10 @@ class UnixLoopTests(unittest.TestCase):
class ActivationTests(unittest.TestCase):
def test_inherited_listener_requires_one_fd_for_this_process_and_wraps_fd_three(self) -> None:
listener = Mock()
listener.family = socket.AF_UNIX
listener.type = socket.SOCK_STREAM
listener.getsockopt.return_value = 1
listener.getsockname.return_value = "/run/user/1000/pi-dictate.sock"
with patch("pi_dictate_service.server.os.getpid", return_value=42), patch(
"pi_dictate_service.server.socket.socket", return_value=listener
) as socket_factory:
@@ -189,7 +193,9 @@ class ActivationTests(unittest.TestCase):
inherited_listener({"LISTEN_PID": "41", "LISTEN_FDS": "1"})
def test_main_requires_local_model_path_and_reads_idle_override(self) -> None:
with patch.dict(os.environ, {}, clear=True):
with patch.dict(os.environ, {}, clear=True), patch(
"pi_dictate_service.server.os.path.isfile", return_value=False
):
with self.assertRaisesRegex(SystemExit, "PI_DICTATE_MODEL_PATH"):
main()
@@ -201,10 +207,12 @@ class ActivationTests(unittest.TestCase):
"PI_DICTATE_MODEL_PATH": "/models/parakeet.nemo",
"PI_DICTATE_TERMS_PATH": "/config/coding-terms.txt",
"PI_DICTATE_BOOST_ALPHA": "0.5",
"PI_DICTATE_IDLE_SECONDS": "12.5",
"PI_DICTATE_IDLE_SECONDS": "120",
},
clear=True,
), patch("pi_dictate_service.server.NemoParakeetModel", return_value=model) as adapter, patch(
), patch("pi_dictate_service.server.os.path.isfile", return_value=False), patch(
"pi_dictate_service.server.NemoParakeetModel", return_value=model
) as adapter, patch(
"pi_dictate_service.server.DictateServiceCore"
) as core_factory, patch(
"pi_dictate_service.server.inherited_listener", return_value=listener
@@ -217,7 +225,7 @@ class ActivationTests(unittest.TestCase):
terms_path="/config/coding-terms.txt",
boost_alpha=0.5,
)
core_factory.assert_called_once_with(model=model, idle_seconds=12.5)
core_factory.assert_called_once_with(model=model, idle_seconds=120)
serve_loop.assert_called_once_with(listener, core)
+31 -4
View File
@@ -1328,7 +1328,12 @@ wheels = [
name = "pi-dictate-service"
version = "0.1.0"
source = { editable = "." }
dependencies = [
[package.optional-dependencies]
cloud = [
{ name = "websockets" },
]
local = [
{ name = "nemo-toolkit", extra = ["asr"] },
{ name = "numpy" },
{ name = "torch" },
@@ -1336,10 +1341,12 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "nemo-toolkit", extras = ["asr"], specifier = "==3.0.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "torch", specifier = "==2.13.0" },
{ name = "nemo-toolkit", extras = ["asr"], marker = "extra == 'local'", specifier = "==3.0.0" },
{ name = "numpy", marker = "extra == 'local'", specifier = "==2.5.2" },
{ name = "torch", marker = "extra == 'local'", specifier = "==2.13.0" },
{ name = "websockets", marker = "extra == 'cloud'", specifier = "==15.0.1" },
]
provides-extras = ["local", "cloud"]
[[package]]
name = "pillow"
@@ -2136,6 +2143,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/00/aca6beb3658dab4ed3dbb41a78e6e7f31342e0b41d28088f205525751601/webdataset-1.0.2-py3-none-any.whl", hash = "sha256:3dbfced32b25c0d199c6b9787937b6f85742bc3c84f652c846893075c1c082d9", size = 74956, upload-time = "2025-06-19T23:26:20.354Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"