diff --git a/dikte/ggml.py b/dikte/ggml.py index 00e9929..fd863b9 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -284,6 +284,17 @@ def _wanted_assets(program): return (f"bin-ubuntu-{arch}.tar.gz",) +def _managed_whisper(program, tag=""): + """Whether this machine is one Dikte publishes its own whisper-server for. + + Linux x86_64 with a Vulkan loader on it, and no version asked for by hand: + a pinned version is upstream's to answer. + """ + return (not tag and program is WHISPER and sys.platform == "linux" + and platform.machine().lower() in ("x86_64", "amd64") + and _has_vulkan()) + + def _install_record(program): return BIN_DIR / program.name / "installed.json" @@ -307,6 +318,21 @@ def installed_version(program): return _read_record(program).get("tag") or "" +def vulkan_missing(program): + """Whether what Dikte installed is the processor build on a machine the + Vulkan one was fetched for. + + The Vulkan whisper-server is a release of Dikte's own, published by hand + once the reviewed archive is built, and the install falls back to the + upstream processor build whenever that release, the file in it, or its + reviewed digest is not there. Nothing is wrong with the fallback except + that it is invisible: a graphics card sitting idle looks exactly like a + graphics card being used. + """ + return (bool(installed_program(program)) + and _read_record(program).get("backend") == "processor") + + def program_path(program, custom=""): """Which copy of the program to run, or "" when there is none. @@ -382,10 +408,8 @@ def install_program(program, tag="", on_progress=None, should_stop=None, """ repo = program.repo release_tag = tag or "latest" - managed = (not tag and program is WHISPER and sys.platform == "linux" - and platform.machine().lower() in ("x86_64", "amd64") - and _has_vulkan()) - item = None + managed = _managed_whisper(program, tag) + item, vulkan = None, False if managed: repo = DIKTE_REPO release_tag = MANAGED_WHISPER_RELEASE @@ -398,6 +422,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None, item = next((a for a in assets if a.name.endswith(MANAGED_WHISPER_VULKAN) and a.sha256 == MANAGED_WHISPER_SHA256), None) + vulkan = item is not None if item: tag = MANAGED_WHISPER_VERSION @@ -476,8 +501,13 @@ def install_program(program, tag="", on_progress=None, should_stop=None, # Found under the sibling, run from the final directory. binary = into / binary.relative_to(fresh) # Written last, so the record never points at anything half-made. - _install_record(program).write_text( - json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8") + record = {"tag": tag, "binary": str(binary)} + if managed: + # Which of the two builds this machine ended up with. Only written + # where both were on offer, so an install that never had the + # choice is not made to look like a fallback. + record["backend"] = "vulkan" if vulkan else "processor" + _install_record(program).write_text(json.dumps(record), encoding="utf-8") except OSError as exc: raise LocalError(t("Could not install {name}: {error}", name=program.name, error=exc)) from exc diff --git a/dikte/i18n.py b/dikte/i18n.py index 23ff6ff..fc7476e 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -780,6 +780,9 @@ TR = { "Not installed.": "Kurulu değil.", "Installed on the system: {path}": "Sistemde kurulu: {path}", "Downloaded, version {version}.": "İndirildi, sürüm {version}.", + "Downloaded, version {version}. There was no Vulkan build, " + "so this one runs on the processor.": + "İndirildi, sürüm {version}. Vulkan sürümü yoktu, bu sürüm işlemcide çalışıyor.", "Fetching the model list…": "Model listesi çekiliyor…", "Downloading…": "İndiriliyor…", "Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}", diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 56771f8..c3155da 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -358,6 +358,13 @@ class LocalModelBox(QGroupBox): # for this machine and may reach the graphics card, while the # released binaries carry processor backends only. self.program_label.setText(t("Installed on the system: {path}", path=path)) + elif ggml.vulkan_missing(self.program): + # The download landed the processor build where the graphics card + # one belongs, and nothing else on this window would say so. + self.program_label.setText( + t("Downloaded, version {version}. There was no Vulkan build, " + "so this one runs on the processor.", + version=ggml.installed_version(self.program) or "?")) else: self.program_label.setText( t("Downloaded, version {version}.", diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 184f6fc..8dcfa0c 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -259,6 +259,7 @@ class InstallProgram(Local): "whisper-bin-ubuntu-vulkan-x64.tar.gz")) self.assertTrue(os.path.isfile(path)) self.assertEqual("v1.9.3", ggml.installed_version(ggml.WHISPER)) + self.assertFalse(ggml.vulkan_missing(ggml.WHISPER)) def test_an_explicit_whisper_version_still_comes_from_upstream(self): self.patch_attr(ggml, "_arch", lambda: "x64") @@ -339,6 +340,24 @@ class InstallProgram(Local): "whisper-bin-ubuntu-x64.tar.gz")) self.assertTrue(os.path.isfile(path)) + def test_a_fallback_to_the_processor_build_is_there_to_be_shown(self): + """Until the Vulkan package is published every download lands the + processor build, and a graphics card sitting idle looks exactly like + one being used. The window asks this and says so.""" + self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + managed = self.release("Dikte-1.1.0-x86_64.AppImage") + managed["tag_name"] = "whisper.cpp-v1.9.3" + upstream = self.release("whisper-bin-ubuntu-x64.tar.gz") + with fake_urlopen(managed, upstream, body(self.archive)): + ggml.install_program(ggml.WHISPER) + self.assertTrue(ggml.vulkan_missing(ggml.WHISPER)) + + def test_a_machine_with_no_vulkan_is_not_told_it_is_missing_one(self): + # Nothing was on offer to fall back from, so there is nothing to say. + self.install("whisper-bin-ubuntu-x64.tar.gz") + self.assertFalse(ggml.vulkan_missing(ggml.WHISPER)) + def test_a_release_with_nothing_for_this_machine_says_so(self): self.patch_attr(ggml, "_arch", lambda: "x64") with fake_urlopen(self.release("whisper-bin-Win32.zip")): diff --git a/tests/test_ui.py b/tests/test_ui.py index 6273447..e147068 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -6,6 +6,7 @@ save, so a setting added to one half and not the other is silently reset the next time anybody presses Save. That is the failure this catches. """ +import json import os import sys import unittest @@ -1214,6 +1215,34 @@ class LocalModels(DikteTest): for row in range(box.repo.count())) self.assertGreaterEqual(view.minimumWidth(), widest) + def test_a_processor_build_where_the_vulkan_one_belongs_says_so(self): + # The Vulkan whisper-server is published by hand, and until it is + # there the download lands upstream's processor build. Said nowhere, + # an idle graphics card looks exactly like one that is being used. + binary = self.path("bin/whisper/v1.9.3/whisper-server") + binary.parent.mkdir(parents=True) + binary.write_text("") + binary.chmod(0o755) + self.path("bin/whisper/installed.json").write_text(json.dumps( + {"tag": "v1.9.3", "binary": str(binary), "backend": "processor"})) + # A whisper-server on this machine's PATH would win over the download. + self.patch_attr(ggml.shutil, "which", lambda name: None) + label = self.window(cfg.Config()).local_whisper.program_label.text() + self.assertIn("v1.9.3", label) + self.assertIn("Vulkan", label) + + def test_an_ordinary_install_is_reported_without_a_word_about_vulkan(self): + binary = self.path("bin/whisper/v1.9.3/whisper-server") + binary.parent.mkdir(parents=True) + binary.write_text("") + binary.chmod(0o755) + self.path("bin/whisper/installed.json").write_text(json.dumps( + {"tag": "v1.9.3", "binary": str(binary)})) + self.patch_attr(ggml.shutil, "which", lambda name: None) + label = self.window(cfg.Config()).local_whisper.program_label.text() + self.assertIn("v1.9.3", label) + self.assertNotIn("Vulkan", label) + def test_only_the_chosen_transcriber_is_on_screen(self): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))