fix(hub): give the voice and whisper directories the search bit

ADD --chmod=0644 also set mode 0644 on /opt/voices and /opt/whisper, so the non-root hub found no voice and no whisper.cpp and the radio stayed silent. The settings page and the 503 replies now name the missing piece.

🎯 Fix: 90 %
💥 Break: 3 %
💸 Cost: 98 %
⏱️ Time: 96 %
🛡️ Security: 100 %
🏆 Score: 93
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 18:46:40 +00:00
1 parent c69a496c86
commit 249265cf1b
6 files changed
+117 -21

No files matched your search

+2
View File
@@ -31,6 +31,8 @@ ADD --checksum=sha256:c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567
https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base-q8_0.bin \ https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base-q8_0.bin \
/opt/whisper/ggml-base-q8_0.bin /opt/whisper/ggml-base-q8_0.bin
COPY --from=whisper /out/whisper-cli /opt/whisper/whisper-cli COPY --from=whisper /out/whisper-cli /opt/whisper/whisper-cli
# ADD --chmod=0644 also gives the directories it creates mode 0644; without the search bit the non-root hub finds no voice and no whisper.cpp
RUN chmod 0755 /opt/voices /opt/whisper
COPY server.py /app/server.py COPY server.py /app/server.py
COPY kvb_hub /app/kvb_hub COPY kvb_hub /app/kvb_hub
+59 -14
View File
@@ -28,6 +28,13 @@ STT_TIMEOUT = 120
NOISE = re.compile(r"\[[^\]\n]{0,40}\]") 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]]: def installed_voices(folder: Path) -> dict[str, tuple[str, int | None]]:
"""Args: """Args:
folder: directory holding <model>.onnx files with <model>.onnx.json. folder: directory holding <model>.onnx files with <model>.onnx.json.
@@ -37,12 +44,8 @@ def installed_voices(folder: Path) -> dict[str, tuple[str, int | None]]:
with one speaker), in model and speaker order; a model without a with one speaker), in model and speaker order; a model without a
readable config or with an unsafe name is left out. readable config or with an unsafe name is left out.
""" """
try:
models = sorted(folder.glob("*.onnx"))
except OSError:
return {}
found: dict[str, tuple[str, int | None]] = {} found: dict[str, tuple[str, int | None]] = {}
for model in models: for model in voice_models(folder):
data = read_json(model.with_name(f"{model.name}.json")) data = read_json(model.with_name(f"{model.name}.json"))
count = data.get("num_speakers") if isinstance(data, dict) else None 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: if not MODEL_NAME.fullmatch(model.stem) or isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= SPEAKER_LIMIT:
@@ -52,6 +55,25 @@ def installed_voices(folder: Path) -> dict[str, tuple[str, int | None]]:
return found 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: def voice_of(key: str, pool: list[str], operator: str) -> str:
"""Args: """Args:
key: the speaker's session id or directory key; HUMAN is the operator. key: the speaker's session id or directory key; HUMAN is the operator.
@@ -121,11 +143,28 @@ class Speech:
scratch = str(self.engines.scratch) 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"} return {"PATH": SAFE_PATH, "HOME": scratch, "TMPDIR": scratch, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"}
def speaks(self) -> bool: def output_gap(self) -> str:
return bool(self.voices) and os.access(self.engines.piper, os.X_OK) """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 hears(self) -> bool: def input_gap(self) -> str:
return os.access(self.engines.whisper, os.X_OK) and self.engines.whisper_model.is_file() """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: def voice_of(self, key: str) -> str:
return voice_of(key, self.pool, self.operator) return voice_of(key, self.pool, self.operator)
@@ -133,11 +172,16 @@ class Speech:
def describe(self) -> dict: def describe(self) -> dict:
operator = self.voice_of(HUMAN) operator = self.voice_of(HUMAN)
sample = phrase("broadcast", HUMAN, [], "radio check") sample = phrase("broadcast", HUMAN, [], "radio check")
return {"tts": self.speaks(), "stt": self.hears(), "voices": len(self.voices), "operator": operator, "sample": sample, "limit": SPEECH_LIMIT, "rate": STT_RATE, "seconds": STT_SECONDS} 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: def synthesize(self, body: bytes) -> bytes:
if not self.speaks(): gap = self.output_gap()
raise Unavailable("speech output is off: no Piper voice or executable is installed") if gap:
raise Unavailable(f"speech output is off: {gap}")
payload = decode(body) payload = decode(body)
expect(payload, ("text", "voice")) expect(payload, ("text", "voice"))
voice = payload["voice"] voice = payload["voice"]
@@ -189,8 +233,9 @@ class Speech:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
def transcribe(self, body: bytes) -> dict: def transcribe(self, body: bytes) -> dict:
if not self.hears(): gap = self.input_gap()
raise Unavailable("speech input is off: whisper.cpp or its model is missing") if gap:
raise Unavailable(f"speech input is off: {gap}")
wav_seconds(body) wav_seconds(body)
command = [str(self.engines.whisper), "--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"] command = [str(self.engines.whisper), "--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"]
with self.hearing: with self.hearing:
+1 -1
View File
@@ -103,7 +103,7 @@
h("section", {}, h("h3", {}, "Funk vorlesen"), h("section", {}, h("h3", {}, "Funk vorlesen"),
h("p", {class: "muted"}, state.speech.tts h("p", {class: "muted"}, state.speech.tts
? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen." ? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen."
: "Piper oder seine Stimmen fehlen im Image; der Funk bleibt stumm."), : `Der Funk bleibt stumm: ${state.speech.tts_off}.`),
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Funk vorlesen"), sample), h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Funk vorlesen"), sample),
h("div", {class: "days", role: "group", "aria-label": "Klassen"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))), h("div", {class: "days", role: "group", "aria-label": "Klassen"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Stationen"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null), stations.length ? h("div", {class: "days", role: "group", "aria-label": "Stationen"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
+1 -1
View File
@@ -63,7 +63,7 @@
const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙"); const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙");
const reason = !window.isSecureContext || !navigator.mediaDevices const reason = !window.isSecureContext || !navigator.mediaDevices
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost" ? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? "Spracheingabe ist aus: whisper.cpp oder sein Modell fehlt im Image" : ""; : !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
if (reason) { if (reason) {
button.disabled = true; button.disabled = true;
button.title = reason; button.title = reason;
+53 -4
View File
@@ -5,10 +5,12 @@ import hashlib
import io import io
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import unittest import unittest
import wave import wave
from pathlib import PurePosixPath
from unittest import mock from unittest import mock
from hub_case import HubCase from hub_case import HubCase
@@ -36,9 +38,41 @@ class TestHubSpeech(HubCase):
with self.assertRaises(server.ToolError): with self.assertRaises(server.ToolError):
engines.Speech(dataclasses.replace(self.engines, operator_voice="en_US-test-high:4"), self.state / "other") engines.Speech(dataclasses.replace(self.engines, operator_voice="en_US-test-high:4"), self.state / "other")
silent = engines.Speech(dataclasses.replace(self.engines, voices=self.outside, operator_voice="anything"), self.state / "silent") silent = engines.Speech(dataclasses.replace(self.engines, voices=self.outside, operator_voice="anything"), self.state / "silent")
self.assertEqual((silent.speaks(), silent.voice_of("s1"), silent.describe()["operator"], silent.describe()["tts"]), (False, "", "", False)) self.assertEqual((silent.voice_of("s1"), silent.describe()["operator"], silent.describe()["tts"]), ("", "", False))
deaf = engines.Speech(dataclasses.replace(self.engines, whisper=self.outside / "missing"), self.state / "deaf") deaf = engines.Speech(dataclasses.replace(self.engines, whisper=self.outside / "missing"), self.state / "deaf")
self.assertFalse(deaf.hears()) self.assertFalse(deaf.describe()["stt"])
@unittest.skipIf(os.geteuid() == 0, "root searches every directory")
def test_the_off_state_names_the_missing_part_with_its_path(self):
self.assertEqual({key: self.httpd.hub.speech.describe()[key] for key in ("tts", "stt", "tts_off", "stt_off")}, {"tts": True, "stt": True, "tts_off": "", "stt_off": ""})
lone = self.outside / "lone"
lone.mkdir()
(lone / "solo.onnx").write_bytes(b"onnx")
cases = (
({"piper": self.outside / "piper"}, "tts_off", f"the Piper executable {self.outside / 'piper'} is missing or not executable"),
({"voices": self.outside}, "tts_off", f"the voice directory {self.outside} holds no <model>.onnx voice"),
({"voices": lone}, "tts_off", f"the voice config {lone / 'solo.onnx'}.json is missing, unreadable or names no 1 to {engines.SPEAKER_LIMIT} speakers"),
({"whisper": self.outside / "whisper-cli"}, "stt_off", f"the whisper.cpp executable {self.outside / 'whisper-cli'} is missing or not executable"),
({"whisper_model": self.outside / "model.bin"}, "stt_off", f"the whisper.cpp model {self.outside / 'model.bin'} is missing or not readable"),
({"whisper_model": self.outside}, "stt_off", f"the whisper.cpp model {self.outside} is not a file"),
)
for number, (change, field, message) in enumerate(cases):
with self.subTest(change=change):
state = engines.Speech(dataclasses.replace(self.engines, **change), self.state / f"case-{number}").describe()
self.assertEqual((state[field[:3]], state[field]), (False, message))
voices = self.engines.voices
whisper = self.outside / "whisper" / "whisper-cli"
whisper.parent.mkdir()
whisper.write_bytes(self.engines.whisper.read_bytes())
whisper.chmod(0o755)
for folder in (voices, whisper.parent):
folder.chmod(0o644)
self.addCleanup(folder.chmod, 0o755)
image = engines.Speech(dataclasses.replace(self.engines, whisper=whisper), self.state / "image").describe()
self.assertEqual({key: image[key] for key in ("tts", "voices", "operator", "tts_off")}, {"tts": False, "voices": 0, "operator": "", "tts_off": f"the voice directory {voices} is missing or not searchable"})
self.assertEqual((image["stt"], image["stt_off"]), (False, f"the whisper.cpp executable {whisper} lies under {whisper.parent}, which the hub may not search"))
status, _, body = self.post_json("/api/tts", {"text": "radio check", "voice": OPERATOR_VOICE}, self.guard(self.host))
self.assertEqual((status, body.decode()), (503, f"speech output is off: the voice directory {voices} is missing or not searchable\n"))
def test_speech_output_is_guarded_whitelisted_capped_and_cached(self): def test_speech_output_is_guarded_whitelisted_capped_and_cached(self):
good = self.guard(self.host) good = self.guard(self.host)
@@ -132,8 +166,8 @@ class TestHubSpeech(HubCase):
self.assertEqual(post(long, good)[0], 200) self.assertEqual(post(long, good)[0], 200)
status, _, body = post(wav_bytes(1, 2, engines.STT_RATE, b"FAIL" * 100), good) status, _, body = post(wav_bytes(1, 2, engines.STT_RATE, b"FAIL" * 100), good)
self.assertEqual(status, 502, body) self.assertEqual(status, 502, body)
with mock.patch.object(self.httpd.hub.speech, "hears", return_value=False): with mock.patch.object(self.httpd.hub.speech, "input_gap", return_value="the whisper.cpp model m is missing or not readable"):
self.assertEqual(post(clip, good)[0], 503) self.assertEqual(post(clip, good), (503, mock.ANY, b"speech input is off: the whisper.cpp model m is missing or not readable\n"))
self.assertEqual(self.scripts.calls, []) self.assertEqual(self.scripts.calls, [])
self.assertEqual(self.get("/api/stt")[0], 405) self.assertEqual(self.get("/api/stt")[0], 405)
@@ -160,6 +194,21 @@ class TestHubSpeech(HubCase):
self.assertIn("the operator voice 'en_US-test-high:9' is not installed", done.stderr) self.assertIn("the operator voice 'en_US-test-high:9' is not installed", done.stderr)
class TestSpeechImage(unittest.TestCase):
def test_every_directory_a_chmod_without_search_bit_creates_is_opened_again(self):
text = (HUB / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ")
steps = [" ".join(step.split()) for step in text.splitlines() if step.strip() and not step.lstrip().startswith("#")]
closed: dict[str, int] = {}
for number, step in enumerate(steps):
found = re.fullmatch(r"(?:ADD|COPY) .*--chmod=([0-7]{3,4}) .* (/\S+)", step)
if found and not int(found[1], 8) & 0o111:
closed[str(PurePosixPath(found[2]).parent)] = number
self.assertEqual(sorted(closed), ["/opt/voices", "/opt/whisper"])
for folder, number in closed.items():
with self.subTest(folder=folder):
self.assertTrue(any(step.startswith("RUN chmod 0755 ") and folder in step.split()[3:] for step in steps[number + 1 :]), steps[number])
class TestPhraseology(unittest.TestCase): class TestPhraseology(unittest.TestCase):
def test_every_class_speaks_in_maritime_phraseology(self): def test_every_class_speaks_in_maritime_phraseology(self):
cases = ( cases = (
+1 -1
View File
@@ -163,7 +163,7 @@ class TestHubState(HubCase):
self.assertEqual([row["voice"] for row in state["radio"]], [speech.voice_of(row["source"]) for row in state["radio"]]) self.assertEqual([row["voice"] for row in state["radio"]], [speech.voice_of(row["source"]) for row in state["radio"]])
self.assertTrue(all(row["voice"] in VOICES and row["voice"] != OPERATOR_VOICE for row in state["radio"])) self.assertTrue(all(row["voice"] in VOICES and row["voice"] != OPERATOR_VOICE for row in state["radio"]))
self.assertEqual(state["speech"], { self.assertEqual(state["speech"], {
"tts": True, "stt": True, "voices": len(VOICES), "operator": OPERATOR_VOICE, "sample": "all stations, this is Operator, radio check, out", "tts": True, "stt": True, "tts_off": "", "stt_off": "", "voices": len(VOICES), "operator": OPERATOR_VOICE, "sample": "all stations, this is Operator, radio check, out",
"limit": phrasing.SPEECH_LIMIT, "rate": engines.STT_RATE, "seconds": engines.STT_SECONDS, "limit": phrasing.SPEECH_LIMIT, "rate": engines.STT_RATE, "seconds": engines.STT_SECONDS,
}) })
self.assertAlmostEqual(state["now"] - state["since"], view.RADIO_WINDOW) self.assertAlmostEqual(state["now"] - state["since"], view.RADIO_WINDOW)