feat(dictate): use local socket-activated Parakeet
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
build/
|
||||
src/*.egg-info/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -0,0 +1 @@
|
||||
3.12.13
|
||||
@@ -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
|
||||
Executable
+183
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Generated
+2238
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user