mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa5343baa | ||
|
|
24a27b434f | ||
|
|
f79039c89d | ||
|
|
1b3742c01e | ||
|
|
6b4e590a12 | ||
|
|
b06a1cd4d1 | ||
|
|
072812b6df | ||
|
|
5381631034 | ||
|
|
afa53934c2 | ||
|
|
a6c0710a3f | ||
|
|
665902b546 | ||
|
|
4f2b2a91d4 | ||
|
|
ef6bb68251 | ||
|
|
71a2e08fa8 | ||
|
|
34f545e8ac | ||
|
|
1c086199c1 | ||
|
|
b46181e001 | ||
|
|
e56515d032 | ||
|
|
840e70463a | ||
|
|
6d7c591b7d | ||
|
|
21e28f621e | ||
|
|
cd419558fd | ||
|
|
b30ee55241 | ||
|
|
e58f924579 | ||
|
|
90ae1690ab | ||
|
|
245f00125e | ||
|
|
c5a7fb2410 | ||
|
|
7da871c567 | ||
|
|
1bb5c9ebbc | ||
|
|
f49e5ef6c0 | ||
|
|
9b03da4175 | ||
|
|
bcd6b81d23 | ||
|
|
2be50cd72d | ||
|
|
f67220721b |
@@ -220,6 +220,11 @@ running.
|
||||
written for subtitles, so the lines keep their place and nothing is shortened.
|
||||
- **History** of every dictation under Settings → History, with a size limit and
|
||||
right-click to delete.
|
||||
- **The speech language is detected, not picked.** Auto is the default: whisper
|
||||
on this machine says what it heard, the hosted providers transcribe in
|
||||
whatever language comes in without being told, and the detected language
|
||||
lands in the history and decides which cleanup prompt (Turkish or the
|
||||
language-agnostic one) a run gets. A fixed language still overrides it.
|
||||
- **Turkish and English interface**, following the system locale by default.
|
||||
|
||||
## The global shortcuts, and the logout KDE needs
|
||||
|
||||
@@ -214,6 +214,11 @@ olmasını ister.
|
||||
yerinde kalır, hiçbir şey kısaltılmaz.
|
||||
- **Geçmiş** Ayarlar → Geçmiş sekmesinde; boyut sınırı var, sağ tıklayıp
|
||||
silebilirsin.
|
||||
- **Konuşma dili seçilmez, algılanır.** Varsayılan otomatiktir: bu makinedeki
|
||||
whisper ne duyduğunu söyler, bulut sağlayıcılar söylenmeden de hangi dilde
|
||||
konuşuluyorsa o dilde yazar; algılanan dil geçmişe düşer ve bir kaydın hangi
|
||||
temizleme promptunu alacağını belirler (Türkçe mi, dile duyarsız olanı mı).
|
||||
Sabit bir dil yine de bunun önüne geçer.
|
||||
- **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler.
|
||||
|
||||
## Global kısayollar ve KDE'nin istediği oturum kapatma
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ business loading Qt to answer one question.
|
||||
# both the .dmg's Info.plist and the AppImage's file name are built from it. A
|
||||
# build off master rather than off a tag appends the commit to it, so that a
|
||||
# bug report from someone running "latest" names a commit.
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
+46
-1
@@ -367,7 +367,8 @@ def local_failure(service, server, exc):
|
||||
|
||||
|
||||
def _transcribe_request(target, audio_path, language, prompt, response_format,
|
||||
granularity=None, timeout=300, aborter=None):
|
||||
granularity=None, timeout=300, aborter=None,
|
||||
detect_language=False):
|
||||
if target.provider == "local":
|
||||
# The timeouts here are sized for a hosted API, where a slow answer is a
|
||||
# bill running. Locally the only thing being spent is time.
|
||||
@@ -379,6 +380,12 @@ def _transcribe_request(target, audio_path, language, prompt, response_format,
|
||||
fields = [("model", target.model), ("response_format", response_format)]
|
||||
if language and language != "auto":
|
||||
fields.append(("language", language))
|
||||
if detect_language:
|
||||
# whisper.cpp was started with -nlp, which keeps the language
|
||||
# probability sweep off every request. Detection is only worth that
|
||||
# sweep for the run that asked for it, so it is switched back on here,
|
||||
# per request, and reported in the verbose_json answer.
|
||||
fields.append(("no_language_probabilities", "false"))
|
||||
# OpenRouter takes the hint field and throws it away, so spare it the bytes.
|
||||
# The same words still reach the cleanup model as a glossary. whisper.cpp
|
||||
# takes it as the initial prompt, the way OpenAI does.
|
||||
@@ -546,6 +553,44 @@ def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=
|
||||
return text
|
||||
|
||||
|
||||
# whisper.cpp reports what it heard as a lowercase full name ("turkish",
|
||||
# "english", "german"…); the settings and the cleanup prompt speak in two-letter
|
||||
# codes. Only the handful Dikte offers as a fixed choice get a code; anything
|
||||
# else is left as the empty string, which the caller reads as "unknown" rather
|
||||
# than guessing at a language it has no label for.
|
||||
_DETECTED_TO_CODE = {
|
||||
"english": "en", "turkish": "tr", "german": "de",
|
||||
"french": "fr", "spanish": "es", "arabic": "ar",
|
||||
}
|
||||
|
||||
|
||||
def transcribe_detected(target, audio_path, language="", prompt="", timeout=300,
|
||||
aborter=None):
|
||||
"""(text, code) with the language the model heard.
|
||||
|
||||
The spoken language is only knowable when the transcription model reports
|
||||
it, and only whisper.cpp does: the hosted endpoints accept "auto" but never
|
||||
say what they heard. So detection is asked for exactly where it can be
|
||||
answered, the local server in auto mode, and every other run transcribes
|
||||
as before and hands back an empty code.
|
||||
"""
|
||||
if target.provider == "local" and language == "auto":
|
||||
data = _transcribe_request(
|
||||
target, audio_path, language, prompt, "verbose_json",
|
||||
detect_language=True, timeout=timeout, aborter=aborter,
|
||||
)
|
||||
text = _local_text(data.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ApiError(t("Transcript came back empty."))
|
||||
detected = data.get("detected_language")
|
||||
code = _DETECTED_TO_CODE.get(
|
||||
detected.strip().lower(), "") if isinstance(detected, str) else ""
|
||||
return text, code
|
||||
text = transcribe(target, audio_path, language=language, prompt=prompt,
|
||||
timeout=timeout, aborter=aborter)
|
||||
return text, ""
|
||||
|
||||
|
||||
def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
|
||||
aborter=None):
|
||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||
|
||||
+21
-4
@@ -630,6 +630,10 @@ class Dikte:
|
||||
"agent": assistant.display_name(self.conf),
|
||||
"provider": assistant.provider(self.conf),
|
||||
"listener": self.evdev.running,
|
||||
# Whether each model on this machine is loaded, and what it ended up
|
||||
# running on. Only this process knows: the servers are its children,
|
||||
# and the command line has no way to ask them anything.
|
||||
"local": self._local_state(),
|
||||
# Asked here rather than by the command line, because on macOS
|
||||
# there is no registry to read: a combination is held by this
|
||||
# process and by nothing else, so this is the only process that
|
||||
@@ -638,6 +642,17 @@ class Dikte:
|
||||
for name, spec in hotkey.SHORTCUTS.items()},
|
||||
}
|
||||
|
||||
def _local_state(self):
|
||||
"""ggml.state(), with a mark for the servers this setup actually uses.
|
||||
|
||||
A server that is neither wanted nor loaded is not worth a line anywhere;
|
||||
one that is wanted and not loaded is exactly the line worth reading.
|
||||
"""
|
||||
local = ggml.state()
|
||||
local["whisper"]["used"] = self.conf["transcribe_provider"] == "local"
|
||||
local["llama"]["used"] = self.conf.uses_local_llm()
|
||||
return local
|
||||
|
||||
def reload_settings(self):
|
||||
"""Read the config file back after something outside changed it."""
|
||||
self.conf.load()
|
||||
@@ -1084,7 +1099,7 @@ class Dikte:
|
||||
if not self._transcripts_pending:
|
||||
self._settle(DICTATION, payload)
|
||||
|
||||
def _on_finished(self, _raw, text, warning):
|
||||
def _on_finished(self, _raw, text, warning, speech_language):
|
||||
if warning:
|
||||
# The text was still pasted, but cleanup did not run. Say so loudly:
|
||||
# a rejected key otherwise looks exactly like working dictation.
|
||||
@@ -1105,9 +1120,10 @@ class Dikte:
|
||||
t("{action}: {preview}", action=action, preview=_preview(text))
|
||||
)
|
||||
self._transcript_settled({"ok": True, "text": text, "raw": _raw,
|
||||
"warning": warning})
|
||||
"warning": warning,
|
||||
"speech_language": speech_language})
|
||||
|
||||
def _on_ask_finished(self, _raw, text, warning):
|
||||
def _on_ask_finished(self, _raw, text, warning, speech_language):
|
||||
agent = assistant.display_name(self.conf)
|
||||
if warning:
|
||||
# A tool the agent was not allowed to touch otherwise looks exactly
|
||||
@@ -1128,7 +1144,8 @@ class Dikte:
|
||||
)
|
||||
self._set_ask_state(IDLE)
|
||||
self._settle(ASK, {"ok": True, "answer": text, "question": _raw,
|
||||
"warning": warning, "agent": agent})
|
||||
"warning": warning, "agent": agent,
|
||||
"speech_language": speech_language})
|
||||
|
||||
def _on_ask_cancelled(self):
|
||||
self.ask_overlay.show_done(t("Stopped."), 2000)
|
||||
|
||||
+103
-7
@@ -30,6 +30,7 @@ from . import audio
|
||||
from . import cleanup
|
||||
from . import config as cfg
|
||||
from . import filetranscribe
|
||||
from . import ggml
|
||||
from . import hotkey
|
||||
from . import hub
|
||||
from . import ipc
|
||||
@@ -836,6 +837,69 @@ def cmd_update(opts):
|
||||
f"{release.url}")
|
||||
|
||||
|
||||
# --- the models on this machine --------------------------------------------
|
||||
|
||||
|
||||
def _local_where(entry):
|
||||
"""Where a local model ran, in a phrase: the card, the processor, or neither.
|
||||
|
||||
The backend and the card keep the names the server printed for them. A
|
||||
graphics card is a product somebody sells under that name, and translating
|
||||
it would be inventing hardware.
|
||||
"""
|
||||
kind = ggml.accel_kind({**entry, "running": True})
|
||||
where = {"gpu": "the graphics card", "cpu": "the processor"}.get(
|
||||
kind, "something it did not name")
|
||||
detail = ggml.accel_detail(entry)
|
||||
return where + (f" ({detail})" if detail else "")
|
||||
|
||||
|
||||
def _local_note(entry):
|
||||
"""What the log establishes when GPU use was requested but unavailable."""
|
||||
if not entry.get("gpu_wanted"):
|
||||
return ""
|
||||
if ggml.accel_kind({**entry, "running": True}) != "cpu":
|
||||
return ""
|
||||
if not ggml.cpu_only_loaded(entry):
|
||||
return " - the graphics card is switched on but could not be used"
|
||||
return (" - only the CPU backend was loaded; check the server log for "
|
||||
"graphics backend or driver errors")
|
||||
|
||||
|
||||
def _local_line(name, entry):
|
||||
if not entry.get("running"):
|
||||
return "not loaded"
|
||||
model = entry.get("model") or ""
|
||||
return (f"loaded on {_local_where(entry)}"
|
||||
+ (f", {model}" if model else "") + _local_note(entry))
|
||||
|
||||
|
||||
def _last_local(conf):
|
||||
"""What the local servers last ran on, read off the logs they left behind.
|
||||
|
||||
For a command line asking while nothing is running: there is no process to
|
||||
put the question to, and the log outlives the process that wrote it. Every
|
||||
entry says `running` is false, because this is an account of the last start
|
||||
rather than a reading of a live one. The logs do not record the binary path
|
||||
or requested GPU setting, so current settings cannot explain that run.
|
||||
"""
|
||||
rows = {}
|
||||
for program, used in (
|
||||
(ggml.WHISPER, conf["transcribe_provider"] == "local"),
|
||||
(ggml.LLAMA, conf.uses_local_llm())):
|
||||
accel = ggml.last_accel(program)
|
||||
rows[program.name] = {
|
||||
# Whether one ever started here at all, which the backend cannot
|
||||
# say on its own: a server that ran and named no backend and one
|
||||
# that never ran both leave it empty.
|
||||
"ran": ggml.server_log(program).exists(),
|
||||
"running": False, "used": used,
|
||||
"backend": accel.backend, "device": accel.device,
|
||||
"layers": accel.layers, "available": list(accel.available),
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def cmd_status(opts):
|
||||
reply = ipc.send("status")
|
||||
if reply is None:
|
||||
@@ -855,6 +919,11 @@ def cmd_status(opts):
|
||||
+ (f" {reply['meeting_message']}" if reply.get("meeting_message") else ""),
|
||||
f"listener: {'on' if reply.get('listener') else 'off'}",
|
||||
]
|
||||
# Nothing for a setup that uses no model on this machine, and nothing at all
|
||||
# from an instance too old to have been asked.
|
||||
for name, entry in (reply.get("local") or {}).items():
|
||||
if entry.get("used") or entry.get("running"):
|
||||
lines.append(f"{name + ':':11}{_local_line(name, entry)}")
|
||||
return out(opts, reply, "\n".join(lines))
|
||||
|
||||
|
||||
@@ -867,13 +936,16 @@ def cmd_doctor(opts):
|
||||
# Mac shells out for one half and Windows for neither. A row saying ydotool
|
||||
# is missing on a machine that would never have run it is not a diagnosis,
|
||||
# it is a red mark to explain away.
|
||||
# Asked once and read twice: whether an instance is running, and what its
|
||||
# local servers are doing, which is a question only that process can answer.
|
||||
live = ipc.send("status") or {}
|
||||
here = paste.desktop()
|
||||
wanted = [here.clipboard, here.keyboard]
|
||||
if sys.platform.startswith("linux"):
|
||||
# Recording, the device list, and KDE's shortcut registry.
|
||||
wanted += ["pw-record", "pactl", "kwriteconfig6"]
|
||||
wanted += ["ffmpeg",
|
||||
assistant.executable(assistant.provider(conf)) or "claude",
|
||||
assistant.executable(assistant.provider(conf)),
|
||||
cleanup.executable(cleanup.provider(conf))]
|
||||
programs = {name: shutil.which(name) or "" for name in wanted if name}
|
||||
target = conf.transcribe_target()
|
||||
@@ -908,8 +980,14 @@ def cmd_doctor(opts):
|
||||
"ready": cleanup_ready},
|
||||
"agent": {"provider": assistant.provider(conf),
|
||||
"directory": assistant.working_dir(conf)},
|
||||
"running": ipc.send("status") is not None,
|
||||
"running": bool(live),
|
||||
# Live when there is an instance to ask, off the logs when there is not.
|
||||
"local": live.get("local") or _last_local(conf),
|
||||
}
|
||||
# An instance from before this field existed is not an instance saying
|
||||
# nothing is loaded; it is one that cannot be asked, and the two must not
|
||||
# print the same line.
|
||||
stale = bool(live) and "local" not in live
|
||||
if target.provider == "local":
|
||||
transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service}, "
|
||||
f"transcribing on {target.model or 'no model yet'}")
|
||||
@@ -929,12 +1007,30 @@ def cmd_doctor(opts):
|
||||
f"{cleanup.model(conf)}")
|
||||
lines = [f"{'✓' if path else '✗'} {name:14} {path or 'not on your PATH'}"
|
||||
for name, path in programs.items()]
|
||||
lines += [
|
||||
transcribe_line,
|
||||
cleanup_line,
|
||||
lines += [transcribe_line, cleanup_line]
|
||||
# Only the models this setup actually uses: a machine transcribing in the
|
||||
# cloud has nothing loaded here and no reason to read about it.
|
||||
for name, entry in checks["local"].items():
|
||||
if not entry.get("used"):
|
||||
continue
|
||||
if stale:
|
||||
lines.append(f"· {name:14} the running instance is too old to say; "
|
||||
f"reload it with: dikte restart")
|
||||
elif entry.get("running"):
|
||||
lines.append(f"✓ {name:14} {_local_line(name, entry)}")
|
||||
elif live:
|
||||
lines.append(f"· {name:14} not loaded")
|
||||
elif entry.get("backend"):
|
||||
lines.append(f"· {name:14} last run on "
|
||||
f"{_local_where(entry)}")
|
||||
elif entry.get("ran"):
|
||||
lines.append(f"· {name:14} last run said nothing about what it "
|
||||
f"was running on")
|
||||
else:
|
||||
lines.append(f"· {name:14} never run here")
|
||||
lines.append(
|
||||
f"{'✓' if checks['running'] else '·'} application "
|
||||
+ ("running" if checks["running"] else "not running"),
|
||||
]
|
||||
+ ("running" if checks["running"] else "not running"))
|
||||
return out(opts, {"ok": True, **checks}, "\n".join(lines))
|
||||
|
||||
|
||||
|
||||
+17
-5
@@ -438,7 +438,9 @@ DEFAULTS = {
|
||||
# What a timestamped run (subtitles) asks OpenRouter for: not every model
|
||||
# there returns segment times. Empty -> openai/whisper-1.
|
||||
"openrouter_file_model": "",
|
||||
"language": "tr",
|
||||
# A stored language overrides this default. Hosted providers receive no
|
||||
# language hint in auto mode; local whisper also reports the detected code.
|
||||
"language": "auto",
|
||||
"transcribe_prompt": "",
|
||||
|
||||
# --- whisper.cpp, on this machine ---------------------------------------
|
||||
@@ -773,13 +775,23 @@ class Config:
|
||||
return self["cleanup_provider"] == "local"
|
||||
|
||||
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
|
||||
subtitles=False):
|
||||
turkish = i18n.language() == "tr"
|
||||
subtitles=False, speech=""):
|
||||
"""`speech` is the two-letter code of the language that was heard, when
|
||||
the transcription model reported one. The default prompts and the
|
||||
glossary rule only exist in Turkish and English, so a detected Turkish
|
||||
recording gets the Turkish prompt and any other detected language, or
|
||||
none at all, the English one, which is written not to care what
|
||||
language the transcript is in. Nothing else calls this with it, so the
|
||||
interface language keeps deciding everywhere the speech was not asked
|
||||
about."""
|
||||
turkish = (speech == "tr") if speech else i18n.language() == "tr"
|
||||
if subtitles:
|
||||
prompt = (self["file_cleanup_prompt"].strip()
|
||||
or default_file_cleanup_prompt())
|
||||
or (FILE_CLEANUP_PROMPT_TR if turkish
|
||||
else FILE_CLEANUP_PROMPT_EN))
|
||||
else:
|
||||
prompt = self["cleanup_prompt"].strip() or default_cleanup_prompt()
|
||||
prompt = (self["cleanup_prompt"].strip()
|
||||
or (CLEANUP_PROMPT_TR if turkish else CLEANUP_PROMPT_EN))
|
||||
glossary = self["transcribe_prompt"].strip()
|
||||
if with_speakers:
|
||||
glossary = "\n".join(x for x in (glossary, self.participants()) if x)
|
||||
|
||||
+284
-5
@@ -35,6 +35,7 @@ import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
@@ -1065,6 +1066,162 @@ def _tail(path, lines=3):
|
||||
return " | ".join(found[-lines:])
|
||||
|
||||
|
||||
# --- what the server is running on ----------------------------------------
|
||||
|
||||
# Both programs say where the model went, and neither is asked: it is printed
|
||||
# while they start and captured in the log Dikte already keeps. Reading it back
|
||||
# is the only way to tell a graphics card that was asked for from one that was
|
||||
# found, which is a difference the settings checkbox cannot make on its own.
|
||||
Accel = collections.namedtuple("Accel", "backend device layers available")
|
||||
|
||||
NO_ACCEL = Accel("", "", "", ())
|
||||
|
||||
# ggml loads each backend from a shared object and says which; whisper then says
|
||||
# whether it found a card, and llama says how many layers went onto it.
|
||||
_BACKEND_LOADED = re.compile(r"^load_backend: loaded (\w+) backend", re.M)
|
||||
_WHISPER_NO_GPU = "whisper_backend_init_gpu: no GPU found"
|
||||
# "using" precedes the attempt to initialise the backend. A later failure
|
||||
# invalidates it, even when model weights were already put on that device.
|
||||
_WHISPER_ATTEMPT = re.compile(
|
||||
r"^whisper_backend_init_gpu: (using|failed to initialize) (\S+) backend",
|
||||
re.M)
|
||||
_WHISPER_BUFFER = re.compile(r"^whisper_model_load:\s+(\S+) total size", re.M)
|
||||
# The device listing, read for the card's name rather than for the verdict.
|
||||
_WHISPER_DEVICE = re.compile(
|
||||
r"^whisper_backend_init_gpu: device (\d+): (.+?) \(type: (\d+)\)", re.M)
|
||||
_LLAMA_OFFLOAD = re.compile(
|
||||
r"^load_tensors: offloaded (\d+)/(\d+) layers to GPU", re.M)
|
||||
_LLAMA_BUFFER = re.compile(
|
||||
r"^load_tensors:\s+(\S+) model buffer size\s*=\s*(\d+(?:\.\d+)?) MiB",
|
||||
re.M)
|
||||
|
||||
# whisper names the device by its ggml handle, "Vulkan0" or "CUDA0", which says
|
||||
# which slot rather than which card. Each backend prints the real name as it
|
||||
# enumerates, one line further up.
|
||||
_HANDLE = re.compile(r"^([A-Za-z]+?)(\d*)$")
|
||||
_BARE = re.compile(r"^(?:Vulkan|CUDA|ROCm|SYCL|Metal|GPU|CPU)\d*$", re.I)
|
||||
_METAL_DEVICE = re.compile(r"^ggml_metal.*picking default device: (.+)$", re.M)
|
||||
# The driver in brackets after the card's own name: "(radv)", "(nvidia)". The
|
||||
# name carries brackets of its own, but in capitals, so the case is what tells
|
||||
# a driver tag from part of the name.
|
||||
_DRIVER_TAG = re.compile(r"\s*\([a-z0-9_.\- ]+\)$")
|
||||
|
||||
|
||||
def _enumerated(text, backend, index):
|
||||
"""The name the backend printed for one of its own devices, by slot.
|
||||
|
||||
Asked by backend rather than by whichever listing came first: a machine
|
||||
with both a CUDA build and a Vulkan loader prints two listings, and the
|
||||
card named in the wrong one is somebody else's card.
|
||||
"""
|
||||
listings = {
|
||||
"vulkan": rf"^ggml_vulkan: {index} = (.+?) \| ",
|
||||
"cuda": rf"^\s*Device {index}: (.+?), compute capability",
|
||||
"rocm": rf"^\s*Device {index}: (.+?), compute capability",
|
||||
}
|
||||
pattern = listings.get(backend.lower())
|
||||
found = re.search(pattern, text, re.M) if pattern else None
|
||||
if found is None and backend.lower() == "metal":
|
||||
found = _METAL_DEVICE.search(text)
|
||||
if found is None:
|
||||
return ""
|
||||
return _DRIVER_TAG.sub("", found.group(1).strip())
|
||||
|
||||
|
||||
def _card_name(text, handle):
|
||||
"""The card behind a ggml handle like "Vulkan0", named the way it sells.
|
||||
|
||||
The backend's own enumeration is asked first because it is the only listing
|
||||
indexed the way the handle is. whisper numbers every device it can see in
|
||||
one sequence, so the Vulkan card can be its device 1 while being Vulkan0,
|
||||
and reading that row by the handle's digit names whatever else was in slot
|
||||
zero. The handle itself is never an answer: it says which slot, and a line
|
||||
reading "Vulkan, Vulkan0" tells nobody which card is doing the work.
|
||||
"""
|
||||
parts = _HANDLE.match(handle or "")
|
||||
backend, index = (parts.group(1), parts.group(2) or "0") if parts else ("", "0")
|
||||
found = _enumerated(text, backend, index)
|
||||
if found:
|
||||
return found
|
||||
# Nothing enumerated: whisper's own listing is all there is, and a single
|
||||
# named device in it can only be the one that ran.
|
||||
named = [name.strip() for _slot, name, kind in _WHISPER_DEVICE.findall(text)
|
||||
if kind != "0" and name.strip() and not _BARE.match(name.strip())]
|
||||
return named[0] if len(named) == 1 else ""
|
||||
|
||||
# The startup chatter is the first few hundred lines; the rest of the file is a
|
||||
# line per request and grows for as long as the server lives.
|
||||
_LOG_HEAD = 64 << 10
|
||||
|
||||
|
||||
def _read_accel(program, log_path):
|
||||
"""What the server that wrote `log_path` is running on.
|
||||
|
||||
An empty backend is a real answer rather than a failure: a whisper built by
|
||||
hand on a Mac has Metal compiled in and prints no load_backend line at all,
|
||||
and calling that "the processor" would be a confident lie about the one
|
||||
thing this is here to be honest about.
|
||||
"""
|
||||
try:
|
||||
with open(log_path, encoding="utf-8", errors="replace") as fh:
|
||||
text = fh.read(_LOG_HEAD)
|
||||
except OSError:
|
||||
return NO_ACCEL
|
||||
# dict.fromkeys rather than a set: the order they were loaded in is the
|
||||
# order they are worth showing in, and CPU is always one of them.
|
||||
available = tuple(dict.fromkeys(_BACKEND_LOADED.findall(text)))
|
||||
cards = [name for name in available if name.upper() != "CPU"]
|
||||
if program is WHISPER:
|
||||
handle, failed = "", False
|
||||
for event, device in _WHISPER_ATTEMPT.findall(text):
|
||||
if event == "using":
|
||||
handle, failed = device, False
|
||||
elif not handle or device == handle:
|
||||
handle, failed = "", True
|
||||
if failed:
|
||||
return Accel("CPU", "", "", available)
|
||||
buffered = _WHISPER_BUFFER.search(text)
|
||||
if not handle and buffered:
|
||||
handle = buffered.group(1)
|
||||
if _WHISPER_NO_GPU in text or handle.upper().startswith("CPU"):
|
||||
return Accel("CPU", handle or "", "", available)
|
||||
if handle:
|
||||
parts = _HANDLE.match(handle)
|
||||
backend = parts.group(1) if parts else ""
|
||||
# The backend as the build spells it, so "Vulkan" rather than the
|
||||
# capitalisation the handle happened to use.
|
||||
backend = next((name for name in cards
|
||||
if name.lower() == backend.lower()), backend)
|
||||
return Accel(backend or "GPU", _card_name(text, handle), "",
|
||||
available)
|
||||
if available and not cards:
|
||||
# Nothing but a processor backend in this build: there was nowhere
|
||||
# else the model could have gone.
|
||||
return Accel("CPU", "", "", available)
|
||||
return Accel("", "", "", available)
|
||||
found = _LLAMA_OFFLOAD.search(text)
|
||||
if found:
|
||||
layers = f"{found.group(1)}/{found.group(2)}"
|
||||
if int(found.group(1)) > 0:
|
||||
# Loaded libraries do not identify the device holding the model.
|
||||
# Host buffers such as CUDA_Host are not GPU allocations. When
|
||||
# several devices hold weights, do not pretend only one ran.
|
||||
handles = list(dict.fromkeys(
|
||||
handle for handle, size in _LLAMA_BUFFER.findall(text)
|
||||
if float(size) > 0 and _BARE.fullmatch(handle)
|
||||
and not handle.upper().startswith("CPU")))
|
||||
if len(handles) == 1:
|
||||
handle = handles[0]
|
||||
backend = _HANDLE.fullmatch(handle).group(1)
|
||||
return Accel(backend, _card_name(text, handle), layers,
|
||||
available)
|
||||
return Accel("GPU", "", layers, available)
|
||||
return Accel("CPU", "", layers, available)
|
||||
if available and not cards:
|
||||
return Accel("CPU", "", "", available)
|
||||
return Accel("", "", "", available)
|
||||
|
||||
|
||||
def _win_image_name(pid):
|
||||
"""The full, lower-cased path of the process's executable, or ''.
|
||||
|
||||
@@ -1118,6 +1275,18 @@ class Server:
|
||||
self._port = 0
|
||||
self._log = ""
|
||||
self._key = None
|
||||
# What the running child settled on, read out of its log once it was
|
||||
# ready. Kept beside the process because it belongs to that process and
|
||||
# to no other: a restart on new settings may land somewhere else.
|
||||
self._accel = NO_ACCEL
|
||||
# The settings the running child was started on, which is not what
|
||||
# _settings holds: a change made while a start is in flight lands there
|
||||
# first, and reporting the new model beside the old process would name
|
||||
# a model this server is not running.
|
||||
self._live = {}
|
||||
# The copy that is running, resolved rather than configured: the
|
||||
# setting is usually empty, meaning whichever one program_path finds.
|
||||
self._binary = ""
|
||||
# The pid this instance last wrote to its pid file, so _forget never
|
||||
# removes a file some other Dikte wrote after us.
|
||||
self._pid = 0
|
||||
@@ -1179,6 +1348,34 @@ class Server:
|
||||
with self._lock:
|
||||
return f"http://{HOST}:{self._port}/v1" if self._port else ""
|
||||
|
||||
def state(self):
|
||||
"""A snapshot of what this server is doing, for something to show.
|
||||
|
||||
Taken under the short lock rather than the start one, so the interface
|
||||
is answered at once even while a model is being read in. Plain types
|
||||
throughout, because this travels over the socket to the command line.
|
||||
"""
|
||||
with self._lock:
|
||||
up = self._proc is not None and self._proc.poll() is None
|
||||
accel = self._accel if up else NO_ACCEL
|
||||
# What it is running, when it is running; what it would run
|
||||
# otherwise. The two differ for as long as a change waits for the
|
||||
# restart that will pick it up.
|
||||
settings = self._live if up else self._settings
|
||||
return {
|
||||
"running": up,
|
||||
"pid": self._proc.pid if up else 0,
|
||||
"port": self._port if up else 0,
|
||||
"model": settings.get("model", ""),
|
||||
"gpu_wanted": bool(settings.get("gpu")),
|
||||
"backend": accel.backend,
|
||||
"device": accel.device,
|
||||
"layers": accel.layers,
|
||||
"available": list(accel.available),
|
||||
"binary": self._binary if up else "",
|
||||
"downloaded": bool(up and is_downloaded(self._binary)),
|
||||
}
|
||||
|
||||
def error(self):
|
||||
"""The last thing the server printed, for a failure after it started."""
|
||||
with self._lock:
|
||||
@@ -1200,9 +1397,12 @@ class Server:
|
||||
self._stop_now()
|
||||
with self._lock:
|
||||
settings, key = dict(self._settings), self._settings_key()
|
||||
proc, port, log = self._launch(settings)
|
||||
proc, port, log, accel = self._launch(settings)
|
||||
with self._lock:
|
||||
self._proc, self._port, self._log, self._key = proc, port, log, key
|
||||
self._accel, self._live = accel, settings
|
||||
self._binary = program_path(self.program,
|
||||
settings.get("binary", ""))
|
||||
# Only the clock. The count is not this launch's to reset: a
|
||||
# caller that took a hold and then asked for the address, which
|
||||
# is what the local cleanup does, would have it wiped here and
|
||||
@@ -1322,7 +1522,9 @@ class Server:
|
||||
self._forget()
|
||||
raise
|
||||
if reason == "ready":
|
||||
return proc, port, str(log)
|
||||
# Read now rather than on demand: the startup lines are at the
|
||||
# head of a file a long-lived server keeps appending to.
|
||||
return proc, port, str(log), _read_accel(self.program, log)
|
||||
last = _tail(log)
|
||||
self._forget()
|
||||
# Losing the port between the probe and the bind is the one
|
||||
@@ -1423,6 +1625,8 @@ class Server:
|
||||
with self._lock:
|
||||
proc, self._proc = self._proc, None
|
||||
self._port, self._log, self._key = 0, "", None
|
||||
self._accel, self._live = NO_ACCEL, {}
|
||||
self._binary = ""
|
||||
self._kill(proc, gently=True)
|
||||
if proc is not None:
|
||||
self._forget()
|
||||
@@ -1547,12 +1751,13 @@ def _whisper_args(settings):
|
||||
binary, "-m", str(model),
|
||||
"--inference-path", INFERENCE_PATH,
|
||||
# Whatever language the request does not name. api.py leaves the field
|
||||
# out when the language is "auto", and the server's own default is
|
||||
# English rather than detection.
|
||||
# out when the language is "auto", and the server's own language is
|
||||
# set here: "auto" makes whisper.cpp detect what it hears.
|
||||
"-l", "auto",
|
||||
# Stock phrases invented for near-silence come from non-speech tokens,
|
||||
# and verbose_json otherwise pays for a language probability sweep
|
||||
# nothing here reads.
|
||||
# nobody asked for. A request that wants the detected language switches
|
||||
# that back on per request.
|
||||
"-sns", "-nlp",
|
||||
]
|
||||
if int(settings["threads"]) > 0:
|
||||
@@ -1606,6 +1811,80 @@ def sweep():
|
||||
return any([server.sweep() for server in SERVERS])
|
||||
|
||||
|
||||
def state():
|
||||
"""What each local server is doing, keyed by program name."""
|
||||
return {server.program.name: server.state() for server in SERVERS}
|
||||
|
||||
|
||||
def is_downloaded(path):
|
||||
"""Whether `path` is a copy Dikte fetched rather than one the system has."""
|
||||
return bool(path) and _under(path, BIN_DIR)
|
||||
|
||||
|
||||
def server_log(program):
|
||||
"""Where this program's server writes, which outlives the process."""
|
||||
return DATA_DIR / f"{program.name}-server.log"
|
||||
|
||||
|
||||
def last_accel(program):
|
||||
"""What the last server for `program` ran on, from the log it left behind.
|
||||
|
||||
For a command line asking with nothing running: the log outlives the process
|
||||
and is the only account of the last start there is.
|
||||
"""
|
||||
return _read_accel(program, server_log(program))
|
||||
|
||||
|
||||
def accel_kind(state):
|
||||
""""off" | "gpu" | "cpu" | "unknown", for a state() or an Accel.
|
||||
|
||||
A tag rather than a sentence, because the two places that show this write
|
||||
their own: the command line answers in English and the settings window in
|
||||
whatever language it was opened in.
|
||||
"""
|
||||
if isinstance(state, Accel):
|
||||
state = {"running": True, "backend": state.backend}
|
||||
if not state.get("running"):
|
||||
return "off"
|
||||
backend = state.get("backend") or ""
|
||||
if not backend:
|
||||
return "unknown"
|
||||
return "cpu" if backend.upper() == "CPU" else "gpu"
|
||||
|
||||
|
||||
def accel_detail(state):
|
||||
"""The backend, the card and the layers, joined, or "" when none were said.
|
||||
|
||||
Names as the server printed them: "CUDA", "Vulkan", the card's own model
|
||||
name. Translating those would be inventing hardware nobody sells.
|
||||
"""
|
||||
if isinstance(state, Accel):
|
||||
state = state._asdict()
|
||||
parts = [state.get("backend") or "", state.get("device") or ""]
|
||||
if state.get("layers"):
|
||||
parts.append(f"{state['layers']} layers")
|
||||
# A whisper on the processor prints "CPU" as its device too, and saying it
|
||||
# twice reads like two different things.
|
||||
seen, out = set(), []
|
||||
for part in parts:
|
||||
if part and part.lower() not in seen:
|
||||
seen.add(part.lower())
|
||||
out.append(part)
|
||||
return ", ".join(out)
|
||||
|
||||
|
||||
def cpu_only_loaded(state):
|
||||
"""Whether CPU is the only backend the log says was loaded.
|
||||
|
||||
A missing GPU backend and one that failed to load look the same here.
|
||||
This cannot establish which backends the binary was built to support.
|
||||
"""
|
||||
if isinstance(state, Accel):
|
||||
state = state._asdict()
|
||||
available = [name.upper() for name in (state.get("available") or [])]
|
||||
return available == ["CPU"]
|
||||
|
||||
|
||||
def stop_all():
|
||||
for server in SERVERS:
|
||||
server.stop()
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ SHORTCUTS = {
|
||||
"Dikte: pause/resume the recording", "pause_shortcut", ""),
|
||||
"cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
|
||||
"cancel_shortcut", ""),
|
||||
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
|
||||
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask the agent",
|
||||
"assistant_shortcut", ""),
|
||||
"meeting": Shortcut("meeting", MEETING_DESKTOP_ID,
|
||||
"Dikte: start/end a meeting recording",
|
||||
|
||||
@@ -804,8 +804,22 @@ TR = {
|
||||
"deneyin.",
|
||||
"Local whisper": "Yerel whisper",
|
||||
"Local model": "Yerel model",
|
||||
"Not loaded.": "Yüklü değil.",
|
||||
"Loaded; it did not say what it is running on.":
|
||||
"Yüklendi; neyin üzerinde çalıştığını söylemedi.",
|
||||
"Loaded on the graphics card ({detail}).":
|
||||
"Ekran kartına yüklendi ({detail}).",
|
||||
"Loaded on the processor ({detail}).": "İşlemciye yüklendi ({detail}).",
|
||||
"Loaded on the processor: only the CPU backend was loaded. Check the "
|
||||
"server log for graphics backend or driver errors.":
|
||||
"İşlemciye yüklendi: yalnızca CPU arka ucu yüklendi. Ekran kartı arka "
|
||||
"ucu veya sürücü hataları için sunucu günlüğünü kontrol edin.",
|
||||
"Loaded on the processor: the graphics card is switched on, but could not "
|
||||
"be used.":
|
||||
"İşlemciye yüklendi: ekran kartı açık, ama kullanılamadı.",
|
||||
"Not installed.": "Kurulu değil.",
|
||||
"Installed on the system: {path}": "Sistemde kurulu: {path}",
|
||||
"Using custom build: {path}": "Özel derleme kullanılıyor: {path}",
|
||||
"Download again": "Yeniden indir",
|
||||
"Downloaded, version {version}.": "İndirildi, sürüm {version}.",
|
||||
"Downloaded, version {version}. There was no Vulkan build, "
|
||||
|
||||
+112
-12
@@ -223,7 +223,9 @@ class WheelGuard(QObject):
|
||||
"""
|
||||
|
||||
def eventFilter(self, box, event):
|
||||
if event.type() == QEvent.Type.Wheel and not box.hasFocus():
|
||||
win = box.window()
|
||||
focused = box.hasFocus() or (win is not None and win.focusWidget() is box)
|
||||
if event.type() == QEvent.Type.Wheel and not focused:
|
||||
# Refused rather than swallowed. An unaccepted wheel event carries
|
||||
# on up the parents to the scroll area, so the page still moves.
|
||||
event.ignore()
|
||||
@@ -253,9 +255,11 @@ class LocalModelBox(QGroupBox):
|
||||
|
||||
changed = pyqtSignal()
|
||||
|
||||
def __init__(self, program, title, models, model_path, repos=None, parent=None):
|
||||
def __init__(self, program, title, models, model_path, binary=None,
|
||||
repos=None, parent=None):
|
||||
super().__init__(title, parent)
|
||||
self.program = program
|
||||
self._binary = binary # () -> a path set by hand, or ""
|
||||
self._models = models # () -> [hub.Item], or (repo) -> [hub.Item]
|
||||
self._model_path = model_path # (name) -> Path
|
||||
self._repos = repos # None, or () -> [repo id]
|
||||
@@ -279,6 +283,8 @@ class LocalModelBox(QGroupBox):
|
||||
self._later.timeout.connect(self._later_fetch)
|
||||
|
||||
form = QFormLayout(self)
|
||||
form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
self.program_label = WrappedLabel()
|
||||
self.install_button = QPushButton(t("Download"))
|
||||
@@ -417,13 +423,23 @@ class LocalModelBox(QGroupBox):
|
||||
self._fill_repos(self.repository())
|
||||
self._fetch_models(self.repository())
|
||||
|
||||
def _program_path(self):
|
||||
return ggml.program_path(self.program,
|
||||
self._binary() if self._binary else "")
|
||||
|
||||
def _show_program(self):
|
||||
path = ggml.program_path(self.program)
|
||||
path = self._program_path()
|
||||
if not path:
|
||||
self.program_label.setText(t("Not installed."))
|
||||
self.install_button.setText(t("Download"))
|
||||
self.install_button.setVisible(True)
|
||||
return
|
||||
if self._binary and self._binary():
|
||||
# Neither a system copy nor one Dikte fetched, and "Downloaded"
|
||||
# over a build someone made themselves is not true.
|
||||
self.program_label.setText(t("Using custom build: {path}", path=path))
|
||||
self.install_button.setVisible(False)
|
||||
return
|
||||
# A copy that is here is not a copy that is right. whisper.cpp releases
|
||||
# every few weeks, and a graphics card installed after Dikte was
|
||||
# changes which build this machine should be running; the button was
|
||||
@@ -835,7 +851,7 @@ class LocalModelBox(QGroupBox):
|
||||
repo=self.repository(), cap=ggml.human_size(ggml.GGUF_MAX_BYTES)))
|
||||
elif not name:
|
||||
self.status.setText(t("Nothing downloaded yet."))
|
||||
elif here and not ggml.program_path(self.program):
|
||||
elif here and not self._program_path():
|
||||
# The model alone runs nothing, and "Ready" over a missing program
|
||||
# reads as though it does.
|
||||
self.status.setText(t("{name} is here, but the program above is "
|
||||
@@ -962,6 +978,57 @@ class SettingsWindow(QDialog):
|
||||
# because of that, so open it on the tab that fixes it.
|
||||
if not conf.transcribe_ready():
|
||||
self.tabs.setCurrentIndex(self.api_tab_index)
|
||||
# A model takes up to ggml.STARTUP_TIMEOUT to load, so a line written
|
||||
# once as the window opens would be wrong for most of the wait. Runs
|
||||
# only while the window is on screen: there is nobody to read it
|
||||
# otherwise, and it costs a lock and a poll() each time.
|
||||
self._local_state_timer = QTimer(self)
|
||||
self._local_state_timer.setInterval(2000)
|
||||
self._local_state_timer.timeout.connect(self._show_local_state)
|
||||
self._show_local_state()
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._show_local_state()
|
||||
self._local_state_timer.start()
|
||||
|
||||
def hideEvent(self, event):
|
||||
self._local_state_timer.stop()
|
||||
super().hideEvent(event)
|
||||
|
||||
def _show_local_state(self):
|
||||
"""What each model on this machine is loaded on, as it is now."""
|
||||
local = ggml.state()
|
||||
self.local_state.setText(
|
||||
self._local_state_text(ggml.WHISPER, local.get("whisper", {})))
|
||||
self.local_llm_state.setText(
|
||||
self._local_state_text(ggml.LLAMA, local.get("llama", {})))
|
||||
|
||||
@staticmethod
|
||||
def _local_state_text(program, entry):
|
||||
"""One line: whether the model is loaded, and what it ended up on.
|
||||
|
||||
Four answers rather than two, because "could not tell" is a real one: a
|
||||
whisper built by hand on a Mac prints nothing about its backend, and
|
||||
answering "the processor" there would be a confident lie about the one
|
||||
thing this line exists to be honest about.
|
||||
"""
|
||||
kind = ggml.accel_kind(entry)
|
||||
if kind == "off":
|
||||
return t("Not loaded.")
|
||||
# The backend and the card keep the names the server printed for them.
|
||||
detail = ggml.accel_detail(entry)
|
||||
if kind == "unknown":
|
||||
return t("Loaded; it did not say what it is running on.")
|
||||
if kind == "gpu":
|
||||
return t("Loaded on the graphics card ({detail}).", detail=detail)
|
||||
if not entry.get("gpu_wanted"):
|
||||
return t("Loaded on the processor ({detail}).", detail=detail)
|
||||
if not ggml.cpu_only_loaded(entry):
|
||||
return t("Loaded on the processor: the graphics card is switched "
|
||||
"on, but could not be used.")
|
||||
return t("Loaded on the processor: only the CPU backend was loaded. "
|
||||
"Check the server log for graphics backend or driver errors.")
|
||||
|
||||
def _scrolled(self, page):
|
||||
"""A tab that scrolls instead of growing the window to fit."""
|
||||
@@ -1002,6 +1069,8 @@ class SettingsWindow(QDialog):
|
||||
def _general_tab(self):
|
||||
page = QWidget()
|
||||
form = QFormLayout(page)
|
||||
form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
|
||||
self.ui_language = QComboBox()
|
||||
for label, code in UI_LANGUAGES:
|
||||
@@ -1158,6 +1227,8 @@ class SettingsWindow(QDialog):
|
||||
|
||||
stt = QGroupBox(t("Speech to text"))
|
||||
stt_form = QFormLayout(stt)
|
||||
stt_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.transcribe_provider = QComboBox()
|
||||
for label, value in TRANSCRIBE_PROVIDERS:
|
||||
self.transcribe_provider.addItem(t(label), value)
|
||||
@@ -1191,7 +1262,8 @@ class SettingsWindow(QDialog):
|
||||
|
||||
self.local_whisper = LocalModelBox(
|
||||
ggml.WHISPER, t("On this machine"),
|
||||
ggml.whisper_models, ggml.whisper_model_path)
|
||||
ggml.whisper_models, ggml.whisper_model_path,
|
||||
binary=lambda: self.conf["local_binary"])
|
||||
stt_form.addRow(self.local_whisper)
|
||||
|
||||
self.local_gpu = QCheckBox(t("Use the graphics card"))
|
||||
@@ -1205,13 +1277,13 @@ class SettingsWindow(QDialog):
|
||||
"spends that once instead of on the first dictation, at the cost of "
|
||||
"the memory it sits in."))
|
||||
self.local_threads = QSpinBox()
|
||||
self.local_threads.setRange(0, 64)
|
||||
max_threads = max(1, os.cpu_count() or 1)
|
||||
self.local_threads.setRange(0, max_threads)
|
||||
self.local_threads.setSpecialValueText(t("Automatic"))
|
||||
# A spin box asks for room for its numbers, and 64 is two characters:
|
||||
# the word standing in for zero is what actually has to fit, and on
|
||||
# macOS, where the stepper sits inside the frame, it does not. Widened
|
||||
# to the word rather than to a number picked by eye, so that it still
|
||||
# fits once the word is "Otomatik".
|
||||
# A spin box asks for room for its numbers, and the word standing in for
|
||||
# zero is what actually has to fit, and on macOS, where the stepper sits
|
||||
# inside the frame, it does not. Widened to the word rather than to a
|
||||
# number picked by eye, so that it still fits once the word is "Otomatik".
|
||||
self.local_threads.setMinimumWidth(
|
||||
self.local_threads.fontMetrics()
|
||||
.horizontalAdvance(t("Automatic")) + 56)
|
||||
@@ -1222,12 +1294,20 @@ class SettingsWindow(QDialog):
|
||||
options_form.addRow("", self.local_preload)
|
||||
options_form.addRow(t("Threads"), self.local_threads)
|
||||
stt_form.addRow(self.local_options)
|
||||
# What the model is actually doing, as against what the boxes above
|
||||
# ask for. The checkbox can only ask: whether a card was found is
|
||||
# decided by the build and by the machine, and is read back off the
|
||||
# server's own log once it has loaded.
|
||||
self.local_state = WrappedLabel("")
|
||||
stt_form.addRow(self.local_state)
|
||||
|
||||
self.transcribe_provider.currentIndexChanged.connect(self._provider_changed)
|
||||
outer.addWidget(stt)
|
||||
|
||||
orr = QGroupBox(t("Transcript cleanup"))
|
||||
orr_form = self.cleanup_form = QFormLayout(orr)
|
||||
orr_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
|
||||
orr_form.addRow("", self.cleanup_enabled)
|
||||
|
||||
@@ -1300,7 +1380,9 @@ class SettingsWindow(QDialog):
|
||||
|
||||
self.local_llm = LocalModelBox(
|
||||
ggml.LLAMA, t("On this machine"),
|
||||
ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos)
|
||||
ggml.llm_quants, ggml.llm_model_path,
|
||||
binary=lambda: self.conf["local_llm_binary"],
|
||||
repos=ggml.llm_repos)
|
||||
orr_form.addRow(self.local_llm)
|
||||
|
||||
self.local_llm_gpu = QCheckBox(t("Use the graphics card"))
|
||||
@@ -1323,6 +1405,8 @@ class SettingsWindow(QDialog):
|
||||
llm_form.addRow("", self.local_llm_preload)
|
||||
llm_form.addRow(t("Thinking"), self.local_llm_reasoning)
|
||||
orr_form.addRow(self.local_llm_options)
|
||||
self.local_llm_state = WrappedLabel("")
|
||||
orr_form.addRow(self.local_llm_state)
|
||||
|
||||
outer.addWidget(orr)
|
||||
|
||||
@@ -1461,6 +1545,8 @@ class SettingsWindow(QDialog):
|
||||
# be worse than none.
|
||||
self.claude_box = QGroupBox(t("Claude Code"))
|
||||
claude_form = QFormLayout(self.claude_box)
|
||||
claude_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.assistant_model = QComboBox()
|
||||
self.assistant_model.setEditable(True)
|
||||
self.assistant_model.addItems(ASSISTANT_MODELS)
|
||||
@@ -1478,6 +1564,8 @@ class SettingsWindow(QDialog):
|
||||
|
||||
self.codex_box = QGroupBox(t("Codex"))
|
||||
codex_form = QFormLayout(self.codex_box)
|
||||
codex_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.assistant_codex_model = QComboBox()
|
||||
self.assistant_codex_model.setEditable(True)
|
||||
self.assistant_codex_model.addItem(t("Codex's own default"), "")
|
||||
@@ -1493,6 +1581,8 @@ class SettingsWindow(QDialog):
|
||||
|
||||
self.openrouter_box = QGroupBox("OpenRouter")
|
||||
or_form = QFormLayout(self.openrouter_box)
|
||||
or_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.assistant_openrouter_model = QComboBox()
|
||||
self.assistant_openrouter_model.setEditable(True)
|
||||
self.assistant_openrouter_model.addItems(ASSISTANT_OR_MODELS)
|
||||
@@ -1510,6 +1600,8 @@ class SettingsWindow(QDialog):
|
||||
|
||||
self.agy_box = QGroupBox(t("Antigravity"))
|
||||
agy_form = QFormLayout(self.agy_box)
|
||||
agy_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.assistant_agy_model = QComboBox()
|
||||
self.assistant_agy_model.setEditable(True)
|
||||
self.assistant_agy_model.addItem(t("Antigravity's own default"), "")
|
||||
@@ -1528,6 +1620,8 @@ class SettingsWindow(QDialog):
|
||||
|
||||
self.opencode_box = QGroupBox("OpenCode Go")
|
||||
og_form = QFormLayout(self.opencode_box)
|
||||
og_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.assistant_opencode_model = QComboBox()
|
||||
self.assistant_opencode_model.setEditable(True)
|
||||
self.assistant_opencode_model.addItems(OPENCODE_MODELS)
|
||||
@@ -1667,6 +1761,8 @@ class SettingsWindow(QDialog):
|
||||
|
||||
models = QGroupBox(t("Minutes"))
|
||||
models_form = QFormLayout(models)
|
||||
models_form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self.meeting_model = QComboBox()
|
||||
self.meeting_model.setEditable(True)
|
||||
self.meeting_model.addItems(MEETING_MODELS)
|
||||
@@ -1849,6 +1945,8 @@ class SettingsWindow(QDialog):
|
||||
# and two combination boxes starting at different places read as two
|
||||
# unrelated settings rather than the pair they are.
|
||||
form = QFormLayout()
|
||||
form.setFieldGrowthPolicy(
|
||||
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
self._shortcut_row(
|
||||
form, "toggle", t("Start and stop"),
|
||||
t("No global shortcut installed."), placeholder="Ctrl+Space",
|
||||
@@ -2410,6 +2508,7 @@ class SettingsWindow(QDialog):
|
||||
self.stt_form.setRowVisible(self.transcribe_status, not local)
|
||||
self.stt_form.setRowVisible(self.local_whisper, local)
|
||||
self.stt_form.setRowVisible(self.local_options, local)
|
||||
self.stt_form.setRowVisible(self.local_state, local)
|
||||
self._refresh_local_box()
|
||||
if local:
|
||||
return
|
||||
@@ -2919,6 +3018,7 @@ class SettingsWindow(QDialog):
|
||||
provider != "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
||||
self.cleanup_form.setRowVisible(self.local_llm_state, provider == "local")
|
||||
self._refresh_local_box()
|
||||
binary = cleanup.executable(provider)
|
||||
found = shutil.which(binary) if binary else ""
|
||||
|
||||
+26
-9
@@ -37,7 +37,7 @@ _paste_lock = threading.Lock()
|
||||
|
||||
class Pipeline(QObject):
|
||||
stage = pyqtSignal(str) # human-readable progress line
|
||||
finished = pyqtSignal(str, str, str) # raw transcript, final text, warning
|
||||
finished = pyqtSignal(str, str, str, str) # raw, final text, warning, language
|
||||
failed = pyqtSignal(str)
|
||||
cancelled = pyqtSignal()
|
||||
|
||||
@@ -120,12 +120,24 @@ class Pipeline(QObject):
|
||||
try:
|
||||
self.stage.emit(t("Transcribing…"))
|
||||
target = conf.transcribe_target()
|
||||
raw = api.transcribe(
|
||||
target,
|
||||
wav_path,
|
||||
language=conf["language"],
|
||||
prompt=conf["transcribe_prompt"],
|
||||
)
|
||||
# The spoken language is only knowable after the fact, and only the
|
||||
# local server says what it heard: auto mode asks it there, and
|
||||
# every other run (a fixed language, or a hosted provider that
|
||||
# detects but stays silent) transcribes as before.
|
||||
auto = conf["language"] == "auto"
|
||||
if auto:
|
||||
raw, detected = api.transcribe_detected(
|
||||
target, wav_path, language=conf["language"],
|
||||
prompt=conf["transcribe_prompt"],
|
||||
)
|
||||
else:
|
||||
raw = api.transcribe(
|
||||
target,
|
||||
wav_path,
|
||||
language=conf["language"],
|
||||
prompt=conf["transcribe_prompt"],
|
||||
)
|
||||
detected = ""
|
||||
|
||||
if conf["filter_hallucinations"] and vad.looks_like_hallucination(raw, duration):
|
||||
self._discard(wav_path)
|
||||
@@ -134,6 +146,10 @@ class Pipeline(QObject):
|
||||
|
||||
text = raw
|
||||
warning = ""
|
||||
# The language the run actually spoke, reported to the window, the
|
||||
# clipboard path and the history alike: the detected code, or the
|
||||
# configured one when nothing was detected to replace it.
|
||||
speech_language = detected or conf["language"]
|
||||
# Remembered rather than re-derived at the history write below: the
|
||||
# ask path runs cleanup under a different setting, and the record
|
||||
# should say what happened, not what one of the two gates implies.
|
||||
@@ -145,7 +161,7 @@ class Pipeline(QObject):
|
||||
self.stage.emit(t("Cleaning up…"))
|
||||
cleaned = True
|
||||
try:
|
||||
text = cleanup.run(raw, conf, conf.cleanup_prompt())
|
||||
text = cleanup.run(raw, conf, conf.cleanup_prompt(speech=detected))
|
||||
except api.ApiError as exc:
|
||||
# Keep the transcript, but never let the failure pass unseen:
|
||||
# a rejected key would otherwise look like working dictation.
|
||||
@@ -183,6 +199,7 @@ class Pipeline(QObject):
|
||||
"question": question,
|
||||
"assistant": assistant.provider(conf) if ask else "",
|
||||
"assistant_model": assistant.model(conf) if ask else "",
|
||||
"speech_language": speech_language,
|
||||
"raw": raw,
|
||||
"text": text,
|
||||
}
|
||||
@@ -221,7 +238,7 @@ class Pipeline(QObject):
|
||||
time.sleep(0.35)
|
||||
paste.copy_bytes(previous)
|
||||
|
||||
self.finished.emit(raw, text, warning)
|
||||
self.finished.emit(raw, text, warning, speech_language)
|
||||
|
||||
except assistant.Cancelled:
|
||||
self.cancelled.emit()
|
||||
|
||||
@@ -55,6 +55,7 @@ cat > "$APPDIR/dikte.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Dikte
|
||||
X-AppImage-Version=$VERSION
|
||||
Comment=Voice dictation: record, transcribe, clean up, paste
|
||||
Exec=dikte
|
||||
Icon=dikte
|
||||
|
||||
@@ -92,6 +92,22 @@ class DikteTest(unittest.TestCase):
|
||||
# down when it last ran.
|
||||
self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json")
|
||||
self.patch_attr(update, "STATE_FILE", data_dir / "update.json")
|
||||
# ggml resolves its own three from paths.DATA_DIR at import, the same
|
||||
# way cfg does. Left alone, a test asking what is installed or what the
|
||||
# last server ran on would be reading whatever this machine happens to
|
||||
# have downloaded, and passing or failing on somebody's home directory.
|
||||
# program_path prefers a whisper-server or llama-server on the PATH
|
||||
# over the copy Dikte downloaded, so on a machine with whisper.cpp
|
||||
# installed these tests would be answering from that copy instead of
|
||||
# from the install they set up. Every other tool still resolves; the
|
||||
# tests that are about the system build patch this again themselves.
|
||||
_which = shutil.which
|
||||
self.patch_attr(shutil, "which", lambda tool, *args, **rest: (
|
||||
None if tool in ("whisper-server", "llama-server")
|
||||
else _which(tool, *args, **rest)))
|
||||
self.patch_attr(ggml, "DATA_DIR", data_dir)
|
||||
self.patch_attr(ggml, "BIN_DIR", data_dir / "bin")
|
||||
self.patch_attr(ggml, "MODELS_DIR", data_dir / "models")
|
||||
|
||||
i18n.set_language("en")
|
||||
self.addCleanup(i18n.set_language, "en")
|
||||
|
||||
@@ -827,6 +827,40 @@ class TranscribeHere(DikteTest):
|
||||
api.transcribe_segments(LOCAL, self.wav)
|
||||
self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin")
|
||||
|
||||
# ---- the detected language --------------------------------------------
|
||||
|
||||
def test_auto_mode_asks_whisper_for_the_detected_language(self):
|
||||
# The -nlp the server was started with is switched back on for this one
|
||||
# request, so whisper's verbose_json reports what it heard.
|
||||
reply = {"text": " Merhaba dünya. ", "detected_language": "turkish"}
|
||||
with fake_urlopen(reply) as calls:
|
||||
text, code = api.transcribe_detected(LOCAL, self.wav, language="auto")
|
||||
fields = multipart_fields(calls[0])
|
||||
self.assertEqual(fields["response_format"], "verbose_json")
|
||||
self.assertEqual(fields["no_language_probabilities"], "false")
|
||||
self.assertNotIn("language", fields)
|
||||
self.assertEqual(text, "Merhaba dünya.")
|
||||
self.assertEqual(code, "tr")
|
||||
|
||||
def test_a_fixed_language_reports_no_detection(self):
|
||||
with fake_urlopen({"text": "hello"}) as calls:
|
||||
text, code = api.transcribe_detected(LOCAL, self.wav, language="tr")
|
||||
self.assertNotIn("no_language_probabilities", multipart_fields(calls[0]))
|
||||
self.assertEqual(text, "hello")
|
||||
self.assertEqual(code, "")
|
||||
|
||||
def test_a_detected_language_without_a_code_stays_unknown(self):
|
||||
with fake_urlopen({"text": "hello", "detected_language": "somali"}):
|
||||
_text, code = api.transcribe_detected(LOCAL, self.wav, language="auto")
|
||||
self.assertEqual(code, "")
|
||||
|
||||
def test_a_hosted_auto_run_transcribes_without_detection(self):
|
||||
with fake_urlopen({"text": "hi"}) as calls:
|
||||
text, code = api.transcribe_detected(OPENAI, self.wav, language="auto")
|
||||
self.assertNotIn("no_language_probabilities", multipart_fields(calls[0]))
|
||||
self.assertEqual(text, "hi")
|
||||
self.assertEqual(code, "")
|
||||
|
||||
|
||||
class Stopping(unittest.TestCase):
|
||||
"""The Stop button, from the far end: a request already blocked on a reply.
|
||||
|
||||
@@ -578,6 +578,21 @@ class Doctor(DikteTest):
|
||||
self.run_doctor(as_json=False, cleanup_provider="codex",
|
||||
cleanup_codex_model="gpt-5.4"))
|
||||
|
||||
def test_agent_on_hosted_provider_does_not_ask_for_a_cli_program(self):
|
||||
for provider in ("openrouter", "opencode"):
|
||||
with self.subTest(provider=provider):
|
||||
reply = self.run_doctor(assistant_provider=provider)
|
||||
self.assertEqual(reply["agent"]["provider"], provider)
|
||||
for cli_name in ("claude", "codex", "agy"):
|
||||
self.assertNotIn(cli_name, reply["programs"])
|
||||
|
||||
def test_agent_on_a_cli_asks_for_the_program(self):
|
||||
for provider, binary in (("claude", "claude"), ("codex", "codex"), ("agy", "agy")):
|
||||
with self.subTest(provider=provider):
|
||||
reply = self.run_doctor(assistant_provider=provider)
|
||||
self.assertEqual(reply["agent"]["provider"], provider)
|
||||
self.assertIn(binary, reply["programs"])
|
||||
|
||||
|
||||
class Devices(DikteTest):
|
||||
def test_a_machine_with_nothing_names_its_own_missing_program(self):
|
||||
@@ -746,6 +761,13 @@ class Replies(DikteTest):
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(out.strip(), "Book it for Thursday.")
|
||||
|
||||
def test_the_json_answer_carries_the_detected_language(self):
|
||||
code, out, _ = self.run_verb(
|
||||
["--json", "record"],
|
||||
{"ok": True, "text": "Selam", "speech_language": "tr"})
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out)["speech_language"], "tr")
|
||||
|
||||
def test_a_dictation_that_failed(self):
|
||||
code, out, err = self.run_verb(["stop", "--wait"],
|
||||
{"ok": False, "error": "No speech detected"})
|
||||
@@ -780,6 +802,145 @@ class Replies(DikteTest):
|
||||
self.assertFalse(launched.called)
|
||||
|
||||
|
||||
class LocalModels(DikteTest):
|
||||
"""Whether the model on this machine is loaded, and what it is loaded on."""
|
||||
|
||||
def status(self, local, **rest):
|
||||
reply = {"ok": True, "running": True, "dictation": "idle", "ask": "idle",
|
||||
"meeting": "idle", "listener": True, "local": local, **rest}
|
||||
with mock.patch.object(ipc, "send", return_value=reply), \
|
||||
captured() as (out, _err):
|
||||
cli.cmd_status(Options(json=False))
|
||||
return out.getvalue()
|
||||
|
||||
def entry(self, **values):
|
||||
base = {"running": True, "used": True, "pid": 7, "port": 4321,
|
||||
"model": "ggml-small.bin", "gpu_wanted": True,
|
||||
"backend": "CUDA", "device": "RTX 4070", "layers": "",
|
||||
"available": ["CUDA", "CPU"]}
|
||||
base.update(values)
|
||||
return base
|
||||
|
||||
def test_a_loaded_model_says_what_it_is_loaded_on(self):
|
||||
line = self.status({"whisper": self.entry()})
|
||||
self.assertIn("whisper:", line)
|
||||
self.assertIn("loaded on the graphics card (CUDA, RTX 4070)", line)
|
||||
self.assertIn("ggml-small.bin", line)
|
||||
|
||||
def test_a_card_asked_for_and_not_found_is_said_out_loud(self):
|
||||
line = self.status({"whisper": self.entry(
|
||||
backend="CPU", device="CPU", available=["CPU"])})
|
||||
self.assertIn("loaded on the processor", line)
|
||||
self.assertIn("only the CPU backend was loaded", line)
|
||||
|
||||
def test_a_download_is_not_assumed_to_lack_gpu_support(self):
|
||||
line = self.status({"whisper": self.entry(
|
||||
backend="CPU", device="CPU", available=["CPU"], downloaded=True)})
|
||||
self.assertIn("only the CPU backend was loaded", line)
|
||||
self.assertIn("driver errors", line)
|
||||
self.assertNotIn("has no GPU backend", line)
|
||||
|
||||
def test_a_card_the_build_could_have_used_says_something_else(self):
|
||||
line = self.status({"whisper": self.entry(
|
||||
backend="CPU", device="CPU", available=["CUDA", "CPU"])})
|
||||
self.assertIn("could not be used", line)
|
||||
self.assertNotIn("carries none", line)
|
||||
|
||||
def test_a_card_nobody_asked_for_is_not_a_complaint(self):
|
||||
line = self.status({"whisper": self.entry(
|
||||
backend="CPU", device="CPU", gpu_wanted=False, available=["CPU"])})
|
||||
self.assertIn("loaded on the processor", line)
|
||||
self.assertNotIn("switched on", line)
|
||||
|
||||
def test_a_model_that_is_wanted_and_not_loaded_says_so(self):
|
||||
line = self.status({"whisper": self.entry(running=False)})
|
||||
self.assertIn("whisper:", line)
|
||||
self.assertIn("not loaded", line)
|
||||
|
||||
def test_a_model_neither_used_nor_loaded_is_not_worth_a_line(self):
|
||||
line = self.status({"llama": self.entry(running=False, used=False)})
|
||||
self.assertNotIn("llama", line)
|
||||
|
||||
def test_an_instance_too_old_to_have_been_asked_says_nothing(self):
|
||||
reply = {"ok": True, "running": True, "dictation": "idle", "ask": "idle",
|
||||
"meeting": "idle", "listener": True}
|
||||
with mock.patch.object(ipc, "send", return_value=reply), \
|
||||
captured() as (out, _err):
|
||||
cli.cmd_status(Options(json=False))
|
||||
self.assertNotIn("whisper", out.getvalue())
|
||||
|
||||
# ---- doctor, which can be asked with nothing running -----------------
|
||||
|
||||
def doctor(self, as_json=False, **settings):
|
||||
self.write_config(settings)
|
||||
with mock.patch.object(ipc, "send", return_value=None), \
|
||||
captured() as (out, _err):
|
||||
cli.cmd_doctor(Options(json=as_json))
|
||||
return json.loads(out.getvalue()) if as_json else out.getvalue()
|
||||
|
||||
def log(self, text):
|
||||
path = ggml.DATA_DIR / "whisper-server.log"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
def test_with_nothing_running_the_last_start_is_read_off_its_log(self):
|
||||
self.log("load_backend: loaded CPU backend from /x.so\n"
|
||||
"whisper_backend_init_gpu: device 0: CPU (type: 0)\n"
|
||||
"whisper_backend_init_gpu: no GPU found\n")
|
||||
line = self.doctor(transcribe_provider="local", local_gpu=True)
|
||||
self.assertIn("last run on the processor", line)
|
||||
self.assertNotIn("this build carries none", line)
|
||||
self.assertNotIn("check the server log", line)
|
||||
|
||||
def test_old_cpu_log_does_not_diagnose_a_new_system_binary(self):
|
||||
self.log("load_backend: loaded CPU backend from /old-download.so\n"
|
||||
"whisper_backend_init_gpu: no GPU found\n")
|
||||
with mock.patch.object(ggml, "program_path",
|
||||
return_value="/usr/bin/whisper-server"):
|
||||
line = self.doctor(transcribe_provider="local", local_gpu=True)
|
||||
data = self.doctor(as_json=True, transcribe_provider="local",
|
||||
local_gpu=True)
|
||||
self.assertIn("last run on the processor", line)
|
||||
self.assertNotIn("carries none", line)
|
||||
self.assertNotIn("gpu_wanted", data["local"]["whisper"])
|
||||
self.assertNotIn("downloaded", data["local"]["whisper"])
|
||||
|
||||
def test_enabling_gpu_does_not_reinterpret_a_past_cpu_run(self):
|
||||
self.log("load_backend: loaded Vulkan backend from /gpu.so\n"
|
||||
"load_backend: loaded CPU backend from /cpu.so\n"
|
||||
"whisper_init_with_params_no_state: use gpu = 0\n"
|
||||
"whisper_backend_init_gpu: no GPU found\n")
|
||||
line = self.doctor(transcribe_provider="local", local_gpu=True)
|
||||
self.assertIn("last run on the processor", line)
|
||||
self.assertNotIn("none was found", line)
|
||||
self.assertNotIn("could not be used", line)
|
||||
|
||||
def test_a_run_that_named_no_backend_is_not_read_as_no_run_at_all(self):
|
||||
# A log with nothing recognisable in it still says a server started
|
||||
# here once, which is a different thing from never having started.
|
||||
self.log("whisper_model_load: model size = 147.37 MB\n")
|
||||
line = self.doctor(transcribe_provider="local")
|
||||
self.assertIn("said nothing about what it was running on", line)
|
||||
self.assertNotIn("never run here", line)
|
||||
|
||||
def test_a_machine_that_never_ran_one_is_not_made_up_a_history_for(self):
|
||||
line = self.doctor(transcribe_provider="local")
|
||||
self.assertIn("never run here", line)
|
||||
|
||||
def test_a_setup_that_transcribes_in_the_cloud_reads_about_none_of_it(self):
|
||||
line = self.doctor(transcribe_provider="openai", cleanup_enabled=False)
|
||||
self.assertNotIn("whisper ", line)
|
||||
self.assertNotIn("never run here", line)
|
||||
|
||||
def test_an_instance_that_cannot_be_asked_is_not_read_as_a_no(self):
|
||||
"""It used to print "not loaded", which is a different claim."""
|
||||
self.write_config({"transcribe_provider": "local"})
|
||||
with mock.patch.object(ipc, "send", return_value={"ok": True}), \
|
||||
captured() as (out, _err):
|
||||
cli.cmd_doctor(Options(json=False))
|
||||
self.assertIn("too old to say", out.getvalue())
|
||||
|
||||
|
||||
class TranscribeRunsHere(DikteTest):
|
||||
"""`dikte transcribe` runs in this process, not in the instance."""
|
||||
|
||||
|
||||
@@ -274,6 +274,18 @@ class CleanupPrompt(DikteTest):
|
||||
def test_no_glossary_means_no_rule_about_one(self):
|
||||
self.assertEqual(cfg.Config().cleanup_prompt(), cfg.CLEANUP_PROMPT_EN)
|
||||
|
||||
def test_a_detected_turkish_recording_gets_the_turkish_prompt(self):
|
||||
"""Auto mode learns what was heard, and that decides the prompt rather
|
||||
than the interface language."""
|
||||
self.write_config({"ui_language": "en", "transcribe_prompt": "Paraşüt"})
|
||||
conf = cfg.Config()
|
||||
prompt = conf.cleanup_prompt(speech="tr")
|
||||
self.assertEqual(prompt, cfg.CLEANUP_PROMPT_TR
|
||||
+ cfg.GLOSSARY_RULE_TR.format(glossary="Paraşüt"))
|
||||
self.assertIn("KONUŞMACININ KULLANDIĞI İSİM VE TERİMLER", prompt)
|
||||
self.assertIn("NAMES AND TERMS THE SPEAKER USES",
|
||||
conf.cleanup_prompt(speech="de"))
|
||||
|
||||
def test_subtitles_use_their_own_prompt(self):
|
||||
conf = cfg.Config()
|
||||
self.assertNotEqual(conf.cleanup_prompt(subtitles=True), conf.cleanup_prompt())
|
||||
|
||||
@@ -735,6 +735,275 @@ class Catalogue(Local):
|
||||
"model.gguf")
|
||||
|
||||
|
||||
# --- what it ended up running on ------------------------------------------
|
||||
|
||||
|
||||
# Trimmed from real logs. The first is this project's own bug report: the
|
||||
# graphics card is switched on, whisper asked for one, and the build had none
|
||||
# to give.
|
||||
WHISPER_CPU = """\
|
||||
load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so
|
||||
whisper_init_from_file_with_params_no_state: loading model from 'ggml-small.bin'
|
||||
whisper_init_with_params_no_state: use gpu = 1
|
||||
whisper_model_load: CPU total size = 189.49 MB
|
||||
whisper_backend_init_gpu: device 0: CPU (type: 0)
|
||||
whisper_backend_init_gpu: no GPU found
|
||||
"""
|
||||
|
||||
WHISPER_CUDA = """\
|
||||
load_backend: loaded CUDA backend from /opt/whisper/libggml-cuda.so
|
||||
load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so
|
||||
whisper_init_with_params_no_state: use gpu = 1
|
||||
whisper_model_load: CUDA0 total size = 189.49 MB
|
||||
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 (type: 1)
|
||||
whisper_backend_init_gpu: using CUDA0 backend
|
||||
"""
|
||||
|
||||
# A card listed, tried, and refused: whisper says so and carries on without it,
|
||||
# and the weights stay where they were put. Reading the listing alone would
|
||||
# report a graphics card that is doing nothing.
|
||||
WHISPER_GPU_FAILED = """\
|
||||
load_backend: loaded Vulkan backend from /usr/lib/ggml/libggml-vulkan.so
|
||||
load_backend: loaded CPU backend from /usr/lib/ggml/libggml-cpu-haswell.so
|
||||
whisper_model_load: CPU total size = 189.49 MB
|
||||
whisper_backend_init_gpu: device 0: Vulkan0 (type: 1)
|
||||
whisper_backend_init_gpu: found GPU device 0: Vulkan0 (type: 1, cnt: 0)
|
||||
whisper_backend_init_gpu: using Vulkan0 backend
|
||||
whisper_backend_init_gpu: failed to initialize Vulkan0 backend
|
||||
"""
|
||||
|
||||
# Both backends in one build. The Vulkan listing is there and is not the one
|
||||
# that ran, so naming the card out of it would name the wrong device.
|
||||
WHISPER_MIXED = """\
|
||||
ggml_vulkan: Found 1 Vulkan devices:
|
||||
ggml_vulkan: 0 = Intel UHD Graphics 770 (ANV TGL) (anv) | uma: 1
|
||||
load_backend: loaded CUDA backend from /opt/whisper/libggml-cuda.so
|
||||
load_backend: loaded Vulkan backend from /opt/whisper/libggml-vulkan.so
|
||||
load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so
|
||||
Device 0: NVIDIA GeForce RTX 4070, compute capability 8.9, VMM: yes
|
||||
whisper_model_load: CUDA0 total size = 189.49 MB
|
||||
whisper_backend_init_gpu: device 0: CUDA0 (type: 1)
|
||||
whisper_backend_init_gpu: using CUDA0 backend
|
||||
"""
|
||||
|
||||
# The same start on a card whisper names only by its slot. The card's own name
|
||||
# is one line further up, printed by the backend as it enumerates.
|
||||
WHISPER_VULKAN = """\
|
||||
ggml_vulkan: Found 1 Vulkan devices:
|
||||
ggml_vulkan: 0 = AMD Radeon RX 6600 (RADV NAVI23) (radv) | uma: 0 | fp16: dot2
|
||||
load_backend: loaded Vulkan backend from /usr/lib/ggml/libggml-vulkan.so
|
||||
load_backend: loaded CPU backend from /usr/lib/ggml/libggml-cpu-haswell.so
|
||||
whisper_model_load: Vulkan0 total size = 189.49 MB
|
||||
whisper_backend_init_gpu: device 0: Vulkan0 (type: 1)
|
||||
whisper_backend_init_gpu: using Vulkan0 backend
|
||||
"""
|
||||
|
||||
# A whisper built by hand on a Mac: Metal is compiled in rather than loaded, so
|
||||
# there is no line to read and no honest answer but "it did not say".
|
||||
WHISPER_QUIET = """\
|
||||
whisper_init_from_file_with_params_no_state: loading model from 'ggml-base.bin'
|
||||
whisper_model_load: model size = 147.37 MB
|
||||
"""
|
||||
|
||||
LLAMA_GPU = """\
|
||||
load_backend: loaded Vulkan backend from /opt/llama/libggml-vulkan.so
|
||||
load_backend: loaded CPU backend from /opt/llama/libggml-cpu.so
|
||||
load_tensors: offloading 28 repeating layers to GPU
|
||||
load_tensors: offloaded 29/29 layers to GPU
|
||||
load_tensors: Vulkan0 model buffer size = 2048.00 MiB
|
||||
"""
|
||||
|
||||
LLAMA_CPU = """\
|
||||
load_backend: loaded Vulkan backend from /opt/llama/libggml-vulkan.so
|
||||
load_backend: loaded CPU backend from /opt/llama/libggml-cpu.so
|
||||
load_tensors: offloaded 0/29 layers to GPU
|
||||
"""
|
||||
|
||||
|
||||
# A downloaded processor-only build pointed at the system's Vulkan backend
|
||||
# through GGML_BACKEND_PATH. whisper numbers every device it can see in one
|
||||
# sequence, so the card is its device 1 while still being Vulkan0.
|
||||
WHISPER_LENT_BACKEND = """\
|
||||
load_backend: loaded CPU backend from /data/bin/whisper/libggml-cpu-haswell.so
|
||||
ggml_vulkan: Found 1 Vulkan devices:
|
||||
ggml_vulkan: 0 = AMD Radeon RX 6600 (RADV NAVI23) (radv) | uma: 0
|
||||
load_backend: loaded Vulkan backend from /usr/lib/ggml/libggml-vulkan.so
|
||||
whisper_model_load: Vulkan0 total size = 189.49 MB
|
||||
whisper_backend_init_gpu: device 0: CPU (type: 0)
|
||||
whisper_backend_init_gpu: device 1: Vulkan0 (type: 1)
|
||||
whisper_backend_init_gpu: found GPU device 1: Vulkan0 (type: 1, cnt: 0)
|
||||
whisper_backend_init_gpu: using Vulkan0 backend
|
||||
"""
|
||||
|
||||
# Two cards, and the one that ran is not the one in the slot the handle names.
|
||||
# Reading whisper's listing by the handle's digit would name the other card.
|
||||
WHISPER_TWO_CARDS = """\
|
||||
ggml_vulkan: Found 1 Vulkan devices:
|
||||
ggml_vulkan: 0 = AMD Radeon RX 6600 (RADV NAVI23) (radv) | uma: 0
|
||||
load_backend: loaded CUDA backend from /opt/whisper/libggml-cuda.so
|
||||
load_backend: loaded Vulkan backend from /opt/whisper/libggml-vulkan.so
|
||||
load_backend: loaded CPU backend from /opt/whisper/libggml-cpu-haswell.so
|
||||
whisper_model_load: Vulkan0 total size = 189.49 MB
|
||||
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 (type: 1)
|
||||
whisper_backend_init_gpu: device 1: Vulkan0 (type: 1)
|
||||
whisper_backend_init_gpu: using Vulkan0 backend
|
||||
"""
|
||||
|
||||
|
||||
class WhatItRunsOn(Local):
|
||||
"""Reading the backend back out of the log the server wrote."""
|
||||
|
||||
def log(self, text):
|
||||
path = self.path("server.log")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
def read(self, program, text):
|
||||
return ggml._read_accel(program, self.log(text))
|
||||
|
||||
def test_a_card_that_was_asked_for_and_not_found_is_the_processor(self):
|
||||
accel = self.read(ggml.WHISPER, WHISPER_CPU)
|
||||
self.assertEqual(accel.backend, "CPU")
|
||||
self.assertEqual(ggml.accel_kind(accel), "cpu")
|
||||
|
||||
def test_only_loaded_backends_are_reported(self):
|
||||
self.assertTrue(ggml.cpu_only_loaded(self.read(ggml.WHISPER, WHISPER_CPU)))
|
||||
self.assertFalse(ggml.cpu_only_loaded(self.read(ggml.WHISPER, WHISPER_CUDA)))
|
||||
|
||||
def test_a_card_that_was_found_is_named(self):
|
||||
accel = self.read(ggml.WHISPER, WHISPER_CUDA)
|
||||
self.assertEqual(accel.backend, "CUDA")
|
||||
self.assertEqual(accel.device, "NVIDIA GeForce RTX 4070")
|
||||
self.assertEqual(ggml.accel_kind(accel), "gpu")
|
||||
self.assertEqual(ggml.accel_detail(accel),
|
||||
"CUDA, NVIDIA GeForce RTX 4070")
|
||||
|
||||
def test_a_card_named_only_by_its_slot_is_looked_up(self):
|
||||
accel = self.read(ggml.WHISPER, WHISPER_VULKAN)
|
||||
self.assertEqual(accel.backend, "Vulkan")
|
||||
# "Vulkan0" says which slot; the point of the line is which card.
|
||||
self.assertEqual(accel.device, "AMD Radeon RX 6600 (RADV NAVI23)")
|
||||
|
||||
def test_the_driver_behind_the_card_is_not_part_of_its_name(self):
|
||||
# "(radv)" is how it is reached; "(RADV NAVI23)" is what it is called.
|
||||
self.assertNotIn("(radv)",
|
||||
self.read(ggml.WHISPER, WHISPER_VULKAN).device)
|
||||
|
||||
def test_a_card_numbered_one_way_and_handled_another_is_still_named(self):
|
||||
accel = self.read(ggml.WHISPER, WHISPER_LENT_BACKEND)
|
||||
self.assertEqual(accel.backend, "Vulkan")
|
||||
self.assertEqual(accel.device, "AMD Radeon RX 6600 (RADV NAVI23)")
|
||||
|
||||
def test_the_card_named_is_the_one_the_handle_belongs_to(self):
|
||||
# whisper's device 0 is the other card. The handle is Vulkan0, and
|
||||
# Vulkan's own device 0 is the AMD one.
|
||||
accel = self.read(ggml.WHISPER, WHISPER_TWO_CARDS)
|
||||
self.assertEqual(accel.device, "AMD Radeon RX 6600 (RADV NAVI23)")
|
||||
self.assertNotIn("NVIDIA", ggml.accel_detail(accel))
|
||||
|
||||
def test_a_card_that_failed_to_start_is_not_a_card_in_use(self):
|
||||
# It was listed, it was tried, it did not work, and whisper went on
|
||||
# without it. The listing alone would have called this a graphics card.
|
||||
accel = self.read(ggml.WHISPER, WHISPER_GPU_FAILED)
|
||||
self.assertEqual(accel.backend, "CPU")
|
||||
self.assertEqual(ggml.accel_kind(accel), "cpu")
|
||||
|
||||
def test_failed_initialisation_overrides_weights_on_the_card(self):
|
||||
log = WHISPER_GPU_FAILED.replace("CPU total size", "Vulkan0 total size")
|
||||
self.assertEqual(self.read(ggml.WHISPER, log).backend, "CPU")
|
||||
|
||||
def test_a_later_successful_attempt_replaces_the_failed_one(self):
|
||||
log = WHISPER_GPU_FAILED + (
|
||||
"whisper_backend_init_gpu: using Vulkan0 backend\n")
|
||||
self.assertEqual(self.read(ggml.WHISPER, log).backend, "Vulkan")
|
||||
|
||||
def test_the_card_named_is_the_one_that_ran(self):
|
||||
accel = self.read(ggml.WHISPER, WHISPER_MIXED)
|
||||
self.assertEqual(accel.backend, "CUDA")
|
||||
self.assertEqual(accel.device, "NVIDIA GeForce RTX 4070")
|
||||
self.assertNotIn("Intel", ggml.accel_detail(accel))
|
||||
|
||||
def test_a_slot_number_is_not_a_name(self):
|
||||
# "Vulkan0" says which slot; with no listing to look it up in, saying
|
||||
# nothing beats saying that.
|
||||
self.assertEqual(self.read(ggml.LLAMA, LLAMA_GPU).device, "")
|
||||
|
||||
def test_a_log_that_says_nothing_is_not_guessed_at(self):
|
||||
accel = self.read(ggml.WHISPER, WHISPER_QUIET)
|
||||
self.assertEqual(accel.backend, "")
|
||||
self.assertEqual(ggml.accel_kind(accel), "unknown")
|
||||
|
||||
def test_a_log_that_is_not_there_is_not_guessed_at_either(self):
|
||||
self.assertEqual(ggml._read_accel(ggml.WHISPER, self.path("gone.log")),
|
||||
ggml.NO_ACCEL)
|
||||
|
||||
def test_the_layers_llama_offloaded_are_read_back(self):
|
||||
accel = self.read(ggml.LLAMA, LLAMA_GPU)
|
||||
self.assertEqual(accel.backend, "Vulkan")
|
||||
self.assertEqual(accel.layers, "29/29")
|
||||
self.assertEqual(ggml.accel_detail(accel), "Vulkan, 29/29 layers")
|
||||
|
||||
def test_llama_names_the_allocated_device_not_the_first_loaded_backend(self):
|
||||
log = (
|
||||
"load_backend: loaded CUDA backend from /x.so\n"
|
||||
" Device 0: NVIDIA RTX 4070, compute capability 8.9, VMM: yes\n"
|
||||
"ggml_vulkan: 0 = Intel UHD Graphics | uma: 1\n"
|
||||
"ggml_vulkan: 1 = AMD Radeon RX 6600 | uma: 0\n"
|
||||
"load_tensors: CUDA_Host model buffer size = 32.00 MiB\n"
|
||||
"load_tensors: Vulkan0 model buffer size = 0.00 MiB\n"
|
||||
+ LLAMA_GPU.replace("Vulkan0 model", "Vulkan1 model"))
|
||||
accel = self.read(ggml.LLAMA, log)
|
||||
self.assertEqual(accel.backend, "Vulkan")
|
||||
self.assertEqual(accel.device, "AMD Radeon RX 6600")
|
||||
|
||||
def test_llama_without_buffer_evidence_does_not_guess_the_backend(self):
|
||||
log = LLAMA_GPU.replace(
|
||||
"load_tensors: Vulkan0 model buffer size = 2048.00 MiB\n", "")
|
||||
accel = self.read(ggml.LLAMA, log)
|
||||
self.assertEqual((accel.backend, accel.device), ("GPU", ""))
|
||||
self.assertEqual(accel.layers, "29/29")
|
||||
|
||||
def test_llama_split_across_cards_does_not_name_only_one(self):
|
||||
log = LLAMA_GPU + (
|
||||
"load_tensors: Vulkan1 model buffer size = 1024.00 MiB\n")
|
||||
accel = self.read(ggml.LLAMA, log)
|
||||
self.assertEqual((accel.backend, accel.device), ("GPU", ""))
|
||||
|
||||
def test_llama_static_metal_build_can_be_identified_by_its_buffer(self):
|
||||
log = (
|
||||
"ggml_metal_init: picking default device: Apple M2\n"
|
||||
"load_tensors: offloaded 29/29 layers to GPU\n"
|
||||
"load_tensors: Metal model buffer size = 2048.00 MiB\n")
|
||||
accel = self.read(ggml.LLAMA, log)
|
||||
self.assertEqual((accel.backend, accel.device), ("Metal", "Apple M2"))
|
||||
|
||||
def test_a_llama_that_offloaded_nothing_is_on_the_processor(self):
|
||||
accel = self.read(ggml.LLAMA, LLAMA_CPU)
|
||||
self.assertEqual(accel.backend, "CPU")
|
||||
self.assertEqual(ggml.accel_kind(accel), "cpu")
|
||||
# The build could have used the card; this run did not.
|
||||
self.assertFalse(ggml.cpu_only_loaded(accel))
|
||||
|
||||
def test_the_processor_is_not_named_twice(self):
|
||||
# whisper prints CPU as the backend and as the device, and saying it
|
||||
# twice reads like two different things.
|
||||
self.assertEqual(ggml.accel_detail(self.read(ggml.WHISPER, WHISPER_CPU)),
|
||||
"CPU")
|
||||
|
||||
def test_which_copy_is_running_decides_what_advice_is_worth_giving(self):
|
||||
mine = ggml.BIN_DIR / "whisper" / "b1" / "whisper-server"
|
||||
mine.parent.mkdir(parents=True, exist_ok=True)
|
||||
mine.write_text("#!/bin/sh\n")
|
||||
self.assertTrue(ggml.is_downloaded(str(mine)))
|
||||
self.assertFalse(ggml.is_downloaded("/usr/bin/whisper-server"))
|
||||
self.assertFalse(ggml.is_downloaded(""))
|
||||
|
||||
def test_nothing_is_running_is_not_a_backend(self):
|
||||
self.assertEqual(ggml.accel_kind({"running": False, "backend": "CUDA"}),
|
||||
"off")
|
||||
|
||||
|
||||
# --- keeping a server alive -----------------------------------------------
|
||||
|
||||
|
||||
@@ -754,6 +1023,18 @@ STAND_IN = textwrap.dedent("""
|
||||
print("could not load model: no such file")
|
||||
sys.exit(2)
|
||||
|
||||
# The startup chatter a real server prints before it binds, so that the
|
||||
# log has something for _read_accel to find. Flushed, because stdout here
|
||||
# is a file and nothing would reach it before the port opened.
|
||||
if "--backend" in args:
|
||||
print("load_backend: loaded " + opt("--backend") + " backend from /x.so",
|
||||
flush=True)
|
||||
print("whisper_backend_init_gpu: device 0: Test Card (type: 1)",
|
||||
flush=True)
|
||||
# The attempt is followed by no failure in this stand-in.
|
||||
print("whisper_backend_init_gpu: using " + opt("--backend") + "0 backend",
|
||||
flush=True)
|
||||
|
||||
started = time.monotonic()
|
||||
healthy_after = float(opt("--healthy-after", "0"))
|
||||
|
||||
@@ -816,6 +1097,50 @@ class Servers(ServerCase):
|
||||
self.assertRegex(url, r"^http://127\.0\.0\.1:\d+/v1$")
|
||||
self.assertTrue(server.running)
|
||||
|
||||
def test_nothing_started_is_a_state_saying_so(self):
|
||||
state = self.server().state()
|
||||
self.assertFalse(state["running"])
|
||||
self.assertEqual(ggml.accel_kind(state), "off")
|
||||
|
||||
def test_a_running_server_says_what_it_settled_on(self):
|
||||
server = self.server(extra=["--backend", "CUDA"], gpu=True)
|
||||
server.serve()
|
||||
state = server.state()
|
||||
self.assertTrue(state["running"])
|
||||
self.assertIn(f":{state['port']}/v1", server.base_url())
|
||||
self.assertEqual(state["backend"], "CUDA")
|
||||
self.assertEqual(state["device"], "Test Card")
|
||||
self.assertTrue(state["gpu_wanted"])
|
||||
self.assertEqual(ggml.accel_kind(state), "gpu")
|
||||
|
||||
def test_a_setting_changed_mid_start_does_not_rename_what_is_running(self):
|
||||
# A save that lands while the model is being read in finds no process
|
||||
# to stop, so it changes the settings under a start already in flight.
|
||||
# The line must name the model that is loaded, not the one that will be.
|
||||
server = self.server(model="first")
|
||||
launch = server._launch
|
||||
|
||||
def during(settings):
|
||||
result = launch(settings)
|
||||
server.configure(model="second")
|
||||
return result
|
||||
|
||||
self.patch_attr(server, "_launch", during)
|
||||
server.serve()
|
||||
self.assertEqual(server.state()["model"], "first")
|
||||
self.assertEqual(server.settings()["model"], "second")
|
||||
|
||||
def test_stopping_takes_the_backend_with_it(self):
|
||||
server = self.server(extra=["--backend", "CUDA"])
|
||||
server.serve()
|
||||
server.stop()
|
||||
self.assertEqual(server.state()["backend"], "")
|
||||
|
||||
def test_a_server_that_announced_nothing_is_not_guessed_at(self):
|
||||
server = self.server()
|
||||
server.serve()
|
||||
self.assertEqual(ggml.accel_kind(server.state()), "unknown")
|
||||
|
||||
def test_the_second_call_does_not_start_a_second_one(self):
|
||||
server = self.server()
|
||||
first = server.serve()
|
||||
|
||||
+200
-3
@@ -15,8 +15,8 @@ from typing import ClassVar
|
||||
from unittest import mock
|
||||
|
||||
from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
|
||||
from PyQt6.QtGui import QWheelEvent
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
from PyQt6.QtGui import QHideEvent, QShowEvent, QWheelEvent
|
||||
from PyQt6.QtWidgets import QApplication, QComboBox, QMessageBox, QSpinBox, QWidget
|
||||
|
||||
from dikte import audio
|
||||
from dikte import cleanup
|
||||
@@ -77,7 +77,7 @@ CHANGED = {
|
||||
"local_model": "ggml-small.bin",
|
||||
"local_gpu": False,
|
||||
"local_preload": False,
|
||||
"local_threads": 6,
|
||||
"local_threads": 1,
|
||||
"local_llm_model": "gemma-3-4b-it-Q4_K_M.gguf",
|
||||
"local_llm_repo": "ggml-org/gemma-4-E2B-it-GGUF",
|
||||
"local_llm_gpu": False,
|
||||
@@ -230,6 +230,51 @@ class Settings(DikteTest):
|
||||
QApplication.sendEvent(box, self.wheel())
|
||||
self.assertNotEqual(box.currentIndex(), before)
|
||||
|
||||
def test_the_wheel_uses_remembered_focus_in_an_inactive_window(self):
|
||||
# Keep the window hidden so no desktop activation policy can give it
|
||||
# keyboard focus. Its remembered focus still selects the wheel target.
|
||||
for widget_type in (QComboBox, QSpinBox):
|
||||
with self.subTest(widget=widget_type.__name__):
|
||||
window = QWidget()
|
||||
self.addCleanup(window.deleteLater)
|
||||
box = widget_type(window)
|
||||
other = QComboBox(window)
|
||||
if isinstance(box, QComboBox):
|
||||
box.addItems(["first", "second", "third"])
|
||||
box.setCurrentIndex(1)
|
||||
value = box.currentIndex
|
||||
else:
|
||||
box.setValue(5)
|
||||
value = box.value
|
||||
box.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
guard = settings_ui.WheelGuard(window)
|
||||
box.installEventFilter(guard)
|
||||
box.setFocus()
|
||||
self.assertFalse(window.isActiveWindow())
|
||||
self.assertFalse(box.hasFocus())
|
||||
self.assertIs(window.focusWidget(), box)
|
||||
before = value()
|
||||
QApplication.sendEvent(box, self.wheel())
|
||||
self.assertNotEqual(value(), before)
|
||||
other.setFocus()
|
||||
self.assertIs(window.focusWidget(), other)
|
||||
before = value()
|
||||
rolled = self.wheel()
|
||||
QApplication.sendEvent(box, rolled)
|
||||
self.assertEqual(value(), before)
|
||||
self.assertFalse(rolled.isAccepted())
|
||||
|
||||
def test_the_wheel_is_refused_when_another_widget_has_focus(self):
|
||||
window = self.window(cfg.Config())
|
||||
box = window.ui_language
|
||||
other = window.corner
|
||||
other.setFocus()
|
||||
before = box.currentIndex()
|
||||
rolled = self.wheel()
|
||||
QApplication.sendEvent(box, rolled)
|
||||
self.assertEqual(box.currentIndex(), before)
|
||||
self.assertFalse(rolled.isAccepted())
|
||||
|
||||
def test_a_wrapped_label_keeps_the_room_its_lines_need(self):
|
||||
# The program path shares a row with a button, and a row is measured
|
||||
# before its width is known: the label has to claim the second line back
|
||||
@@ -326,6 +371,56 @@ class Settings(DikteTest):
|
||||
self.assertEqual(shown, [provider])
|
||||
self.assertFalse(box.isHidden())
|
||||
|
||||
def test_editable_boxes_live_in_forms_that_grow_the_field_column(self):
|
||||
window = self.window(cfg.Config())
|
||||
|
||||
def contains(layout, target):
|
||||
for index in range(layout.count()):
|
||||
item = layout.itemAt(index)
|
||||
widget = item.widget()
|
||||
if widget is target or (widget is not None and
|
||||
widget.isAncestorOf(target)):
|
||||
return True
|
||||
child = item.layout()
|
||||
if child is not None and contains(child, target):
|
||||
return True
|
||||
return False
|
||||
|
||||
forms = window.findChildren(settings_ui.QFormLayout)
|
||||
boxes = [
|
||||
window.paste_shortcut,
|
||||
window.transcribe_model,
|
||||
window.file_model,
|
||||
window.cleanup_model,
|
||||
window.cleanup_gemini_model,
|
||||
window.cleanup_opencode_model,
|
||||
window.cleanup_agy_model,
|
||||
window.cleanup_claude_model,
|
||||
window.cleanup_codex_model,
|
||||
window.assistant_model,
|
||||
window.assistant_agy_model,
|
||||
window.assistant_opencode_model,
|
||||
window.assistant_codex_model,
|
||||
window.assistant_openrouter_model,
|
||||
window.meeting_model,
|
||||
*(box for box, _status, _missing in
|
||||
window._shortcut_rows.values()),
|
||||
]
|
||||
for box in boxes:
|
||||
form = next((candidate for candidate in forms
|
||||
if contains(candidate, box)), None)
|
||||
with self.subTest(box=box.objectName() or box.currentText()):
|
||||
self.assertIsNotNone(form)
|
||||
self.assertEqual(
|
||||
form.fieldGrowthPolicy(),
|
||||
settings_ui.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
window.local_llm.layout().fieldGrowthPolicy(),
|
||||
settings_ui.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow,
|
||||
)
|
||||
|
||||
def test_codex_answering_refills_both_of_its_boxes(self):
|
||||
"""The list Codex gave replaces the built-in one, in both places, and
|
||||
neither loses what was already picked."""
|
||||
@@ -1346,6 +1441,73 @@ class LocalModels(DikteTest):
|
||||
self.window(conf)._save()
|
||||
self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin")
|
||||
|
||||
def state(self, **values):
|
||||
base = {"running": True, "pid": 3, "port": 4321, "model": "ggml-small.bin",
|
||||
"gpu_wanted": True, "backend": "CUDA", "device": "RTX 4070",
|
||||
"layers": "", "available": ["CUDA", "CPU"]}
|
||||
base.update(values)
|
||||
return base
|
||||
|
||||
def shown(self, **values):
|
||||
"""The line the window writes under the local model boxes."""
|
||||
window = self.window(self.config(transcribe_provider="local"))
|
||||
with mock.patch.object(ggml, "state",
|
||||
return_value={"whisper": self.state(**values),
|
||||
"llama": self.state(running=False)}):
|
||||
window._show_local_state()
|
||||
return window.local_state.text(), window.local_llm_state.text()
|
||||
|
||||
def test_a_loaded_model_says_which_card_it_is_on(self):
|
||||
whisper, llm = self.shown()
|
||||
self.assertIn("graphics card", whisper)
|
||||
self.assertIn("RTX 4070", whisper)
|
||||
# The other box is about the other model, and that one is not loaded.
|
||||
self.assertIn("Not loaded", llm)
|
||||
|
||||
def test_a_card_asked_for_and_missing_is_not_left_to_be_guessed_at(self):
|
||||
whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"])
|
||||
self.assertIn("processor", whisper)
|
||||
self.assertIn("only the CPU backend was loaded", whisper)
|
||||
|
||||
def test_a_download_is_not_assumed_to_lack_gpu_support(self):
|
||||
whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"],
|
||||
downloaded=True)
|
||||
self.assertIn("only the CPU backend was loaded", whisper)
|
||||
self.assertIn("driver errors", whisper)
|
||||
self.assertNotIn("installing one", whisper)
|
||||
|
||||
def test_a_system_build_is_not_told_to_install_itself(self):
|
||||
whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"],
|
||||
downloaded=False)
|
||||
self.assertIn("only the CPU backend was loaded", whisper)
|
||||
self.assertNotIn("Dikte downloaded", whisper)
|
||||
|
||||
def test_a_build_that_could_have_used_one_says_the_other_thing(self):
|
||||
whisper, _ = self.shown(backend="CPU", device="CPU",
|
||||
available=["CUDA", "CPU"])
|
||||
self.assertIn("could not be used", whisper)
|
||||
self.assertNotIn("no graphics backend", whisper)
|
||||
|
||||
def test_a_processor_nobody_argued_about_is_stated_plainly(self):
|
||||
whisper, _ = self.shown(backend="CPU", device="CPU", gpu_wanted=False,
|
||||
available=["CPU"])
|
||||
self.assertEqual(whisper, "Loaded on the processor (CPU).")
|
||||
|
||||
def test_a_server_that_said_nothing_is_not_answered_for(self):
|
||||
"""A whisper built by hand on a Mac prints no backend line at all."""
|
||||
whisper, _ = self.shown(backend="", device="", available=[])
|
||||
self.assertIn("did not say", whisper)
|
||||
|
||||
def test_the_line_stops_being_written_while_the_window_is_away(self):
|
||||
# The events rather than show() and hide(): showing the window for real
|
||||
# would send the same event down to the download boxes, which answer it
|
||||
# by asking Hugging Face what models there are.
|
||||
window = self.window(self.config(transcribe_provider="local"))
|
||||
window.showEvent(QShowEvent())
|
||||
self.assertTrue(window._local_state_timer.isActive())
|
||||
window.hideEvent(QHideEvent())
|
||||
self.assertFalse(window._local_state_timer.isActive())
|
||||
|
||||
def test_nothing_is_fetched_for_a_window_nobody_opened(self):
|
||||
# DikteTest closes the network, so a request would fail the test. The
|
||||
# lists are asked for when the box is shown, not when it is built.
|
||||
@@ -1461,6 +1623,29 @@ class LocalModels(DikteTest):
|
||||
self.assertNotIn("Ready", box.status.text())
|
||||
self.assertIn("program", box.status.text())
|
||||
|
||||
def test_a_program_set_in_the_settings_is_not_called_downloaded(self):
|
||||
mine = self.path("my-whisper-server")
|
||||
mine.write_text("#!/bin/sh\n")
|
||||
mine.chmod(0o755)
|
||||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||||
box = self.window(self.config(local_binary=str(mine))).local_whisper
|
||||
self.assertIn(str(mine), box.program_label.text())
|
||||
self.assertFalse(box.install_button.isVisibleTo(box))
|
||||
|
||||
def test_a_model_over_a_program_set_by_hand_is_ready(self):
|
||||
# The program is there, it is just named by the settings rather than
|
||||
# downloaded, and the status line looked past it.
|
||||
mine = self.path("my-whisper-server")
|
||||
mine.write_text("#!/bin/sh\n")
|
||||
mine.chmod(0o755)
|
||||
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||||
path = ggml.whisper_model_path("ggml-small.bin")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"not really a model")
|
||||
box = self.window(self.config(local_binary=str(mine))).local_whisper
|
||||
box.load("ggml-small.bin")
|
||||
self.assertIn("Ready", box.status.text())
|
||||
|
||||
def test_changing_the_publisher_changes_the_model(self):
|
||||
# The model chosen under the old publisher is not published by the new
|
||||
# one. Carried over, it was added back as "not downloaded" and selected
|
||||
@@ -1781,3 +1966,15 @@ class LocalModels(DikteTest):
|
||||
# isHidden rather than isVisible: the window itself is never
|
||||
# shown in a test, so nothing in it is ever visible.
|
||||
self.assertEqual(other.isHidden(), name != chosen)
|
||||
|
||||
def test_local_threads_range_is_bounded_by_cpu_count(self):
|
||||
with mock.patch("os.cpu_count", return_value=8):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertEqual(window.local_threads.minimum(), 0)
|
||||
self.assertEqual(window.local_threads.maximum(), 8)
|
||||
|
||||
def test_local_threads_range_has_safe_minimum_when_cpu_count_is_none(self):
|
||||
with mock.patch("os.cpu_count", return_value=None):
|
||||
window = self.window(cfg.Config())
|
||||
self.assertEqual(window.local_threads.minimum(), 0)
|
||||
self.assertEqual(window.local_threads.maximum(), 1)
|
||||
|
||||
+57
-11
@@ -35,7 +35,7 @@ class Chain(DikteTest):
|
||||
cleaned="Book it for Thursday.",
|
||||
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
||||
clipboard=b"what was there before", paste_error=None,
|
||||
focus=None):
|
||||
detected="en", focus=None):
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
done, failures, stages, cancels = [], [], [], []
|
||||
pipeline.finished.connect(lambda *args: done.append(args))
|
||||
@@ -45,14 +45,19 @@ class Chain(DikteTest):
|
||||
|
||||
cleanup = (mock.Mock(side_effect=cleanup_error) if cleanup_error
|
||||
else mock.Mock(return_value=cleaned))
|
||||
# Auto mode takes the detection path; a fixed language the plain one.
|
||||
# Both are mocked so the chain runs either way without a server.
|
||||
behavior = {"side_effect": transcribe_error} if transcribe_error \
|
||||
else {"return_value": transcript}
|
||||
detect_behavior = {"side_effect": transcribe_error} if transcribe_error \
|
||||
else {"return_value": (transcript, detected)}
|
||||
calls = {}
|
||||
# The chain reports its own failures on stderr, which a test run has no
|
||||
# use for.
|
||||
with contextlib.redirect_stderr(io.StringIO()), \
|
||||
mock.patch.object(
|
||||
api, "transcribe",
|
||||
**({"side_effect": transcribe_error} if transcribe_error
|
||||
else {"return_value": transcript})) as tr, \
|
||||
mock.patch.object(api, "transcribe", **behavior) as tr, \
|
||||
mock.patch.object(api, "transcribe_detected",
|
||||
**detect_behavior) as tdet, \
|
||||
mock.patch.object(api, "cleanup", cleanup), \
|
||||
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
|
||||
mock.patch.object(paste, "copy") as copy, \
|
||||
@@ -62,7 +67,8 @@ class Chain(DikteTest):
|
||||
return_value=clipboard) as read_clipboard, \
|
||||
mock.patch.object(worker.time, "sleep", lambda seconds: None):
|
||||
press.side_effect = paste_error
|
||||
calls = {"transcribe": tr, "cleanup": cleanup, "ask": ask_call,
|
||||
calls = {"transcribe": tr, "transcribe_detected": tdet,
|
||||
"cleanup": cleanup, "ask": ask_call,
|
||||
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
||||
"read_clipboard": read_clipboard}
|
||||
pipeline._work(self.wav, duration,
|
||||
@@ -77,7 +83,8 @@ class Chain(DikteTest):
|
||||
run = self.run_chain()
|
||||
self.assertEqual(run["failures"], [])
|
||||
self.assertEqual(run["done"][0],
|
||||
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
||||
("uh, book it for Thursday", "Book it for Thursday.",
|
||||
"", "en"))
|
||||
run["copy"].assert_called_once_with("Book it for Thursday.")
|
||||
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
|
||||
focus=None)
|
||||
@@ -129,7 +136,7 @@ class Chain(DikteTest):
|
||||
self.conf["restore_clipboard"] = True
|
||||
run = self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||
self.assertEqual(run["failures"], [])
|
||||
raw, text, warning = run["done"][0]
|
||||
raw, text, warning, _lang = run["done"][0]
|
||||
self.assertIn("not trusted", warning)
|
||||
run["copy_bytes"].assert_not_called()
|
||||
|
||||
@@ -182,17 +189,41 @@ class Chain(DikteTest):
|
||||
self.assertEqual(run["transcribe"].call_args.kwargs["language"], "tr")
|
||||
self.assertEqual(run["transcribe"].call_args.kwargs["prompt"], "Paraşüt")
|
||||
|
||||
def test_auto_mode_asks_for_the_detected_language_and_records_it(self):
|
||||
run = self.run_chain(detected="tr")
|
||||
told = run["transcribe_detected"].call_args.kwargs
|
||||
self.assertEqual(told["language"], "auto")
|
||||
self.assertEqual(cfg.read_history()[0]["speech_language"], "tr")
|
||||
self.assertEqual(run["done"][0][3], "tr")
|
||||
run["transcribe"].assert_not_called()
|
||||
|
||||
def test_the_detected_language_is_told_to_the_cleanup_prompt(self):
|
||||
# The mock stands in for api.cleanup, which the cleanup module calls
|
||||
# with (text, key, model, system_prompt, …); the prompt is the fourth.
|
||||
self.conf["transcribe_prompt"] = "Paraşüt"
|
||||
run = self.run_chain(detected="tr")
|
||||
prompt = run["cleanup"].call_args.args[3]
|
||||
# Turkish was detected, so the Turkish glossary rule is appended.
|
||||
self.assertIn("KONUŞMACININ KULLANDIĞI İSİM VE TERİMLER", prompt)
|
||||
|
||||
def test_a_fixed_language_needs_no_detection(self):
|
||||
self.conf["language"] = "en"
|
||||
run = self.run_chain()
|
||||
run["transcribe"].assert_called_once()
|
||||
run["transcribe_detected"].assert_not_called()
|
||||
self.assertEqual(cfg.read_history()[0]["speech_language"], "en")
|
||||
|
||||
# ---- silence and stock phrases ----------------------------------------
|
||||
|
||||
def test_room_tone_costs_no_api_call(self):
|
||||
run = self.run_chain(rms=[0.00001] * 60)
|
||||
run["transcribe"].assert_not_called()
|
||||
run["transcribe_detected"].assert_not_called()
|
||||
self.assertIn("No speech", run["failures"][0])
|
||||
|
||||
def test_the_silence_check_can_be_switched_off(self):
|
||||
self.conf["skip_silent"] = False
|
||||
run = self.run_chain(rms=[0.00001] * 60)
|
||||
run["transcribe"].assert_called_once()
|
||||
run["transcribe_detected"].assert_called_once()
|
||||
|
||||
def test_a_stock_phrase_from_a_short_clip_is_thrown_away(self):
|
||||
run = self.run_chain(duration=2.0, transcript="Altyazı M.K.")
|
||||
@@ -208,7 +239,7 @@ class Chain(DikteTest):
|
||||
|
||||
def test_a_failed_cleanup_still_pastes_the_transcript(self):
|
||||
run = self.run_chain(cleanup_error=api.ApiError("rate limited"))
|
||||
_raw, text, warning = run["done"][0]
|
||||
_raw, text, warning, _lang = run["done"][0]
|
||||
self.assertEqual(text, "uh, book it for Thursday")
|
||||
self.assertIn("rate limited", warning)
|
||||
run["copy"].assert_called_once_with("uh, book it for Thursday")
|
||||
@@ -220,6 +251,9 @@ class Chain(DikteTest):
|
||||
self.assertEqual(cfg.read_history()[0]["cleanup_error"], "bad key")
|
||||
|
||||
def test_a_failed_transcription_ends_the_run(self):
|
||||
# This path mocks api.transcribe, so it wants
|
||||
# the plain (fixed-language) transcription.
|
||||
self.conf["language"] = "tr"
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
@@ -231,6 +265,9 @@ class Chain(DikteTest):
|
||||
copy.assert_not_called()
|
||||
|
||||
def test_a_clipboard_that_will_not_take_it(self):
|
||||
# This path mocks api.transcribe, so it wants
|
||||
# the plain (fixed-language) transcription.
|
||||
self.conf["language"] = "tr"
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
@@ -243,6 +280,9 @@ class Chain(DikteTest):
|
||||
self.assertIn("wl-copy", failures[0])
|
||||
|
||||
def test_an_unexpected_error_is_reported_rather_than_swallowed(self):
|
||||
# This path mocks api.transcribe, so it wants
|
||||
# the plain (fixed-language) transcription.
|
||||
self.conf["language"] = "tr"
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
@@ -280,6 +320,9 @@ class Chain(DikteTest):
|
||||
run["press"].assert_not_called()
|
||||
|
||||
def test_a_command_that_was_cancelled(self):
|
||||
# This path mocks api.transcribe, so it wants
|
||||
# the plain (fixed-language) transcription.
|
||||
self.conf["language"] = "tr"
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
cancels = []
|
||||
pipeline.cancelled.connect(lambda: cancels.append(True))
|
||||
@@ -289,6 +332,9 @@ class Chain(DikteTest):
|
||||
self.assertEqual(cancels, [True])
|
||||
|
||||
def test_an_agent_that_is_not_installed(self):
|
||||
# This path mocks api.transcribe, so it wants
|
||||
# the plain (fixed-language) transcription.
|
||||
self.conf["language"] = "tr"
|
||||
pipeline = worker.Pipeline(self.conf)
|
||||
failures = []
|
||||
pipeline.failed.connect(failures.append)
|
||||
|
||||
Reference in New Issue
Block a user