feat(dictate): use local socket-activated Parakeet

This commit is contained in:
jb
2026-08-26 12:38:45 -04:00
parent ea74dbc6a7
commit e234468a73
34 changed files with 6702 additions and 527 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv/
build/
src/*.egg-info/
__pycache__/
*.py[cod]
+1
View File
@@ -0,0 +1 @@
3.12.13
+37
View File
@@ -0,0 +1,37 @@
API
async
await
Bun
CLI
CUDA
Git
git commit
git status
GitHub
GitLab
Ghostty
JSON
JSON response
NeMo
niri
Node.js
nono
npm
npm install
npm test
Parakeet
Pi
PipeWire
Python
PyTorch
stderr
stdout
Svelte
SvelteKit
systemd
TUI
TypeScript
Unix socket
Vite
Wayland
WebSocket
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL_REVISION="541d1f99c6b0c3cd0b11a95167540bb8edefd82b"
MODEL_SHA256="3cbdc85877e668ca7b82d0d56770eb1fac76691f55d6b97545e8d61ca588d10d"
MODEL_FILENAME="parakeet-tdt-0.6b-v3.nemo"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPOSITORY_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
INSTALL_ROOT="$HOME/.local/share/pi-dictate"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
MODEL_DIR="$INSTALL_ROOT/models/parakeet-tdt-0.6b-v3"
MODEL_PATH="$MODEL_DIR/$MODEL_FILENAME"
DICTIONARY_SOURCE="$SCRIPT_DIR/coding-terms.txt"
DICTIONARY_PATH="$INSTALL_ROOT/config/coding-terms.txt"
DRY_RUN=false
MODEL_SOURCE=""
ACTION=install
usage() {
cat <<'EOF'
Usage: install.sh [--dry-run] [--model-source PATH]
install.sh uninstall [--dry-run]
Installs the pinned service environment, model snapshot, and systemd user units.
Without --model-source, installation downloads the exact pinned model revision.
EOF
}
run() {
printf '+ '
printf '%q ' "$@"
printf '\n'
if ! $DRY_RUN; then
"$@"
fi
}
is_owned_unit() {
local destination="$1"
local source="$2"
[[ -L "$destination" && "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]] ||
{ [[ -f "$destination" ]] && grep -qxF '# Managed by pi-dictate install.sh' "$destination"; }
}
install_owned_unit() {
local source="$1"
local destination="$2"
if [[ -L "$destination" && "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]]; then
run rm "$destination"
elif [[ -e "$destination" || -L "$destination" ]]; then
if [[ ! -f "$destination" ]] || ! grep -qxF '# Managed by pi-dictate install.sh' "$destination"; then
printf 'Refusing to replace unmanaged systemd unit: %s\n' "$destination" >&2
return 1
fi
fi
run install -m 0644 "$source" "$destination"
}
remove_owned_unit() {
local destination="$1"
local source="$2"
if [[ -L "$destination" && "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]]; then
run rm "$destination"
elif [[ -f "$destination" ]] && grep -qxF '# Managed by pi-dictate install.sh' "$destination"; then
run rm "$destination"
elif [[ -e "$destination" || -L "$destination" ]]; then
printf 'Leaving unmanaged unit in place: %s\n' "$destination" >&2
fi
}
while (($#)); do
case "$1" in
uninstall) ACTION=uninstall ;;
--dry-run) DRY_RUN=true ;;
--model-source)
shift
(($#)) || { printf '%s\n' '--model-source requires a path' >&2; exit 2; }
MODEL_SOURCE="$1"
;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
shift
done
socket_source="$REPOSITORY_ROOT/systemd/user/pi-dictate.socket"
service_source="$REPOSITORY_ROOT/systemd/user/pi-dictate.service"
socket_link="$UNIT_DIR/pi-dictate.socket"
service_link="$UNIT_DIR/pi-dictate.service"
if [[ "$ACTION" == uninstall ]]; then
for pair in "$socket_link:$socket_source" "$service_link:$service_source"; do
destination="${pair%%:*}"
source="${pair#*:}"
if [[ ( -e "$destination" || -L "$destination" ) ]] && ! is_owned_unit "$destination" "$source"; then
printf 'Refusing to disable or remove unmanaged systemd unit: %s\n' "$destination" >&2
exit 1
fi
done
run systemctl --user disable --now pi-dictate.socket
remove_owned_unit "$socket_link" "$socket_source"
remove_owned_unit "$service_link" "$service_source"
run systemctl --user daemon-reload
run rm -rf "$INSTALL_ROOT"
exit 0
fi
command -v uv >/dev/null || { printf '%s\n' 'uv is required' >&2; exit 1; }
command -v systemctl >/dev/null || { printf '%s\n' 'systemctl is required' >&2; exit 1; }
run mkdir -p "$INSTALL_ROOT" "$MODEL_DIR" "$(dirname "$DICTIONARY_PATH")" "$UNIT_DIR"
run chmod 0700 "$INSTALL_ROOT" "$(dirname "$MODEL_DIR")" "$MODEL_DIR" "$(dirname "$DICTIONARY_PATH")"
if $DRY_RUN; then
printf '+ UV_PROJECT_ENVIRONMENT=%q uv sync --frozen --no-dev --no-editable --project %q\n' "$INSTALL_ROOT/venv" "$SCRIPT_DIR"
else
UV_PROJECT_ENVIRONMENT="$INSTALL_ROOT/venv" uv sync --frozen --no-dev --no-editable --project "$SCRIPT_DIR"
fi
MODEL_TEMP="$MODEL_DIR/.${MODEL_FILENAME}.install.$$"
cleanup_model_temp() {
if ! $DRY_RUN; then
rm -f -- "$MODEL_TEMP"
fi
}
trap cleanup_model_temp EXIT
if [[ -n "$MODEL_SOURCE" ]]; then
[[ -f "$MODEL_SOURCE" ]] || { printf 'Model source not found: %s\n' "$MODEL_SOURCE" >&2; exit 1; }
run cp --reflink=auto "$MODEL_SOURCE" "$MODEL_TEMP"
else
if $DRY_RUN; then
printf '+ download nvidia/parakeet-tdt-0.6b-v3@%s to %q\n' "$MODEL_REVISION" "$MODEL_TEMP"
else
MODEL_REVISION="$MODEL_REVISION" MODEL_PATH="$MODEL_TEMP" \
"$INSTALL_ROOT/venv/bin/python" - <<'PY'
import os
import shutil
from pathlib import Path
from huggingface_hub import hf_hub_download
source = hf_hub_download(
repo_id="nvidia/parakeet-tdt-0.6b-v3",
filename="parakeet-tdt-0.6b-v3.nemo",
revision=os.environ["MODEL_REVISION"],
)
destination = Path(os.environ["MODEL_PATH"])
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
PY
fi
fi
if ! $DRY_RUN; then
actual_sha256="$(sha256sum "$MODEL_TEMP" | cut -d' ' -f1)"
if [[ "$actual_sha256" != "$MODEL_SHA256" ]]; then
rm -f "$MODEL_TEMP"
printf 'Model checksum mismatch: expected %s, got %s\n' "$MODEL_SHA256" "$actual_sha256" >&2
exit 1
fi
fi
run chmod 0400 "$MODEL_TEMP"
run mv -f "$MODEL_TEMP" "$MODEL_PATH"
trap - EXIT
if [[ ! -e "$DICTIONARY_PATH" ]]; then
run cp "$DICTIONARY_SOURCE" "$DICTIONARY_PATH"
run chmod 0600 "$DICTIONARY_PATH"
else
printf 'Keeping existing coding dictionary: %s\n' "$DICTIONARY_PATH"
fi
install_owned_unit "$socket_source" "$socket_link"
install_owned_unit "$service_source" "$service_link"
run systemctl --user daemon-reload
run systemctl --user enable --now pi-dictate.socket
if $DRY_RUN; then
printf 'Would install pi-dictate with model revision %s.\n' "$MODEL_REVISION"
else
printf 'Installed pi-dictate with model revision %s.\n' "$MODEL_REVISION"
printf 'Check it with: systemctl --user status pi-dictate.socket pi-dictate.service\n'
fi
+23
View File
@@ -0,0 +1,23 @@
[project]
name = "pi-dictate-service"
version = "0.1.0"
description = "Socket-activated local transcription service for the Pi dictate extension"
requires-python = ">=3.12,<3.13"
dependencies = [
"nemo-toolkit[asr]==3.0.0",
"numpy==2.5.2",
"torch==2.13.0",
]
[project.scripts]
pi-dictate-service = "pi_dictate_service.server:main"
[build-system]
requires = ["setuptools==84.0.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.uv]
package = true
@@ -0,0 +1,5 @@
"""GPU-isolated local dictate service."""
from .protocol import PROTOCOL_VERSION
__all__ = ["PROTOCOL_VERSION"]
@@ -0,0 +1,22 @@
"""In-memory PCM validation and conversion."""
from __future__ import annotations
from array import array
import struct
MAX_PCM_BYTES = 5 * 60 * 16_000 * 2
class AudioError(ValueError):
pass
def pcm_s16le_to_floats(pcm: bytes, *, max_bytes: int = MAX_PCM_BYTES) -> array[float]:
"""Convert mono little-endian signed-16 PCM to a bounded float32 array."""
if len(pcm) > max_bytes:
raise AudioError("PCM exceeds the five-minute duration limit")
if len(pcm) % 2:
raise AudioError("PCM byte length must be even")
return array("f", (sample[0] / 32768.0 for sample in struct.iter_unpack("<h", pcm)))
@@ -0,0 +1,138 @@
"""Lazy, offline NeMo adapter for an explicit local Parakeet archive."""
from __future__ import annotations
from copy import deepcopy
import hashlib
import os
from pathlib import Path
from typing import Protocol, Sequence
MAX_TERMS_FILE_BYTES = 64 * 1024
MAX_CODING_TERMS = 512
MAX_TERM_BYTES = 128
PINNED_MODEL_SHA256 = "3cbdc85877e668ca7b82d0d56770eb1fac76691f55d6b97545e8d61ca588d10d"
class TranscriptionModel(Protocol):
def load(self) -> None: ...
def transcribe(self, samples: Sequence[float]) -> str: ...
def load_coding_terms(path: str | os.PathLike[str]) -> list[str]:
"""Load a bounded host-owned phrase list, ignoring comments and duplicates."""
terms_path = Path(path).expanduser()
if not terms_path.is_file():
raise ValueError("PI_DICTATE_TERMS_PATH must name an existing local dictionary")
if terms_path.stat().st_size > MAX_TERMS_FILE_BYTES:
raise ValueError(f"coding dictionary exceeds {MAX_TERMS_FILE_BYTES} bytes")
terms: list[str] = []
seen: set[str] = set()
for raw_line in terms_path.read_text(encoding="utf-8").splitlines():
term = raw_line.strip()
if not term or term.startswith("#"):
continue
if len(term.encode("utf-8")) > MAX_TERM_BYTES:
raise ValueError(f"coding dictionary terms may not exceed {MAX_TERM_BYTES} UTF-8 bytes")
if term not in seen:
terms.append(term)
seen.add(term)
if len(terms) > MAX_CODING_TERMS:
raise ValueError(f"coding dictionary may not exceed {MAX_CODING_TERMS} terms")
return terms
class NemoParakeetModel:
"""Load NeMo only when requested so core tests require neither CUDA nor NeMo."""
def __init__(
self,
model_path: str | os.PathLike[str],
*,
terms_path: str | os.PathLike[str] | None = None,
boost_alpha: float = 1.0,
expected_sha256: str = PINNED_MODEL_SHA256,
) -> None:
path = Path(model_path).expanduser()
if path.suffix != ".nemo" or not path.is_file():
raise ValueError("PI_DICTATE_MODEL_PATH must name an existing local .nemo file")
if boost_alpha <= 0:
raise ValueError("PI_DICTATE_BOOST_ALPHA must be positive")
self._model_path = path.resolve()
self._terms = load_coding_terms(terms_path) if terms_path is not None else []
self._boost_alpha = boost_alpha
self._expected_sha256 = expected_sha256
self._model: object | None = None
self._numpy: object | None = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
with self._model_path.open("rb") as model_file:
actual_sha256 = hashlib.file_digest(model_file, "sha256").hexdigest()
if actual_sha256 != self._expected_sha256:
raise ValueError(
"model checksum mismatch: expected the pinned Parakeet revision"
)
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
os.environ["HF_DATASETS_OFFLINE"] = "1"
os.environ["NEMO_OFFLINE"] = "1"
import numpy
from nemo.collections.asr.models import ASRModel
from omegaconf import open_dict
model = ASRModel.restore_from(
restore_path=str(self._model_path),
map_location="cuda",
)
model.to("cuda")
model.eval()
if self._terms:
decoding = deepcopy(model.cfg.decoding)
with open_dict(decoding):
greedy = decoding["greedy"]
greedy["boosting_tree"] = {
"key_phrases_list": list(self._terms),
"context_score": 1.0,
"depth_scaling": 2.0,
"unk_score": 0.0,
"final_eos_score": 1.0,
"score_per_phrase": 0.0,
"source_lang": "en",
"use_triton": True,
"uniform_weights": False,
"use_bpe_dropout": False,
}
greedy["boosting_tree_alpha"] = self._boost_alpha
model.change_decoding_strategy(decoding, verbose=False)
self._numpy = numpy
self._model = model
def transcribe(self, samples: Sequence[float]) -> str:
if self._model is None or self._numpy is None:
raise RuntimeError("model is not loaded")
numpy = self._numpy
audio = numpy.asarray(samples, dtype=numpy.float32) # type: ignore[attr-defined]
output: Sequence[object] = self._model.transcribe( # type: ignore[attr-defined]
audio=[audio],
batch_size=1,
num_workers=0,
verbose=False,
)
if not output:
return ""
hypothesis = output[0]
if isinstance(hypothesis, str):
return hypothesis
text = getattr(hypothesis, "text", None)
if isinstance(text, str):
return text
raise RuntimeError("NeMo returned an unsupported transcription result")
@@ -0,0 +1,195 @@
"""Bounded newline-delimited JSON protocol primitives for dictate protocol v1."""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any, Mapping
PROTOCOL_VERSION = 1
MAX_FRAME_BYTES = 64 * 1024
MAX_TRANSCRIPT_BYTES = 32 * 1024
MAX_ERROR_DETAIL_BYTES = 2 * 1024
MAX_ID_BYTES = 128
class ProtocolError(ValueError):
"""A stable protocol error suitable for returning to a client."""
def __init__(
self,
code: str,
detail: str,
*,
close_connection: bool = False,
request_id: str = "",
generation_id: str | None = None,
) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
self.close_connection = close_connection
self.request_id = request_id
self.generation_id = generation_id
@dataclass(frozen=True)
class Request:
version: int
type: str
request_id: str
generation_id: str | None = None
_REQUEST_FIELDS = {
"hello": frozenset({"version", "type", "requestId"}),
"ping": frozenset({"version", "type", "requestId"}),
"start": frozenset({"version", "type", "requestId", "generationId"}),
"stop": frozenset({"version", "type", "requestId", "generationId"}),
"cancel": frozenset({"version", "type", "requestId", "generationId"}),
}
def _bounded_utf8(value: str, limit: int) -> str:
raw = value.encode("utf-8", errors="replace")
if len(raw) <= limit:
return value
return raw[:limit].decode("utf-8", errors="ignore")
def _validate_id(value: Any, field: str) -> str:
if not isinstance(value, str) or not value:
raise ProtocolError("invalid_request", f"{field} must be a non-empty string")
try:
encoded = value.encode("utf-8")
except UnicodeEncodeError as error:
raise ProtocolError("invalid_request", f"{field} must contain valid Unicode") from error
if len(encoded) > MAX_ID_BYTES:
raise ProtocolError("invalid_request", f"{field} exceeds {MAX_ID_BYTES} UTF-8 bytes")
return value
def _safe_id(value: Any) -> str:
try:
return _validate_id(value, "identifier")
except ProtocolError:
return ""
def parse_request(frame: Mapping[str, Any]) -> Request:
"""Validate a decoded request and reject all fields outside its exact schema."""
if not isinstance(frame, Mapping):
raise ProtocolError("invalid_request", "request must be a JSON object")
request_id_value = frame.get("requestId")
request_id = _safe_id(request_id_value)
version = frame.get("version")
if not isinstance(version, int) or isinstance(version, bool):
raise ProtocolError("invalid_request", "version must be an integer", request_id=request_id)
if version != PROTOCOL_VERSION:
raise ProtocolError(
"unsupported_version",
f"protocol version {version} is unsupported",
close_connection=True,
request_id=request_id,
)
request_type = frame.get("type")
if not isinstance(request_type, str) or request_type not in _REQUEST_FIELDS:
raise ProtocolError("invalid_request", "unknown request type", request_id=request_id)
expected_fields = _REQUEST_FIELDS[request_type]
if frozenset(frame) != expected_fields:
raise ProtocolError(
"invalid_request",
f"{request_type} requires exactly: {', '.join(sorted(expected_fields))}",
request_id=request_id,
)
request_id = _validate_id(request_id_value, "requestId")
generation_id = None
if "generationId" in expected_fields:
generation_id = _validate_id(frame.get("generationId"), "generationId")
return Request(version, request_type, request_id, generation_id)
class FrameDecoder:
"""Incrementally decode bounded newline-delimited JSON objects."""
def __init__(self) -> None:
self._buffer = bytearray()
self._failed = False
def feed(self, data: bytes) -> list[dict[str, Any]]:
if self._failed:
raise ProtocolError("connection_closed", "decoder is closed", close_connection=True)
self._buffer.extend(data)
frames: list[dict[str, Any]] = []
while True:
newline = self._buffer.find(b"\n")
if newline < 0:
if len(self._buffer) > MAX_FRAME_BYTES:
self._fail("frame_too_large", f"frame exceeds {MAX_FRAME_BYTES} bytes")
return frames
if newline > MAX_FRAME_BYTES:
self._fail("frame_too_large", f"frame exceeds {MAX_FRAME_BYTES} bytes")
raw = bytes(self._buffer[:newline])
del self._buffer[: newline + 1]
try:
value = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
self._fail("malformed_json", "frame contains malformed JSON")
if not isinstance(value, dict):
self._fail("malformed_json", "frame must contain a JSON object")
frames.append(value)
def _fail(self, code: str, detail: str) -> None:
self._failed = True
self._buffer.clear()
raise ProtocolError(code, detail, close_connection=True)
def encode_frame(frame: Mapping[str, Any]) -> bytes:
"""Encode one bounded service frame, applying the error-detail output cap."""
payload = dict(frame)
if payload.get("type") == "result":
transcript = payload.get("transcript")
if not isinstance(transcript, str):
raise ProtocolError("invalid_event", "result transcript must be a string")
if len(transcript.encode("utf-8")) > MAX_TRANSCRIPT_BYTES:
raise ProtocolError("transcript_too_large", "transcript exceeds output limit")
if payload.get("type") == "error":
detail = payload.get("detail", "")
if not isinstance(detail, str):
detail = str(detail)
payload["detail"] = _bounded_utf8(detail, MAX_ERROR_DETAIL_BYTES)
payload["requestId"] = _safe_id(payload.get("requestId"))
generation_id = _safe_id(payload.get("generationId"))
if generation_id:
payload["generationId"] = generation_id
else:
payload.pop("generationId", None)
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_FRAME_BYTES:
raise ProtocolError("frame_too_large", "encoded frame exceeds output limit")
return encoded + b"\n"
def error_event(error: ProtocolError) -> dict[str, Any]:
event: dict[str, Any] = {
"version": PROTOCOL_VERSION,
"type": "error",
"requestId": _safe_id(error.request_id),
"code": error.code,
"detail": _bounded_utf8(error.detail, MAX_ERROR_DETAIL_BYTES),
}
generation_id = _safe_id(error.generation_id)
if generation_id:
event["generationId"] = generation_id
return event
@@ -0,0 +1,297 @@
"""PipeWire recorder adapter with bounded in-memory output."""
from __future__ import annotations
from dataclasses import dataclass
import os
import signal
import subprocess
import threading
import time
from typing import BinaryIO, Callable, Protocol
from .audio import MAX_PCM_BYTES
PW_RECORD_COMMAND = [
"pw-record",
"--raw",
"--format",
"s16",
"--rate",
"16000",
"--channels",
"1",
"-",
]
MAX_STDERR_BYTES = 64 * 1024
class Process(Protocol):
pid: int
stdout: BinaryIO
stderr: BinaryIO
returncode: int | None
def poll(self) -> int | None: ...
def terminate(self) -> None: ...
def kill(self) -> None: ...
def wait(self, timeout: float | None = None) -> int: ...
ProcessFactory = Callable[..., Process]
ProcessGroupSignal = Callable[[int, int], None]
@dataclass(frozen=True)
class Capture:
pcm: bytes
returncode: int
signal: int | None
stderr_tail: bytes
class RecorderError(RuntimeError):
def __init__(
self,
code: str,
detail: str,
*,
returncode: int | None = None,
signal_number: int | None = None,
) -> None:
super().__init__(detail)
self.code = code
self.detail = detail
self.returncode = returncode
self.signal_number = signal_number
class PwRecorder:
"""Capture raw PCM while continuously draining both child pipes."""
def __init__(
self,
*,
process_factory: ProcessFactory = subprocess.Popen,
max_bytes: int = MAX_PCM_BYTES,
stderr_limit: int = MAX_STDERR_BYTES,
stop_timeout: float = 3.0,
clock: Callable[[], float] = time.monotonic,
process_group_signal: ProcessGroupSignal = os.killpg,
) -> None:
self._process_factory = process_factory
self._max_bytes = max_bytes
self._stderr_limit = stderr_limit
self._stop_timeout = stop_timeout
self._clock = clock
self._process_group_signal = process_group_signal
self._process: Process | None = None
self._stdout_thread: threading.Thread | None = None
self._stderr_thread: threading.Thread | None = None
self._pcm = bytearray()
self._stderr = bytearray()
self._lock = threading.Lock()
self._intentional = False
self._duration_limited = False
self._duration_stop_at: float | None = None
self._reported = False
@property
def buffered_bytes(self) -> int:
with self._lock:
return len(self._pcm)
def start(self) -> None:
if self._process is not None:
raise RecorderError("invalid_state", "recorder is already started")
try:
process = self._process_factory(
list(PW_RECORD_COMMAND),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
except OSError as error:
raise RecorderError("recorder_spawn", f"could not start pw-record: {error}") from error
if process.stdout is None or process.stderr is None:
raise RecorderError("recorder_spawn", "pw-record pipes were not created")
self._process = process
self._stdout_thread = threading.Thread(target=self._drain_stdout, daemon=True)
self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
self._stdout_thread.start()
self._stderr_thread.start()
def stop(self) -> Capture:
return self._finish(discard=False)
def cancel(self) -> None:
try:
self._finish(discard=True)
finally:
with self._lock:
self._pcm.clear()
def poll_unexpected(self) -> RecorderError | None:
process = self._require_process()
returncode = process.poll()
if returncode is None:
with self._lock:
duration_stop_at = self._duration_stop_at
if (
not self._intentional
and not self._reported
and duration_stop_at is not None
and self._clock() - duration_stop_at >= self._stop_timeout
):
self._reported = True
self._signal_group(process, signal.SIGKILL)
process.wait(timeout=self._stop_timeout)
self._join_drainers()
with self._lock:
self._pcm.clear()
return RecorderError(
"duration_limit",
"recording exceeded the five-minute duration limit; pw-record was killed after ignoring SIGTERM",
returncode=process.returncode,
signal_number=signal.SIGKILL,
)
return None
if self._intentional or self._reported:
return None
self._reported = True
self._join_drainers()
with self._lock:
self._pcm.clear()
if self._duration_limited:
return RecorderError(
"duration_limit",
"recording exceeded the five-minute duration limit",
returncode=returncode,
signal_number=-returncode if returncode < 0 else None,
)
return self._exit_error(returncode, "pw-record exited unexpectedly")
def _finish(self, *, discard: bool) -> Capture:
process = self._require_process()
self._intentional = True
terminated_running_process = process.poll() is None
if terminated_running_process:
self._signal_group(process, signal.SIGTERM)
try:
returncode = process.wait(timeout=self._stop_timeout)
except subprocess.TimeoutExpired as error:
self._signal_group(process, signal.SIGKILL)
process.wait(timeout=self._stop_timeout)
self._join_drainers()
with self._lock:
self._pcm.clear()
raise RecorderError(
"recorder_timeout",
"pw-record did not stop after SIGTERM and was killed",
returncode=process.returncode,
signal_number=signal.SIGKILL,
) from error
self._join_drainers()
stderr_tail = self._stderr_bytes()
if self._duration_limited:
with self._lock:
self._pcm.clear()
raise RecorderError(
"duration_limit",
"recording exceeded the five-minute duration limit",
returncode=returncode,
signal_number=-returncode if returncode < 0 else None,
)
clean_pipewire_sigterm = (
terminated_running_process and returncode == 1 and not stderr_tail
)
if returncode not in (0, -signal.SIGTERM) and not clean_pipewire_sigterm:
with self._lock:
self._pcm.clear()
raise self._exit_error(returncode, "pw-record failed while stopping")
with self._lock:
pcm = b"" if discard else bytes(self._pcm)
self._pcm.clear()
return Capture(
pcm=pcm,
returncode=returncode,
signal=-returncode if returncode < 0 else None,
stderr_tail=stderr_tail,
)
def _drain_stdout(self) -> None:
process = self._require_process()
while True:
chunk = process.stdout.read(64 * 1024)
if not chunk:
return
with self._lock:
if self._duration_limited:
continue
if len(self._pcm) + len(chunk) > self._max_bytes:
self._duration_limited = True
self._duration_stop_at = self._clock()
self._pcm.clear()
else:
self._pcm.extend(chunk)
continue
if process.poll() is None:
self._signal_group(process, signal.SIGTERM)
def _drain_stderr(self) -> None:
process = self._require_process()
while True:
chunk = process.stderr.read(16 * 1024)
if not chunk:
return
with self._lock:
self._stderr.extend(chunk)
overflow = len(self._stderr) - self._stderr_limit
if overflow > 0:
del self._stderr[:overflow]
def _join_drainers(self) -> None:
for thread in (self._stdout_thread, self._stderr_thread):
if thread is None:
continue
thread.join(timeout=self._stop_timeout)
if thread.is_alive():
raise RecorderError(
"recorder_timeout",
"pw-record pipe did not close after the process group stopped",
)
def _signal_group(self, process: Process, signal_number: int) -> None:
try:
self._process_group_signal(process.pid, signal_number)
except ProcessLookupError:
pass
def _stderr_bytes(self) -> bytes:
with self._lock:
return bytes(self._stderr)
def _exit_error(self, returncode: int, prefix: str) -> RecorderError:
if returncode < 0:
classification = f"signal {-returncode}"
signal_number = -returncode
else:
classification = f"code {returncode}"
signal_number = None
stderr = self._stderr_bytes().decode("utf-8", errors="replace").strip()
detail = f"{prefix} with {classification}"
if stderr:
detail = f"{detail}: {stderr}"
return RecorderError(
"recorder_exit",
detail,
returncode=returncode,
signal_number=signal_number,
)
def _require_process(self) -> Process:
if self._process is None:
raise RecorderError("invalid_state", "recorder has not started")
return self._process
@@ -0,0 +1,570 @@
"""Dictate service state machine and Unix-domain socket loop."""
from __future__ import annotations
from collections.abc import Sequence
from concurrent.futures import Executor, Future, ThreadPoolExecutor
from dataclasses import dataclass
import os
from queue import Empty, SimpleQueue
import selectors
import socket
import time
from typing import Callable, Protocol
from .audio import AudioError, pcm_s16le_to_floats
from .model import NemoParakeetModel, TranscriptionModel
from .protocol import (
MAX_FRAME_BYTES,
MAX_TRANSCRIPT_BYTES,
PROTOCOL_VERSION,
FrameDecoder,
ProtocolError,
Request,
encode_frame,
error_event,
parse_request,
)
from .recorder import Capture, PwRecorder, RecorderError
DEFAULT_IDLE_SECONDS = 15 * 60
MAX_CLIENT_CONNECTIONS = 16
MAX_CONNECTION_OUTPUT_BYTES = MAX_FRAME_BYTES
class Recorder(Protocol):
def start(self) -> None: ...
def stop(self) -> Capture: ...
def cancel(self) -> None: ...
def poll_unexpected(self) -> RecorderError | None: ...
@dataclass(frozen=True)
class Outbound:
client_id: str
frame: dict[str, object]
@dataclass
class _Generation:
owner: str
generation_id: str
request_id: str
state: str
recorder: Recorder | None = None
tombstoned: bool = False
@dataclass(frozen=True)
class _Completion:
kind: str
generation_id: str
future: Future[object]
class DictateServiceCore:
"""Single-generation protocol state machine with injected host boundaries."""
def __init__(
self,
*,
model: TranscriptionModel,
recorder_factory: Callable[[], Recorder] = PwRecorder,
executor: Executor | None = None,
clock: Callable[[], float] = time.monotonic,
idle_seconds: float = DEFAULT_IDLE_SECONDS,
) -> None:
self._model = model
self._recorder_factory = recorder_factory
self._executor = executor or ThreadPoolExecutor(max_workers=1, thread_name_prefix="dictate")
self._owns_executor = executor is None
self._clock = clock
self._idle_seconds = idle_seconds
self._last_activity = clock()
self._clients: dict[str, bool] = {}
self._active: _Generation | None = None
self._model_loaded = bool(getattr(model, "loaded", False))
self._worker_kind: str | None = None
self._completions: SimpleQueue[_Completion] = SimpleQueue()
self._events: list[Outbound] = []
@property
def active_generation_id(self) -> str | None:
return self._active.generation_id if self._active else None
def accepted(self, client_id: str) -> None:
"""Track a socket without treating acceptance as protocol activity."""
self._clients.setdefault(client_id, False)
def handle_request(self, client_id: str, request: Request) -> None:
"""Handle one already parsed request and queue its observable events."""
self._last_activity = self._clock()
self._clients.setdefault(client_id, False)
if request.type == "hello":
self._clients[client_id] = True
self._emit(
client_id,
{
"version": PROTOCOL_VERSION,
"type": "pong",
"requestId": request.request_id,
"service": "pi-dictate",
"ready": self._model_loaded,
},
)
return
if not self._clients[client_id]:
self._request_error(client_id, request, "hello_required", "hello is required first")
return
if request.type == "ping":
self._emit(
client_id,
{
"version": PROTOCOL_VERSION,
"type": "pong",
"requestId": request.request_id,
"service": "pi-dictate",
"ready": self._model_loaded,
"busy": self._active is not None,
},
)
elif request.type == "start":
self._start(client_id, request)
elif request.type == "stop":
self._stop(client_id, request)
elif request.type == "cancel":
self._cancel(client_id, request)
def poll(self) -> None:
"""Apply completed workers and recorder exits without blocking the socket loop."""
while True:
try:
completion = self._completions.get_nowait()
except Empty:
break
self._apply_completion(completion)
active = self._active
if active and active.state == "listening" and active.recorder and self._worker_kind is None:
try:
failure = active.recorder.poll_unexpected()
except RecorderError as error:
failure = error
if failure is not None:
self._async_error(active, failure.code, failure.detail)
self._finish_generation(active, notify_idle=True)
def drain_events(self) -> list[Outbound]:
events, self._events = self._events, []
return events
def disconnect(self, client_id: str) -> None:
self._clients.pop(client_id, None)
active = self._active
if active is None or active.owner != client_id:
return
active.tombstoned = True
if active.state == "listening" and active.recorder is not None:
active.state = "stopping"
self._submit("cancel", active, active.recorder.cancel)
def should_exit(self) -> bool:
if self._active is not None or self._worker_kind is not None:
return False
return self._clock() - self._last_activity >= self._idle_seconds
def close(self) -> None:
active = self._active
if active and active.recorder and active.state == "listening":
try:
active.recorder.cancel()
except RecorderError:
pass
self._active = None
if self._owns_executor:
self._executor.shutdown(wait=True, cancel_futures=False)
def _start(self, client_id: str, request: Request) -> None:
assert request.generation_id is not None
if self._active is not None or self._worker_kind is not None:
self._request_error(client_id, request, "busy", "another generation is active")
return
active = _Generation(client_id, request.generation_id, request.request_id, "loading")
self._active = active
if not self._model_loaded:
self._state(active, "loading")
self._submit("load", active, self._model.load)
return
self._begin_recording(active)
def _begin_recording(self, active: _Generation) -> None:
if active.tombstoned:
self._finish_generation(active, notify_idle=False)
return
try:
recorder = self._recorder_factory()
recorder.start()
except RecorderError as error:
self._async_error(active, error.code, error.detail)
self._finish_generation(active, notify_idle=True)
return
except OSError as error:
self._async_error(active, "recorder_spawn", str(error))
self._finish_generation(active, notify_idle=True)
return
active.recorder = recorder
self._state(active, "listening")
def _stop(self, client_id: str, request: Request) -> None:
active = self._owned_generation(client_id, request)
if active is None:
return
if active.state != "listening" or active.recorder is None:
self._request_error(client_id, request, "invalid_state", f"cannot stop while {active.state}")
return
active.request_id = request.request_id
try:
failure = active.recorder.poll_unexpected()
except RecorderError as error:
failure = error
if failure is not None:
self._async_error(active, failure.code, failure.detail)
self._finish_generation(active, notify_idle=True)
return
active.state = "stopping"
self._state(active, "stopping", assign=False)
self._submit("stop", active, self._stop_and_convert, active.recorder)
def _cancel(self, client_id: str, request: Request) -> None:
active = self._owned_generation(client_id, request)
if active is None:
return
if active.tombstoned:
self._request_error(client_id, request, "invalid_state", "generation is already cancelled")
return
active.tombstoned = True
active.request_id = request.request_id
self._emit(
client_id,
{
"version": PROTOCOL_VERSION,
"type": "cancelled",
"requestId": active.request_id,
"generationId": active.generation_id,
},
)
if active.state == "listening" and active.recorder is not None:
active.state = "stopping"
self._submit("cancel", active, active.recorder.cancel)
def _owned_generation(self, client_id: str, request: Request) -> _Generation | None:
assert request.generation_id is not None
active = self._active
if active is None:
self._request_error(client_id, request, "invalid_state", "no generation is active")
return None
if active.owner != client_id:
self._request_error(client_id, request, "not_owner", "generation belongs to another client")
return None
if active.generation_id != request.generation_id:
self._request_error(
client_id,
request,
"generation_mismatch",
"generation ID does not match the active generation",
)
return None
return active
@staticmethod
def _stop_and_convert(recorder: Recorder) -> Sequence[float]:
capture = recorder.stop()
return pcm_s16le_to_floats(capture.pcm)
def _submit(
self,
kind: str,
active: _Generation,
function: Callable[..., object],
*args: object,
) -> None:
self._worker_kind = kind
future = self._executor.submit(function, *args)
future.add_done_callback(
lambda completed, kind=kind, generation_id=active.generation_id: self._completions.put(
_Completion(kind, generation_id, completed)
)
)
def _apply_completion(self, completion: _Completion) -> None:
if self._worker_kind == completion.kind:
self._worker_kind = None
active = self._active
if active is None or active.generation_id != completion.generation_id:
return
try:
result = completion.future.result()
except RecorderError as error:
if not active.tombstoned:
self._async_error(active, error.code, error.detail)
self._finish_generation(active, notify_idle=not active.tombstoned)
return
except AudioError as error:
if not active.tombstoned:
self._async_error(active, "invalid_audio", str(error))
self._finish_generation(active, notify_idle=not active.tombstoned)
return
except BaseException as error:
if not active.tombstoned:
code = "model_failure" if completion.kind in ("load", "infer") else "recorder_failure"
self._async_error(active, code, str(error))
self._finish_generation(active, notify_idle=not active.tombstoned)
return
if completion.kind == "load":
self._model_loaded = True
if active.tombstoned:
self._finish_generation(active, notify_idle=False)
else:
self._begin_recording(active)
elif completion.kind == "stop":
if active.tombstoned:
self._finish_generation(active, notify_idle=False)
return
samples = result
if not isinstance(samples, Sequence) or isinstance(samples, (str, bytes, bytearray)):
self._async_error(active, "invalid_audio", "audio conversion returned invalid samples")
self._finish_generation(active, notify_idle=True)
return
self._state(active, "transcribing")
self._submit("infer", active, self._model.transcribe, samples)
elif completion.kind == "infer":
if active.tombstoned:
self._finish_generation(active, notify_idle=False)
return
if not isinstance(result, str):
self._async_error(active, "model_failure", "model returned a non-string transcript")
elif len(result.encode("utf-8")) > MAX_TRANSCRIPT_BYTES:
self._async_error(active, "transcript_too_large", "transcript exceeds output limit")
else:
self._emit(
active.owner,
{
"version": PROTOCOL_VERSION,
"type": "result",
"requestId": active.request_id,
"generationId": active.generation_id,
"transcript": result,
},
)
self._finish_generation(active, notify_idle=True)
elif completion.kind == "cancel":
self._finish_generation(active, notify_idle=False)
def _finish_generation(self, active: _Generation, *, notify_idle: bool) -> None:
if self._active is not active:
return
self._active = None
self._last_activity = self._clock()
if notify_idle and active.owner in self._clients:
self._state(active, "idle", assign=False)
def _state(self, active: _Generation, state: str, *, assign: bool = True) -> None:
if assign:
active.state = state
self._emit(
active.owner,
{
"version": PROTOCOL_VERSION,
"type": "state",
"requestId": active.request_id,
"generationId": active.generation_id,
"state": state,
},
)
def _request_error(self, client_id: str, request: Request, code: str, detail: str) -> None:
error = ProtocolError(
code,
detail,
request_id=request.request_id,
generation_id=request.generation_id,
)
self._emit(client_id, error_event(error))
def _async_error(self, active: _Generation, code: str, detail: str) -> None:
error = ProtocolError(
code,
detail,
request_id=active.request_id,
generation_id=active.generation_id,
)
self._emit(active.owner, error_event(error))
def _emit(self, client_id: str, frame: dict[str, object]) -> None:
if client_id in self._clients:
self._events.append(Outbound(client_id, frame))
@dataclass
class _Connection:
socket: socket.socket
decoder: FrameDecoder
output: bytearray
close_after_write: bool = False
def inherited_listener(environ: dict[str, str] | None = None) -> socket.socket:
"""Return systemd's single inherited listener at fd 3."""
environment = os.environ if environ is None else environ
try:
listen_pid = int(environment.get("LISTEN_PID", "0"))
listen_fds = int(environment.get("LISTEN_FDS", "0"))
except ValueError as error:
raise RuntimeError("invalid systemd socket activation environment") from error
if listen_pid != os.getpid() or listen_fds != 1:
raise RuntimeError("expected exactly one systemd socket-activated listener")
listener = socket.socket(fileno=3)
listener.setblocking(False)
return listener
def serve(listener: socket.socket, core: DictateServiceCore) -> None:
"""Serve protocol v1 on an explicit listener until the core becomes idle."""
selector = selectors.DefaultSelector()
connections: dict[str, _Connection] = {}
sequence = 0
listener.setblocking(False)
selector.register(listener, selectors.EVENT_READ, None)
try:
while not core.should_exit():
core.poll()
for outbound in core.drain_events():
connection = connections.get(outbound.client_id)
if connection is None:
continue
try:
encoded = encode_frame(outbound.frame)
except ProtocolError:
_close_connection(selector, connections, core, outbound.client_id)
continue
if len(connection.output) + len(encoded) > MAX_CONNECTION_OUTPUT_BYTES:
_close_connection(selector, connections, core, outbound.client_id)
continue
connection.output.extend(encoded)
selector.modify(
connection.socket,
selectors.EVENT_READ | selectors.EVENT_WRITE,
outbound.client_id,
)
for key, mask in selector.select(timeout=0.1):
if key.data is None:
try:
client, _address = listener.accept()
except BlockingIOError:
continue
if len(connections) >= MAX_CLIENT_CONNECTIONS:
client.close()
continue
client.setblocking(False)
sequence += 1
client_id = f"client-{sequence}"
connections[client_id] = _Connection(client, FrameDecoder(), bytearray())
core.accepted(client_id)
selector.register(client, selectors.EVENT_READ, client_id)
continue
client_id = key.data
connection = connections.get(client_id)
if connection is None:
continue
if mask & selectors.EVENT_READ:
try:
data = connection.socket.recv(64 * 1024)
except BlockingIOError:
data = None
except (BrokenPipeError, ConnectionResetError):
_close_connection(selector, connections, core, client_id)
continue
if data == b"":
_close_connection(selector, connections, core, client_id)
continue
if data:
try:
for frame in connection.decoder.feed(data):
core.handle_request(client_id, parse_request(frame))
except ProtocolError as error:
connection.output.extend(encode_frame(error_event(error)))
if error.close_connection:
connection.close_after_write = True
if mask & selectors.EVENT_WRITE and connection.output:
try:
sent = connection.socket.send(connection.output)
except BlockingIOError:
sent = 0
except (BrokenPipeError, ConnectionResetError):
_close_connection(selector, connections, core, client_id)
continue
del connection.output[:sent]
if connection.close_after_write and not connection.output:
_close_connection(selector, connections, core, client_id)
elif client_id in connections:
events = selectors.EVENT_READ
if connection.output:
events |= selectors.EVENT_WRITE
selector.modify(connection.socket, events, client_id)
finally:
for client_id in list(connections):
_close_connection(selector, connections, core, client_id)
selector.unregister(listener)
listener.close()
selector.close()
core.close()
def _close_connection(
selector: selectors.BaseSelector,
connections: dict[str, _Connection],
core: DictateServiceCore,
client_id: str,
) -> None:
connection = connections.pop(client_id, None)
if connection is None:
return
core.disconnect(client_id)
try:
selector.unregister(connection.socket)
except KeyError:
pass
connection.socket.close()
def main() -> None:
model_path = os.environ.get("PI_DICTATE_MODEL_PATH")
if not model_path:
raise SystemExit("PI_DICTATE_MODEL_PATH must name an explicit local .nemo file")
terms_path = os.environ.get("PI_DICTATE_TERMS_PATH")
if not terms_path:
raise SystemExit("PI_DICTATE_TERMS_PATH must name the host coding dictionary")
idle_value = os.environ.get("PI_DICTATE_IDLE_SECONDS", str(DEFAULT_IDLE_SECONDS))
boost_value = os.environ.get("PI_DICTATE_BOOST_ALPHA", "1.0")
try:
idle_seconds = float(idle_value)
boost_alpha = float(boost_value)
except ValueError as error:
raise SystemExit("PI_DICTATE_IDLE_SECONDS and PI_DICTATE_BOOST_ALPHA must be numeric") from error
if idle_seconds <= 0:
raise SystemExit("PI_DICTATE_IDLE_SECONDS must be positive")
if boost_alpha <= 0:
raise SystemExit("PI_DICTATE_BOOST_ALPHA must be positive")
model = NemoParakeetModel(model_path, terms_path=terms_path, boost_alpha=boost_alpha)
core = DictateServiceCore(model=model, idle_seconds=idle_seconds)
listener = inherited_listener()
serve(listener, core)
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "install.sh"
class InstallerTests(unittest.TestCase):
def run_installer(
self,
home: Path,
model: Path,
*arguments: str,
install_root_override: str | None = None,
) -> subprocess.CompletedProcess[str]:
environment = {
**os.environ,
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"PI_DICTATE_INSTALL_ROOT": install_root_override or str(
home / ".local/share/pi-dictate"
),
}
return subprocess.run(
[str(SCRIPT), *arguments, "--dry-run", "--model-source", str(model)],
env=environment,
capture_output=True,
text=True,
check=False,
)
def test_dry_run_seeds_dictionary_and_installs_owned_unit_copies(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
model = home / "model.nemo"
model.write_bytes(b"model")
result = self.run_installer(home, model)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("coding-terms.txt", result.stdout)
self.assertIn("pi-dictate.socket", result.stdout)
self.assertIn("install -m 0644", result.stdout)
self.assertNotIn("ln -s", result.stdout)
self.assertIn(".nemo.install.", result.stdout)
self.assertIn("mv -f", result.stdout)
self.assertIn("uv sync --frozen --no-dev --no-editable", result.stdout)
self.assertIn("Would install pi-dictate", result.stdout)
self.assertFalse((home / ".local/share/pi-dictate").exists())
def test_ignores_dangerous_install_root_environment_override(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
model = home / "model.nemo"
model.write_bytes(b"model")
result = self.run_installer(
home,
model,
"uninstall",
install_root_override="/",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(str(home / ".local/share/pi-dictate"), result.stdout)
self.assertNotIn("rm -rf / ", result.stdout)
def test_uninstall_refuses_to_disable_an_unmanaged_same_named_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("[Socket]\nListenStream=/tmp/unrelated.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.assertEqual(
unit.read_text(encoding="utf-8"),
"[Socket]\nListenStream=/tmp/unrelated.sock\n",
)
def test_reinstall_preserves_an_existing_host_dictionary(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
model = home / "model.nemo"
model.write_bytes(b"model")
dictionary = home / ".local/share/pi-dictate/config/coding-terms.txt"
dictionary.parent.mkdir(parents=True)
dictionary.write_text("my private term\n", encoding="utf-8")
result = self.run_installer(home, model)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Keeping existing coding dictionary", result.stdout)
self.assertEqual(dictionary.read_text(encoding="utf-8"), "my private term\n")
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
import hashlib
import os
from contextlib import nullcontext
from pathlib import Path
import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
from pi_dictate_service.model import NemoParakeetModel, load_coding_terms
class FakeNemoModel:
def __init__(self) -> None:
self.eval_called = False
self.to_device: str | None = None
self.audio = None
self.transcribe_options: dict[str, object] = {}
self.cfg = SimpleNamespace(decoding={"strategy": "greedy_batch", "greedy": {"max_symbols": 10}})
self.decoding_config: dict[str, object] | None = None
def eval(self) -> None:
self.eval_called = True
def to(self, device: str) -> None:
self.to_device = device
def change_decoding_strategy(self, config: dict[str, object], verbose: bool) -> None:
self.decoding_config = config
def transcribe(self, audio: list[object], **options: object) -> list[object]:
self.audio = audio
self.transcribe_options = options
return [type("Hypothesis", (), {"text": "hello locally"})()]
class FakeASRModel:
restored_path: str | None = None
restored: FakeNemoModel | None = None
@classmethod
def restore_from(cls, restore_path: str, map_location: str) -> FakeNemoModel:
cls.restored_path = restore_path
self = FakeNemoModel()
cls.restored = self
return self
class FakeNumpy(ModuleType):
float32 = "float32"
def asarray(self, samples: list[float], dtype: object) -> tuple[list[float], object]:
return samples, dtype
class NemoParakeetModelTests(unittest.TestCase):
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"
model_path.write_bytes(b"model")
terms_path = Path(directory) / "coding-terms.txt"
terms_path.write_text("JSON\nnpm test\n", encoding="utf-8")
modules = self.fake_modules()
with patch.dict(sys.modules, modules, clear=False), patch.dict(os.environ, {}, clear=True):
adapter = NemoParakeetModel(
model_path,
terms_path=terms_path,
boost_alpha=0.5,
expected_sha256=hashlib.sha256(b"model").hexdigest(),
)
self.assertIsNone(FakeASRModel.restored_path)
adapter.load()
transcript = adapter.transcribe([0.0, 0.5])
self.assertEqual(os.environ["HF_HUB_OFFLINE"], "1")
self.assertEqual(os.environ["TRANSFORMERS_OFFLINE"], "1")
self.assertEqual(FakeASRModel.restored_path, str(model_path))
self.assertTrue(FakeASRModel.restored.eval_called)
self.assertEqual(FakeASRModel.restored.to_device, "cuda")
self.assertEqual(transcript, "hello locally")
self.assertEqual(FakeASRModel.restored.audio, [([0.0, 0.5], "float32")])
self.assertEqual(
FakeASRModel.restored.decoding_config["greedy"]["boosting_tree"]["key_phrases_list"],
["JSON", "npm test"],
)
self.assertEqual(
FakeASRModel.restored.decoding_config["greedy"]["boosting_tree_alpha"],
0.5,
)
self.assertEqual(
FakeASRModel.restored.transcribe_options,
{"batch_size": 1, "num_workers": 0, "verbose": False},
)
def test_loads_a_bounded_deduplicated_host_dictionary(self) -> None:
with tempfile.TemporaryDirectory() as directory:
terms_path = Path(directory) / "coding-terms.txt"
terms_path.write_text("# local terms\n JSON \n\nnpm\nJSON\n", encoding="utf-8")
self.assertEqual(load_coding_terms(terms_path), ["JSON", "npm"])
terms_path.write_text("x" * 129, encoding="utf-8")
with self.assertRaisesRegex(ValueError, "128 UTF-8 bytes"):
load_coding_terms(terms_path)
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"
model_path.write_bytes(b"replaced")
adapter = NemoParakeetModel(model_path)
with self.assertRaisesRegex(ValueError, "model checksum mismatch"):
adapter.load()
def test_requires_an_existing_local_nemo_file(self) -> None:
with self.assertRaisesRegex(ValueError, "local .nemo"):
NemoParakeetModel("/does/not/exist")
@staticmethod
def fake_modules() -> dict[str, ModuleType]:
nemo = ModuleType("nemo")
collections = ModuleType("nemo.collections")
asr = ModuleType("nemo.collections.asr")
models = ModuleType("nemo.collections.asr.models")
models.ASRModel = FakeASRModel
asr.models = models
collections.asr = asr
nemo.collections = collections
omegaconf = ModuleType("omegaconf")
omegaconf.open_dict = lambda _config: nullcontext()
return {
"nemo": nemo,
"nemo.collections": collections,
"nemo.collections.asr": asr,
"nemo.collections.asr.models": models,
"numpy": FakeNumpy("numpy"),
"omegaconf": omegaconf,
}
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,162 @@
import json
import unittest
from pi_dictate_service.audio import AudioError, pcm_s16le_to_floats
from pi_dictate_service.protocol import (
MAX_ERROR_DETAIL_BYTES,
MAX_FRAME_BYTES,
MAX_ID_BYTES,
MAX_TRANSCRIPT_BYTES,
FrameDecoder,
ProtocolError,
encode_frame,
parse_request,
)
class FrameDecoderTests(unittest.TestCase):
def test_decodes_fragmented_and_multiple_frames(self) -> None:
decoder = FrameDecoder()
self.assertEqual(decoder.feed(b'{"version":1'), [])
frames = decoder.feed(
b',"type":"ping","requestId":"one"}\n'
b'{"version":1,"type":"ping","requestId":"two"}\n'
)
self.assertEqual([frame["requestId"] for frame in frames], ["one", "two"])
def test_rejects_malformed_json_and_oversized_frames_as_fatal(self) -> None:
with self.assertRaisesRegex(ProtocolError, "malformed JSON") as malformed:
FrameDecoder().feed(b'{nope}\n')
self.assertTrue(malformed.exception.close_connection)
self.assertEqual(malformed.exception.code, "malformed_json")
with self.assertRaises(ProtocolError) as oversized:
FrameDecoder().feed(b"x" * (MAX_FRAME_BYTES + 1))
self.assertTrue(oversized.exception.close_connection)
self.assertEqual(oversized.exception.code, "frame_too_large")
class RequestValidationTests(unittest.TestCase):
def test_requires_exact_fields_bounded_ids_and_supported_version(self) -> None:
request = parse_request(
{"version": 1, "type": "start", "requestId": "r1", "generationId": "g1"}
)
self.assertEqual(request.generation_id, "g1")
with self.assertRaises(ProtocolError) as unknown:
parse_request(
{
"version": 1,
"type": "start",
"requestId": "r1",
"generationId": "g1",
"path": "/tmp/audio",
}
)
self.assertEqual(unknown.exception.code, "invalid_request")
self.assertFalse(unknown.exception.close_connection)
with self.assertRaises(ProtocolError) as long_id:
parse_request(
{
"version": 1,
"type": "ping",
"requestId": "é" * ((MAX_ID_BYTES // 2) + 1),
}
)
self.assertEqual(long_id.exception.code, "invalid_request")
with self.assertRaises(ProtocolError) as unsupported:
parse_request({"version": 2, "type": "hello", "requestId": "r2"})
self.assertEqual(unsupported.exception.code, "unsupported_version")
self.assertTrue(unsupported.exception.close_connection)
def test_rejects_unhashable_types_invalid_unicode_and_huge_integers(self) -> None:
for request_type in ([], {}):
with self.subTest(request_type=request_type):
with self.assertRaises(ProtocolError) as failed:
parse_request(
{"version": 1, "type": request_type, "requestId": "request"}
)
self.assertEqual(failed.exception.code, "invalid_request")
with self.assertRaises(ProtocolError) as surrogate:
parse_request({"version": 1, "type": "ping", "requestId": "\ud800"})
self.assertEqual(surrogate.exception.request_id, "")
huge_integer = b'{"version":' + (b"9" * 5000) + b',"type":"hello","requestId":"r"}\n'
with self.assertRaises(ProtocolError) as huge:
FrameDecoder().feed(huge_integer)
self.assertEqual(huge.exception.code, "malformed_json")
self.assertTrue(huge.exception.close_connection)
def test_error_echoes_only_valid_bounded_ids(self) -> None:
oversized = "x" * (MAX_ID_BYTES + 1)
with self.assertRaises(ProtocolError) as unsupported:
parse_request(
{"version": 2, "type": "hello", "requestId": oversized}
)
self.assertEqual(unsupported.exception.request_id, "")
encoded = encode_frame(
{
"version": 1,
"type": "error",
"requestId": oversized,
"generationId": "\ud800",
"code": "invalid_request",
"detail": "bad request",
}
)
payload = json.loads(encoded)
self.assertEqual(payload["requestId"], "")
self.assertNotIn("generationId", payload)
def test_encoded_transcripts_and_error_details_are_bounded(self) -> None:
with self.assertRaises(ProtocolError) as transcript:
encode_frame(
{
"version": 1,
"type": "result",
"generationId": "g1",
"transcript": "é" * ((MAX_TRANSCRIPT_BYTES // 2) + 1),
}
)
self.assertEqual(transcript.exception.code, "transcript_too_large")
encoded = encode_frame(
{
"version": 1,
"type": "error",
"requestId": "r1",
"code": "failed",
"detail": "é" * MAX_ERROR_DETAIL_BYTES,
}
)
payload = json.loads(encoded)
self.assertLessEqual(len(payload["detail"].encode("utf-8")), MAX_ERROR_DETAIL_BYTES)
self.assertLessEqual(len(encoded) - 1, MAX_FRAME_BYTES)
class AudioConversionTests(unittest.TestCase):
def test_converts_little_endian_signed_16_bit_pcm(self) -> None:
pcm = b"\x00\x80\x00\xc0\x00\x00\x00\x40\xff\x7f"
samples = pcm_s16le_to_floats(pcm)
self.assertEqual(samples.itemsize, 4)
self.assertEqual(
list(samples),
[-1.0, -0.5, 0.0, 0.5, 32767 / 32768.0],
)
def test_rejects_odd_or_overlong_pcm(self) -> None:
with self.assertRaisesRegex(AudioError, "even"):
pcm_s16le_to_floats(b"\x00")
with self.assertRaisesRegex(AudioError, "duration"):
pcm_s16le_to_floats(b"\x00\x00" * 5, max_bytes=8)
if __name__ == "__main__":
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
import io
import subprocess
import threading
import unittest
from pi_dictate_service.recorder import PW_RECORD_COMMAND, PwRecorder, RecorderError
class TrackingStream(io.BytesIO):
def __init__(self, value: bytes, events: list[str], name: str) -> None:
super().__init__(value)
self._events = events
self._name = name
self.eof = threading.Event()
def read(self, size: int = -1) -> bytes:
value = super().read(size)
if not value and not self.eof.is_set():
self._events.append(f"{self._name}-eof")
self.eof.set()
return value
class FakeProcess:
def __init__(
self,
stdout: TrackingStream,
stderr: TrackingStream,
*,
returncode: int | None = None,
timeout: bool = False,
terminate_returncode: int = -15,
) -> None:
self.pid = 1234
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
self._timeout = timeout
self._terminate_returncode = terminate_returncode
self.events = stdout._events
def poll(self) -> int | None:
return self.returncode
def terminate(self) -> None:
self.events.append("terminate")
if not self._timeout:
self.returncode = self._terminate_returncode
def kill(self) -> None:
self.events.append("kill")
self.returncode = -9
self._timeout = False
def wait(self, timeout: float | None = None) -> int:
self.events.append("wait")
if self._timeout:
raise subprocess.TimeoutExpired("pw-record", timeout)
if self.returncode is None:
self.returncode = 0
return self.returncode
class ProcessFactory:
def __init__(self, process: FakeProcess) -> None:
self.process = process
self.calls: list[tuple[list[str], dict[str, object]]] = []
def __call__(self, command: list[str], **kwargs: object) -> FakeProcess:
self.calls.append((command, kwargs))
return self.process
class Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
class PwRecorderTests(unittest.TestCase):
def make_recorder(
self,
pcm: bytes = b"\x00\x00\x01\x00",
stderr: bytes = b"",
*,
returncode: int | None = None,
timeout: bool = False,
max_bytes: int = 1024,
stderr_limit: int = 64 * 1024,
clock: Clock | None = None,
terminate_returncode: int = -15,
) -> tuple[PwRecorder, FakeProcess, ProcessFactory, list[str]]:
events: list[str] = []
process = FakeProcess(
TrackingStream(pcm, events, "stdout"),
TrackingStream(stderr, events, "stderr"),
returncode=returncode,
timeout=timeout,
terminate_returncode=terminate_returncode,
)
factory = ProcessFactory(process)
def signal_group(pid: int, signal_number: int) -> None:
self.assertEqual(pid, process.pid)
if signal_number == 15:
process.terminate()
elif signal_number == 9:
process.kill()
else:
self.fail(f"unexpected signal: {signal_number}")
recorder = PwRecorder(
process_factory=factory,
max_bytes=max_bytes,
stderr_limit=stderr_limit,
stop_timeout=0.01,
clock=clock or Clock(),
process_group_signal=signal_group,
)
return recorder, process, factory, events
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
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
capture = recorder.stop()
self.assertEqual(factory.calls[0][0], PW_RECORD_COMMAND)
self.assertIs(factory.calls[0][1]["stdout"], subprocess.PIPE)
self.assertIs(factory.calls[0][1]["stderr"], subprocess.PIPE)
self.assertIs(factory.calls[0][1]["start_new_session"], True)
self.assertEqual(capture.pcm, b"\x00\x00\x01\x00")
self.assertEqual(capture.stderr_tail, b"23456789")
self.assertIn("stdout-eof", events)
self.assertIn("wait", events)
def test_accepts_pw_record_code_one_after_intentional_sigterm(self) -> None:
recorder, process, _factory, _events = self.make_recorder(terminate_returncode=1)
recorder.start()
capture = recorder.stop()
self.assertEqual(capture.returncode, 1)
self.assertIsNone(capture.signal)
self.assertEqual(capture.pcm, b"\x00\x00\x01\x00")
self.assertEqual(process.returncode, 1)
def test_rejects_code_one_if_process_already_exited_or_has_stderr(self) -> None:
exited, exited_process, _factory, _events = self.make_recorder(returncode=1)
exited.start()
self.assertTrue(exited_process.stdout.eof.wait(1))
with self.assertRaises(RecorderError):
exited.stop()
noisy, _process, _factory, _events = self.make_recorder(
stderr=b"pipewire failed",
terminate_returncode=1,
)
noisy.start()
with self.assertRaises(RecorderError):
noisy.stop()
def test_shutdown_timeout_escalates_to_kill_and_fails(self) -> None:
recorder, _process, _factory, events = self.make_recorder(timeout=True)
recorder.start()
with self.assertRaises(RecorderError) as failed:
recorder.stop()
self.assertEqual(failed.exception.code, "recorder_timeout")
self.assertLess(events.index("terminate"), events.index("kill"))
def test_classifies_unexpected_exit_code_and_signal(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
stderr=b"pipewire failed", returncode=7
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
failure = recorder.poll_unexpected()
self.assertIsNotNone(failure)
assert failure is not None
self.assertEqual(failure.code, "recorder_exit")
self.assertIn("code 7", failure.detail)
self.assertIn("pipewire failed", failure.detail)
signalled, signal_process, _factory, _events = self.make_recorder(returncode=-11)
signalled.start()
self.assertTrue(signal_process.stdout.eof.wait(1))
signal_failure = signalled.poll_unexpected()
assert signal_failure is not None
self.assertIn("signal 11", signal_failure.detail)
def test_duration_limit_discards_audio_and_reports_failure(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
pcm=b"\x00\x00" * 10, max_bytes=8
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
failure = recorder.poll_unexpected()
assert failure is not None
self.assertEqual(failure.code, "duration_limit")
self.assertEqual(recorder.buffered_bytes, 0)
def test_stop_reports_a_duration_limit_that_was_already_reached(self) -> None:
recorder, process, _factory, _events = self.make_recorder(
pcm=b"\x00\x00" * 10, max_bytes=8
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
with self.assertRaises(RecorderError) as failed:
recorder.stop()
self.assertEqual(failed.exception.code, "duration_limit")
self.assertEqual(recorder.buffered_bytes, 0)
def test_duration_limit_kills_a_recorder_that_ignores_sigterm(self) -> None:
clock = Clock()
recorder, process, _factory, events = self.make_recorder(
pcm=b"\x00\x00" * 10,
max_bytes=8,
timeout=True,
clock=clock,
)
recorder.start()
self.assertTrue(process.stdout.eof.wait(1))
self.assertIsNone(recorder.poll_unexpected())
clock.now = 0.02
failure = recorder.poll_unexpected()
assert failure is not None
self.assertEqual(failure.code, "duration_limit")
self.assertIn("kill", events)
if __name__ == "__main__":
unittest.main()
+333
View File
@@ -0,0 +1,333 @@
from concurrent.futures import Future
from collections.abc import Sequence
from dataclasses import dataclass
import unittest
from pi_dictate_service.protocol import MAX_TRANSCRIPT_BYTES, parse_request
from pi_dictate_service.recorder import Capture, RecorderError
from pi_dictate_service.server import DictateServiceCore, Outbound
class Clock:
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
@dataclass
class PendingWork:
future: Future[object]
function: object
args: tuple[object, ...]
class ControlledExecutor:
def __init__(self) -> None:
self.pending: list[PendingWork] = []
def submit(self, function: object, *args: object) -> Future[object]:
future: Future[object] = Future()
self.pending.append(PendingWork(future, function, args))
return future
def run_next(self) -> None:
work = self.pending.pop(0)
try:
result = work.function(*work.args) # type: ignore[operator]
except BaseException as error:
work.future.set_exception(error)
else:
work.future.set_result(result)
def shutdown(self, wait: bool = True, cancel_futures: bool = False) -> None:
return None
class FakeModel:
def __init__(self) -> None:
self.loaded = False
self.transcript = "dictated text"
self.load_error: BaseException | None = None
self.transcribe_error: BaseException | None = None
self.samples: Sequence[float] | None = None
def load(self) -> None:
if self.load_error:
raise self.load_error
self.loaded = True
def transcribe(self, samples: Sequence[float]) -> str:
self.samples = samples
if self.transcribe_error:
raise self.transcribe_error
return self.transcript
class FakeRecorder:
def __init__(self) -> None:
self.started = False
self.cancelled = False
self.capture = Capture(b"\x00\x00\x00\x40", -15, 15, b"")
self.stop_error: RecorderError | None = None
self.unexpected: RecorderError | None = None
def start(self) -> None:
self.started = True
def stop(self) -> Capture:
if self.stop_error:
raise self.stop_error
return self.capture
def cancel(self) -> None:
self.cancelled = True
def poll_unexpected(self) -> RecorderError | None:
failure, self.unexpected = self.unexpected, None
return failure
class RecorderFactory:
def __init__(self) -> None:
self.instances: list[FakeRecorder] = []
def __call__(self) -> FakeRecorder:
recorder = FakeRecorder()
self.instances.append(recorder)
return recorder
class CoreTestCase(unittest.TestCase):
def setUp(self) -> None:
self.clock = Clock()
self.executor = ControlledExecutor()
self.model = FakeModel()
self.recorders = RecorderFactory()
self.core = DictateServiceCore(
model=self.model,
recorder_factory=self.recorders,
executor=self.executor,
clock=self.clock,
idle_seconds=900,
)
def request(
self,
client: str,
request_type: str,
request_id: str,
generation_id: str | None = None,
) -> list[dict[str, object]]:
frame: dict[str, object] = {
"version": 1,
"type": request_type,
"requestId": request_id,
}
if generation_id is not None:
frame["generationId"] = generation_id
self.core.handle_request(client, parse_request(frame))
return self.frames(self.core.drain_events(), client)
@staticmethod
def frames(events: list[Outbound], client: str | None = None) -> list[dict[str, object]]:
return [event.frame for event in events if client is None or event.client_id == client]
def hello(self, client: str = "a") -> None:
events = self.request(client, "hello", f"hello-{client}")
self.assertEqual(events[0]["type"], "pong")
def load_to_listening(self, client: str = "a", generation: str = "g1") -> FakeRecorder:
self.hello(client)
self.request(client, "start", "start", generation)
self.executor.run_next()
self.core.poll()
events = self.frames(self.core.drain_events(), client)
self.assertEqual(events[-1]["state"], "listening")
self.assertEqual(events[-1]["requestId"], "start")
return self.recorders.instances[-1]
class HandshakeAndOwnershipTests(CoreTestCase):
def test_hello_is_required_and_valid_errors_keep_session_open(self) -> None:
events = self.request("a", "start", "s1", "g1")
self.assertEqual(events[0]["code"], "hello_required")
self.hello("a")
self.request("a", "start", "s2", "g1")
self.assertEqual(len(self.executor.pending), 1)
def test_one_generation_is_globally_owned(self) -> None:
self.load_to_listening()
self.hello("b")
busy = self.request("b", "start", "s2", "g2")
self.assertEqual(busy[0]["code"], "busy")
not_owner = self.request("b", "stop", "x", "g1")
self.assertEqual(not_owner[0]["code"], "not_owner")
stale = self.request("a", "stop", "x2", "old")
self.assertEqual(stale[0]["code"], "generation_mismatch")
stopping = self.request("a", "stop", "x3", "g1")
self.assertEqual(stopping[0]["state"], "stopping")
class CancellationTests(CoreTestCase):
def test_cancel_during_load_is_immediate_and_blocks_until_worker_finishes(self) -> None:
self.hello("a")
self.request("a", "start", "start", "g1")
cancelled = self.request("a", "cancel", "cancel", "g1")
self.assertEqual(cancelled[0]["type"], "cancelled")
self.hello("b")
self.assertEqual(self.request("b", "start", "start-b", "g2")[0]["code"], "busy")
self.executor.run_next()
self.core.poll()
self.assertEqual(self.recorders.instances, [])
self.assertNotIn("result", [event["type"] for event in self.frames(self.core.drain_events())])
def test_cancel_during_inference_suppresses_later_result(self) -> None:
self.load_to_listening()
self.request("a", "stop", "stop", "g1")
self.executor.run_next()
self.core.poll()
transcribing = self.frames(self.core.drain_events(), "a")
self.assertEqual(transcribing[0]["state"], "transcribing")
cancelled = self.request("a", "cancel", "cancel", "g1")
self.assertEqual(cancelled[0]["type"], "cancelled")
self.executor.run_next()
self.core.poll()
self.assertNotIn("result", [event["type"] for event in self.frames(self.core.drain_events())])
def test_disconnect_cancels_capture_and_tombstones_workers(self) -> None:
recorder = self.load_to_listening()
self.core.disconnect("a")
self.assertEqual(len(self.executor.pending), 1)
self.executor.run_next()
self.core.poll()
self.assertTrue(recorder.cancelled)
self.assertEqual(self.core.active_generation_id, None)
class StopAndInferenceTests(CoreTestCase):
def test_stop_drains_then_converts_before_inference_and_emits_result(self) -> None:
self.load_to_listening()
stopping = self.request("a", "stop", "stop", "g1")
self.assertEqual(stopping[0]["state"], "stopping")
self.assertEqual(stopping[0]["requestId"], "stop")
self.executor.run_next()
self.core.poll()
transcribing = self.frames(self.core.drain_events(), "a")
self.assertEqual(transcribing[0]["state"], "transcribing")
self.executor.run_next()
self.core.poll()
completed = self.frames(self.core.drain_events(), "a")
self.assertEqual(list(self.model.samples or []), [0.0, 0.5])
self.assertEqual(completed[0]["type"], "result")
self.assertEqual(completed[0]["transcript"], "dictated text")
self.assertEqual(completed[1]["state"], "idle")
def test_empty_transcript_is_a_result(self) -> None:
self.model.transcript = ""
self.load_to_listening()
self.request("a", "stop", "stop", "g1")
self.executor.run_next()
self.core.poll()
self.core.drain_events()
self.executor.run_next()
self.core.poll()
completed = self.frames(self.core.drain_events(), "a")
self.assertEqual(completed[0]["type"], "result")
self.assertEqual(completed[0]["transcript"], "")
def test_model_and_transcript_failures_are_bounded_terminal_errors(self) -> None:
self.model.load_error = RuntimeError("CUDA unavailable")
self.hello()
self.request("a", "start", "start", "g1")
self.executor.run_next()
self.core.poll()
failed = self.frames(self.core.drain_events(), "a")
self.assertEqual(failed[0]["code"], "model_failure")
self.assertEqual(failed[-1]["state"], "idle")
model = FakeModel()
model.loaded = True
model.transcript = "x" * (MAX_TRANSCRIPT_BYTES + 1)
core = DictateServiceCore(
model=model,
recorder_factory=self.recorders,
executor=self.executor,
clock=self.clock,
)
core.handle_request("a", parse_request({"version": 1, "type": "hello", "requestId": "h"}))
core.drain_events()
core.handle_request("a", parse_request({"version": 1, "type": "start", "requestId": "s", "generationId": "g"}))
core.drain_events()
core.handle_request("a", parse_request({"version": 1, "type": "stop", "requestId": "t", "generationId": "g"}))
self.executor.run_next()
core.poll()
core.drain_events()
self.executor.run_next()
core.poll()
failed = self.frames(core.drain_events(), "a")
self.assertEqual(failed[0]["code"], "transcript_too_large")
def test_unexpected_recorder_exit_is_terminal(self) -> None:
recorder = self.load_to_listening()
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 3")
self.core.poll()
failed = self.frames(self.core.drain_events(), "a")
self.assertEqual(failed[0]["code"], "recorder_exit")
self.assertEqual(failed[-1]["state"], "idle")
def test_stop_does_not_reclassify_an_already_exited_recorder_as_intentional(self) -> None:
recorder = self.load_to_listening()
recorder.unexpected = RecorderError("recorder_exit", "pw-record exited with code 0")
failed = self.request("a", "stop", "stop", "g1")
self.assertEqual(failed[0]["code"], "recorder_exit")
self.assertEqual(failed[-1]["state"], "idle")
self.assertEqual(self.executor.pending, [])
class IdleTests(CoreTestCase):
def test_only_valid_requests_reset_idle_and_workers_suspend_then_restart_timer(self) -> None:
self.core.accepted("a")
self.clock.advance(899)
self.assertFalse(self.core.should_exit())
self.request("a", "hello", "h")
self.clock.advance(899)
self.assertFalse(self.core.should_exit())
self.request("a", "start", "s", "g")
self.clock.advance(901)
self.assertFalse(self.core.should_exit())
self.request("a", "cancel", "c", "g")
self.executor.run_next()
self.core.poll()
self.core.drain_events()
self.assertFalse(self.core.should_exit())
self.clock.advance(899)
self.assertFalse(self.core.should_exit())
self.clock.advance(1)
self.assertTrue(self.core.should_exit())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,38 @@
from pathlib import Path
import unittest
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
UNIT_DIR = REPOSITORY_ROOT / "systemd/user"
class SystemdUnitTests(unittest.TestCase):
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)
self.assertIn("SocketMode=0600", socket_unit)
self.assertIn("Accept=no", socket_unit)
self.assertIn("RemoveOnStop=yes", socket_unit)
def test_service_is_offline_hardened_and_uses_private_native_caches(self) -> None:
service_unit = (UNIT_DIR / "pi-dictate.service").read_text(encoding="utf-8")
for expected in (
"PI_DICTATE_MODEL_PATH=%h/.local/share/pi-dictate/models/",
"PI_DICTATE_TERMS_PATH=%h/.local/share/pi-dictate/config/coding-terms.txt",
"PI_DICTATE_BOOST_ALPHA=1.0",
"HF_HUB_OFFLINE=1",
"TRANSFORMERS_OFFLINE=1",
"TRITON_CACHE_DIR=/tmp/pi-dictate-triton",
"NUMBA_CACHE_DIR=/tmp/pi-dictate-numba",
"LimitCORE=0",
"MemorySwapMax=0",
"ProtectSystem=strict",
"ProtectHome=read-only",
"RestrictAddressFamilies=AF_UNIX",
"IPAddressDeny=any",
):
self.assertIn(expected, service_unit)
if __name__ == "__main__":
unittest.main()
+225
View File
@@ -0,0 +1,225 @@
import json
import os
from pathlib import Path
import socket
import struct
import tempfile
import threading
import time
import unittest
from unittest.mock import Mock, patch
from pi_dictate_service.server import (
MAX_CLIENT_CONNECTIONS,
DictateServiceCore,
inherited_listener,
main,
serve,
)
class LoadedModel:
loaded = True
def load(self) -> None:
return None
def transcribe(self, samples: list[float]) -> str:
return ""
class UnixLoopTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.socket_path = Path(self.temporary.name) / "dictate.sock"
self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.listener.bind(str(self.socket_path))
self.listener.listen()
self.core = DictateServiceCore(model=LoadedModel(), idle_seconds=0.4)
self.failure: BaseException | None = None
def run() -> None:
try:
serve(self.listener, self.core)
except BaseException as error:
self.failure = error
self.thread = threading.Thread(target=run)
self.thread.start()
def tearDown(self) -> None:
self.thread.join(timeout=2)
if self.thread.is_alive():
self.fail("server loop did not exit after its idle timeout")
if self.failure:
raise self.failure
self.temporary.cleanup()
def connect(self) -> socket.socket:
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(1)
client.connect(str(self.socket_path))
return client
@staticmethod
def receive_frame(client: socket.socket) -> dict[str, object]:
data = bytearray()
while not data.endswith(b"\n"):
chunk = client.recv(4096)
if not chunk:
break
data.extend(chunk)
return json.loads(data)
def test_accepts_fragmented_requests_and_writes_persistent_responses(self) -> None:
with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello",')
client.sendall(b'"requestId":"hello"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong")
client.sendall(b'{"version":1,"type":"ping","requestId":"ping"}\n')
pong = self.receive_frame(client)
self.assertEqual(pong["requestId"], "ping")
self.assertEqual(pong["service"], "pi-dictate")
def test_valid_state_error_keeps_open_but_malformed_frame_errors_once_then_closes(self) -> None:
with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"hello"}\n')
self.receive_frame(client)
client.sendall(
b'{"version":1,"type":"stop","requestId":"stop","generationId":"g"}\n'
)
self.assertEqual(self.receive_frame(client)["code"], "invalid_state")
client.sendall(b'{"version":1,"type":"ping","requestId":"ping"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong")
client.sendall(b'{broken}\n')
self.assertEqual(self.receive_frame(client)["code"], "malformed_json")
self.assertEqual(client.recv(1), b"")
self.assertTrue(self.socket_path.exists(), "the service must not unlink a listener-owned path")
def test_malicious_json_values_do_not_crash_the_service(self) -> None:
with self.connect() as client:
client.sendall(b'{"version":1,"type":[],"requestId":"bad-type"}\n')
self.assertEqual(self.receive_frame(client)["code"], "invalid_request")
client.sendall(b'{"version":1,"type":"ping","requestId":"\\ud800"}\n')
self.assertEqual(self.receive_frame(client)["code"], "invalid_request")
with self.connect() as client:
huge_integer = b"9" * 5000
client.sendall(
b'{"version":' + huge_integer + b',"type":"hello","requestId":"huge"}\n'
)
self.assertEqual(self.receive_frame(client)["code"], "malformed_json")
self.assertEqual(client.recv(1), b"")
with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"healthy"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong")
def test_closes_non_reading_client_before_output_queue_can_grow_unbounded(self) -> None:
with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"hello"}\n')
self.receive_frame(client)
ping = b'{"version":1,"type":"ping","requestId":"p"}\n'
try:
client.sendall(ping * 1200)
except BrokenPipeError:
pass
received = bytearray()
while True:
try:
chunk = client.recv(64 * 1024)
except ConnectionResetError:
break
if not chunk:
break
received.extend(chunk)
self.assertLessEqual(len(received), 64 * 1024)
with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"healthy"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong")
def test_rejects_connections_above_the_fixed_limit(self) -> None:
clients = [self.connect() for _ in range(MAX_CLIENT_CONNECTIONS)]
try:
time.sleep(0.1)
overflow = self.connect()
try:
overflow.sendall(b'{"version":1,"type":"hello","requestId":"overflow"}\n')
with self.assertRaises((ConnectionResetError, BrokenPipeError)):
if overflow.recv(1) == b"":
raise ConnectionResetError
finally:
overflow.close()
finally:
for client in clients:
client.close()
def test_client_reset_does_not_crash_the_service(self) -> None:
reset_client = self.connect()
reset_client.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
reset_client.sendall(b'{"version":1,"type":"hello","requestId":"abandoned"}\n')
reset_client.close()
time.sleep(0.15)
with self.connect() as client:
client.sendall(b'{"version":1,"type":"hello","requestId":"hello"}\n')
self.assertEqual(self.receive_frame(client)["type"], "pong")
class ActivationTests(unittest.TestCase):
def test_inherited_listener_requires_one_fd_for_this_process_and_wraps_fd_three(self) -> None:
listener = Mock()
with patch("pi_dictate_service.server.os.getpid", return_value=42), patch(
"pi_dictate_service.server.socket.socket", return_value=listener
) as socket_factory:
self.assertIs(
inherited_listener({"LISTEN_PID": "42", "LISTEN_FDS": "1"}),
listener,
)
socket_factory.assert_called_once_with(fileno=3)
listener.setblocking.assert_called_once_with(False)
with patch("pi_dictate_service.server.os.getpid", return_value=42):
with self.assertRaisesRegex(RuntimeError, "exactly one"):
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 self.assertRaisesRegex(SystemExit, "PI_DICTATE_MODEL_PATH"):
main()
model = object()
listener = object()
with patch.dict(
os.environ,
{
"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",
},
clear=True,
), 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
), patch("pi_dictate_service.server.serve") as serve_loop:
core = core_factory.return_value
main()
adapter.assert_called_once_with(
"/models/parakeet.nemo",
terms_path="/config/coding-terms.txt",
boost_alpha=0.5,
)
core_factory.assert_called_once_with(model=model, idle_seconds=12.5)
serve_loop.assert_called_once_with(listener, core)
if __name__ == "__main__":
unittest.main()
+2238
View File
File diff suppressed because it is too large Load Diff