diff --git a/hub/Dockerfile b/hub/Dockerfile index 3e892ed..5ee898a 100644 --- a/hub/Dockerfile +++ b/hub/Dockerfile @@ -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 diff --git a/hub/kvb_hub/engines.py b/hub/kvb_hub/engines.py index c766f79..3eec8d6 100644 --- a/hub/kvb_hub/engines.py +++ b/hub/kvb_hub/engines.py @@ -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 .onnx files with .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 .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: diff --git a/hub/templates/page-settings.js b/hub/templates/page-settings.js index 2c26198..6740400 100644 --- a/hub/templates/page-settings.js +++ b/hub/templates/page-settings.js @@ -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), diff --git a/hub/templates/page-speech.js b/hub/templates/page-speech.js index e034afe..fae057b 100644 --- a/hub/templates/page-speech.js +++ b/hub/templates/page-speech.js @@ -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; diff --git a/tests/test_hub_speech.py b/tests/test_hub_speech.py index 61b0192..a1a352c 100644 --- a/tests/test_hub_speech.py +++ b/tests/test_hub_speech.py @@ -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 .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 = ( diff --git a/tests/test_hub_state.py b/tests/test_hub_state.py index be750f3..6f559ef 100644 --- a/tests/test_hub_state.py +++ b/tests/test_hub_state.py @@ -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)