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:
1 parent
c69a496c86
commit
249265cf1b
6 files changed
+117
-21
No files matched your search
@@ -31,6 +31,8 @@ ADD --checksum=sha256:c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567
|
||||
https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base-q8_0.bin \
|
||||
/opt/whisper/ggml-base-q8_0.bin
|
||||
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 kvb_hub /app/kvb_hub
|
||||
|
||||
+59
-14
@@ -28,6 +28,13 @@ 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.
|
||||
@@ -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
|
||||
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]] = {}
|
||||
for model in models:
|
||||
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:
|
||||
@@ -52,6 +55,25 @@ def installed_voices(folder: Path) -> dict[str, tuple[str, int | 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.
|
||||
@@ -121,11 +143,28 @@ class Speech:
|
||||
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 speaks(self) -> bool:
|
||||
return bool(self.voices) and os.access(self.engines.piper, os.X_OK)
|
||||
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 hears(self) -> bool:
|
||||
return os.access(self.engines.whisper, os.X_OK) and self.engines.whisper_model.is_file()
|
||||
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)
|
||||
@@ -133,11 +172,16 @@ class Speech:
|
||||
def describe(self) -> dict:
|
||||
operator = self.voice_of(HUMAN)
|
||||
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:
|
||||
if not self.speaks():
|
||||
raise Unavailable("speech output is off: no Piper voice or executable is installed")
|
||||
gap = self.output_gap()
|
||||
if gap:
|
||||
raise Unavailable(f"speech output is off: {gap}")
|
||||
payload = decode(body)
|
||||
expect(payload, ("text", "voice"))
|
||||
voice = payload["voice"]
|
||||
@@ -189,8 +233,9 @@ class Speech:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def transcribe(self, body: bytes) -> dict:
|
||||
if not self.hears():
|
||||
raise Unavailable("speech input is off: whisper.cpp or its model is missing")
|
||||
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:
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
h("section", {}, h("h3", {}, "Funk vorlesen"),
|
||||
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."
|
||||
: "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", 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),
|
||||
|
||||
@@ -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 reason = !window.isSecureContext || !navigator.mediaDevices
|
||||
? "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) {
|
||||
button.disabled = true;
|
||||
button.title = reason;
|
||||
|
||||
@@ -5,10 +5,12 @@ import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
import wave
|
||||
from pathlib import PurePosixPath
|
||||
from unittest import mock
|
||||
|
||||
from hub_case import HubCase
|
||||
@@ -36,9 +38,41 @@ class TestHubSpeech(HubCase):
|
||||
with self.assertRaises(server.ToolError):
|
||||
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")
|
||||
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")
|
||||
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):
|
||||
good = self.guard(self.host)
|
||||
@@ -132,8 +166,8 @@ class TestHubSpeech(HubCase):
|
||||
self.assertEqual(post(long, good)[0], 200)
|
||||
status, _, body = post(wav_bytes(1, 2, engines.STT_RATE, b"FAIL" * 100), good)
|
||||
self.assertEqual(status, 502, body)
|
||||
with mock.patch.object(self.httpd.hub.speech, "hears", return_value=False):
|
||||
self.assertEqual(post(clip, good)[0], 503)
|
||||
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), (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.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)
|
||||
|
||||
|
||||
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):
|
||||
def test_every_class_speaks_in_maritime_phraseology(self):
|
||||
cases = (
|
||||
|
||||
@@ -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.assertTrue(all(row["voice"] in VOICES and row["voice"] != OPERATOR_VOICE for row in state["radio"]))
|
||||
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,
|
||||
})
|
||||
self.assertAlmostEqual(state["now"] - state["since"], view.RADIO_WINDOW)
|
||||
|
||||
Reference in new issue
Block a user