Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
10 KiB
Python
251 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import threading
|
|
import wave
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .council import HUMAN
|
|
from .errors import EngineError, ToolError, Unavailable
|
|
from .fields import TEXT_LIMIT, bounded, decode, expect
|
|
from .phrasing import SPEECH_LIMIT, SPOKEN_LINE, phrase
|
|
from .records import digest, read_json
|
|
from .skill_scripts import SAFE_PATH
|
|
|
|
MODEL_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
|
|
SPEAKER_LIMIT = 10000
|
|
TTS_TIMEOUT = 60
|
|
TTS_KEEP = 200
|
|
STT_RATE = 16000
|
|
STT_SECONDS = 120
|
|
STT_LIMIT = 44 + STT_RATE * 2 * STT_SECONDS
|
|
STT_TIMEOUT = 120
|
|
NOISE = re.compile(r"\[[^\]\n]{0,40}\]")
|
|
|
|
|
|
def voice_models(folder: Path) -> list[Path]:
|
|
try:
|
|
return sorted(folder.glob("*.onnx"))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def installed_voices(folder: Path) -> dict[str, tuple[str, int | None]]:
|
|
"""Args:
|
|
folder: directory holding <model>.onnx files with <model>.onnx.json.
|
|
|
|
Returns:
|
|
voice id <model>:<speaker> -> (model, speaker id or None for a model
|
|
with one speaker), in model and speaker order; a model without a
|
|
readable config or with an unsafe name is left out.
|
|
"""
|
|
found: dict[str, tuple[str, int | None]] = {}
|
|
for model in voice_models(folder):
|
|
data = read_json(model.with_name(f"{model.name}.json"))
|
|
count = data.get("num_speakers") if isinstance(data, dict) else None
|
|
if not MODEL_NAME.fullmatch(model.stem) or isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= SPEAKER_LIMIT:
|
|
continue
|
|
for speaker in range(count):
|
|
found[f"{model.stem}:{speaker}"] = (model.stem, speaker if count > 1 else None)
|
|
return found
|
|
|
|
|
|
def unreachable(path: Path, part: str, mode: int, ability: str) -> str:
|
|
"""Args:
|
|
path: a file or directory an engine needs.
|
|
part: what path is, such as "the Piper executable".
|
|
mode: the os.access mode the hub needs on path.
|
|
ability: what path must be for that mode, such as "executable".
|
|
|
|
Returns:
|
|
"" when the hub may use path, otherwise a message naming path and
|
|
the first directory above it the hub may not search, if any.
|
|
"""
|
|
if os.access(path, mode):
|
|
return ""
|
|
closed = next((folder for folder in reversed(path.parents) if not os.access(folder, os.X_OK)), None)
|
|
if closed is not None:
|
|
return f"{part} {path} lies under {closed}, which the hub may not search"
|
|
return f"{part} {path} is missing or not {ability}"
|
|
|
|
|
|
def voice_of(key: str, pool: list[str], operator: str) -> str:
|
|
"""Args:
|
|
key: the speaker's session id or directory key; HUMAN is the operator.
|
|
pool: the installed voice ids other than the operator's, in their
|
|
fixed order.
|
|
operator: the operator's voice; empty when no voice is installed.
|
|
|
|
Returns:
|
|
operator for HUMAN or when the pool is empty, otherwise the voice
|
|
sha256(key) picks from the pool.
|
|
"""
|
|
if key == HUMAN or not pool:
|
|
return operator
|
|
return pool[int(digest(key), 16) % len(pool)]
|
|
|
|
|
|
def wav_seconds(body: bytes) -> float:
|
|
try:
|
|
with wave.open(io.BytesIO(body), "rb") as reader:
|
|
shape = (reader.getnchannels(), reader.getsampwidth(), reader.getframerate(), reader.getcomptype())
|
|
frames = reader.getnframes()
|
|
except (wave.Error, EOFError):
|
|
raise ToolError("the recording must be a WAV file") from None
|
|
if shape != (1, 2, STT_RATE, "NONE"):
|
|
raise ToolError(f"the recording must be {STT_RATE} Hz mono 16-bit PCM")
|
|
if not 0 < frames <= STT_RATE * STT_SECONDS:
|
|
raise ToolError(f"the recording must last more than 0 and at most {STT_SECONDS} seconds")
|
|
return frames / STT_RATE
|
|
|
|
|
|
def playable(path: Path) -> bool:
|
|
try:
|
|
with wave.open(str(path), "rb") as reader:
|
|
return reader.getnframes() > 0
|
|
except (OSError, wave.Error, EOFError):
|
|
return False
|
|
|
|
|
|
def heard(output: str) -> str:
|
|
return " ".join(NOISE.sub(" ", output).split())[:TEXT_LIMIT]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Engines:
|
|
piper: Path
|
|
voices: Path
|
|
operator_voice: str
|
|
whisper: Path
|
|
whisper_model: Path
|
|
scratch: Path
|
|
|
|
|
|
class Speech:
|
|
def __init__(self, engines: Engines, cache: Path) -> None:
|
|
self.engines = engines
|
|
self.cache = cache
|
|
cache.mkdir(exist_ok=True)
|
|
self.voices = installed_voices(engines.voices)
|
|
if self.voices and engines.operator_voice not in self.voices:
|
|
raise ToolError(f"the operator voice {engines.operator_voice!r} is not installed in {engines.voices}")
|
|
self.operator = engines.operator_voice if self.voices else ""
|
|
self.pool = [voice for voice in self.voices if voice != self.operator]
|
|
self.speaking = threading.Lock()
|
|
self.hearing = threading.Lock()
|
|
|
|
def env(self) -> dict[str, str]:
|
|
scratch = str(self.engines.scratch)
|
|
return {"PATH": SAFE_PATH, "HOME": scratch, "TMPDIR": scratch, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"}
|
|
|
|
def output_gap(self) -> str:
|
|
"""Returns:
|
|
"" when speech output works, otherwise why not, naming the Piper
|
|
executable, the voice directory or a voice config with its path.
|
|
"""
|
|
folder = self.engines.voices
|
|
gap = unreachable(self.engines.piper, "the Piper executable", os.X_OK, "executable") or unreachable(folder, "the voice directory", os.R_OK | os.X_OK, "searchable")
|
|
if gap or self.voices:
|
|
return gap
|
|
models = voice_models(folder)
|
|
if not models:
|
|
return f"the voice directory {folder} holds no <model>.onnx voice"
|
|
return f"the voice config {models[0]}.json is missing, unreadable or names no 1 to {SPEAKER_LIMIT} speakers"
|
|
|
|
def input_gap(self) -> str:
|
|
"""Returns:
|
|
"" when speech input works, otherwise why not, naming the
|
|
whisper.cpp executable or model with its path.
|
|
"""
|
|
model = self.engines.whisper_model
|
|
gap = unreachable(self.engines.whisper, "the whisper.cpp executable", os.X_OK, "executable") or unreachable(model, "the whisper.cpp model", os.R_OK, "readable")
|
|
return gap or ("" if os.path.isfile(model) else f"the whisper.cpp model {model} is not a file")
|
|
|
|
def voice_of(self, key: str) -> str:
|
|
return voice_of(key, self.pool, self.operator)
|
|
|
|
def describe(self) -> dict:
|
|
operator = self.voice_of(HUMAN)
|
|
sample = phrase("broadcast", HUMAN, [], "radio check")
|
|
silent, deaf = self.output_gap(), self.input_gap()
|
|
return {
|
|
"tts": not silent, "stt": not deaf, "tts_off": silent, "stt_off": deaf, "voices": len(self.voices), "operator": operator, "sample": sample,
|
|
"limit": SPEECH_LIMIT, "rate": STT_RATE, "seconds": STT_SECONDS,
|
|
}
|
|
|
|
def synthesize(self, body: bytes) -> bytes:
|
|
gap = self.output_gap()
|
|
if gap:
|
|
raise Unavailable(f"speech output is off: {gap}")
|
|
payload = decode(body)
|
|
expect(payload, ("text", "voice"))
|
|
voice = payload["voice"]
|
|
if not isinstance(voice, str) or voice not in self.voices:
|
|
raise ToolError("voice must be one of the installed voices")
|
|
line = " ".join(bounded(payload, "text", SPEECH_LIMIT).split())
|
|
if not SPOKEN_LINE.fullmatch(line):
|
|
raise ToolError(f"text must be 1 to {SPEECH_LIMIT} printable characters")
|
|
key = digest(f"{voice}\n{line}")
|
|
target = self.cache / f"{key}.wav"
|
|
with self.speaking:
|
|
if not target.is_file():
|
|
self.render(voice, line, target)
|
|
self.trim()
|
|
os.utime(target)
|
|
return target.read_bytes()
|
|
|
|
def render(self, voice: str, line: str, target: Path) -> None:
|
|
model, speaker = self.voices[voice]
|
|
onnx = self.engines.voices / f"{model}.onnx"
|
|
partial = self.cache / f".{target.stem}-{secrets.token_hex(4)}.wav"
|
|
command = [str(self.engines.piper), "--model", str(onnx), "--config", f"{onnx}.json", "--output-file", str(partial)]
|
|
command += [] if speaker is None else ["--speaker", str(speaker)]
|
|
try:
|
|
done = subprocess.run(
|
|
command,
|
|
input=f"{line}\n",
|
|
capture_output=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=TTS_TIMEOUT,
|
|
env=self.env(),
|
|
cwd=self.engines.scratch,
|
|
check=False,
|
|
)
|
|
if done.returncode != 0 or not playable(partial):
|
|
raise EngineError(f"piper failed with exit code {done.returncode}: {done.stderr.strip()[-300:]}")
|
|
os.replace(partial, target)
|
|
except subprocess.TimeoutExpired:
|
|
raise EngineError(f"piper took longer than {TTS_TIMEOUT} seconds") from None
|
|
except OSError as error:
|
|
raise EngineError(f"piper did not start: {error.strerror}") from None
|
|
finally:
|
|
partial.unlink(missing_ok=True)
|
|
|
|
def trim(self) -> None:
|
|
kept = sorted(self.cache.glob("*.wav"), key=lambda path: path.stat().st_mtime, reverse=True)
|
|
for path in kept[TTS_KEEP:]:
|
|
path.unlink(missing_ok=True)
|
|
|
|
def transcribe(self, body: bytes) -> dict:
|
|
gap = self.input_gap()
|
|
if gap:
|
|
raise Unavailable(f"speech input is off: {gap}")
|
|
wav_seconds(body)
|
|
command = [str(self.engines.whisper), "--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"]
|
|
with self.hearing:
|
|
try:
|
|
done = subprocess.run(command, input=body, capture_output=True, timeout=STT_TIMEOUT, env=self.env(), cwd=self.engines.scratch, check=False)
|
|
except subprocess.TimeoutExpired:
|
|
raise EngineError(f"whisper.cpp took longer than {STT_TIMEOUT} seconds") from None
|
|
except OSError as error:
|
|
raise EngineError(f"whisper.cpp did not start: {error.strerror}") from None
|
|
if done.returncode != 0:
|
|
raise EngineError(f"whisper.cpp failed with exit code {done.returncode}: {done.stderr.decode('utf-8', errors='replace').strip()[-300:]}")
|
|
return {"text": heard(done.stdout.decode("utf-8", errors="replace"))}
|