Transcribe and clean up on this machine, without installing anything first

whisper-server is started on --inference-path /v1/audio/transcriptions,
which is exactly the path api.py already builds for the hosted providers,
and llama-server answers /chat/completions the way OpenRouter does. So the
local half is one more base URL rather than a second code path: worker.py,
filetranscribe.py and meeting.py are untouched, and dictation, subtitles
and meetings all work here on the first try.

Three findings worth naming, none of them in the new code:

whisper.cpp cuts segments on tokens, which in Turkish lands inside a word
about as often as between two. Pasted raw that gives "akraba değ\niller.";
in a subtitle it gives a cue reading "değ". Whisper marks the start of a
word with a leading space, so a piece that does not begin with one
continues the word above it.

A small model will repeat the transcript until the context is full, and
every one of those tokens is a second of somebody waiting: measured at 206
seconds, and 25 with a ceiling on the reply. Hosted models are left alone,
where the same runaway is rare and a ceiling would cut the minutes short.

A server outlives SIGTERM and SIGKILL holding its model in memory. Signals
are now turned into an event Qt delivers, since Qt blocks in C where a
Python handler never runs, and a pid file lets the next start sweep up
what a SIGKILL left behind.

The minutes keep their own provider rather than following cleanup's. The
two jobs are not the same size: a 4B model here will strip the filler words
out of a dictation and will not write up an hour long meeting.

The suite runs offline now: a test that reaches the network says so instead
of quietly going there.
This commit is contained in:
yusufipk
2026-08-01 20:00:35 +03:00
parent c0b892f53c
commit 2cfbbb2d99
16 changed files with 1194 additions and 120 deletions
+13 -9
View File
@@ -1,9 +1,9 @@
# Dikte
Press `Ctrl+Space`, talk, press again. The recording goes to OpenAI or OpenRouter
for transcription, a model on OpenRouter cleans it up (dropping the *uh*s, the
restarts, the missing punctuation), and the result lands in your clipboard and
is pasted into whatever window you were typing in.
Press `Ctrl+Space`, talk, press again. The recording is transcribed on this
machine by default, a model cleans it up (dropping the *uh*s, the restarts, the
missing punctuation), and the result lands in your clipboard and is pasted into
whatever window you were typing in.
Built for KDE Plasma 6 on Wayland. No dependencies beyond system packages:
just the Python standard library and PyQt6.
@@ -39,10 +39,12 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
settings window installs a GNOME or KDE global shortcut.
Two keys go in the settings window: **OpenAI** and **OpenRouter**. Speech to text
runs on either one (`gpt-4o-transcribe` by default), cleanup always on
OpenRouter (`google/gemini-3.5-flash-lite`), so a single OpenRouter key can
cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
Speech to text and cleanup each pick a provider in the settings window. Both can
run here, on whisper.cpp and llama.cpp: the program and the model are downloaded
from that window (checksummed, into `~/.local/share/dikte`), so nothing has to be
installed first and nothing leaves the machine. The hosted alternatives want a
key: **OpenAI** or **OpenRouter** for speech to text, **OpenRouter** for cleanup
(`google/gemini-3.5-flash-lite`), so a single OpenRouter key can cover both. They fall back to `OPENAI_API_KEY` and `OPENROUTER_API_KEY`, and are
stored in `~/.config/dikte/config.json`, mode 600. Cleanup can be switched off,
in which case the raw transcript is pasted, and a thinking model's effort can be
set next to it.
@@ -144,7 +146,9 @@ ipc.py one request and one reply over the local socket
audio.py PCM capture: pw-record for dictation, ffmpeg for a meeting
meeting.py channel split, speaker labelling, cleanup, minutes
assistant.py running a dictation through Claude Code, Codex or OpenRouter
api.py transcription on either provider, OpenRouter cleanup (stdlib only)
api.py transcription and cleanup on any provider (stdlib only)
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
hub.py what GitHub and Hugging Face have on offer today
worker.py transcribe → clean up → clipboard → paste
vad.py deciding whether a recording holds speech at all
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
+14 -9
View File
@@ -1,9 +1,8 @@
# Dikte
`Ctrl+Space`'e bas, konuş, tekrar bas. Ses OpenAI'ye ya da OpenRouter'a gidip
yazıya çevrilir, OpenRouter'daki bir model transkripti temizler (ıı'lar,
tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye
yapıştırılır.
`Ctrl+Space`'e bas, konuş, tekrar bas. Ses varsayılan olarak bu makinede yazıya
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır.
KDE Plasma 6 / Wayland için yazıldı. Sistem paketleri dışında bağımlılığı yok:
sadece Python standart kütüphanesi ve PyQt6.
@@ -39,10 +38,14 @@ sudo apt install pulseaudio-utils xclip xdotool ffmpeg
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
Ayarlar penceresinde iki anahtar istenir: **OpenAI** ve **OpenRouter**. Sesi
yazıya çevirme ikisinden birinde çalışır (varsayılan `gpt-4o-transcribe`),
temizleme her zaman OpenRouter'da (`google/gemini-3.5-flash-lite`), yani tek bir
OpenRouter anahtarı ikisine de yeter. Boş bırakırsan `OPENAI_API_KEY` ve
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
seçer. İkisi de burada çalışabilir, whisper.cpp ve llama.cpp üzerinde: program
da model de o pencereden indirilir (sha256 doğrulamasıyla,
`~/.local/share/dikte` altına), yani önceden hiçbir şey kurman gerekmez ve
makineden hiçbir şey çıkmaz. Bulut seçenekleri anahtar ister: yazıya çevirme
için **OpenAI** ya da **OpenRouter**, temizleme için **OpenRouter**
(`google/gemini-3.5-flash-lite`), yani tek bir OpenRouter anahtarı ikisine de
yeter. Boş bırakırsan `OPENAI_API_KEY` ve
`OPENROUTER_API_KEY` kullanılır; anahtarlar `~/.config/dikte/config.json`
içinde, izinler 600. Temizlemeyi tamamen kapatabilirsin, o zaman ham transkript
yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebilirsin.
@@ -142,7 +145,9 @@ ipc.py yerel sokette bir istek, bir cevap
audio.py PCM kaydı: diktede pw-record, toplantıda ffmpeg
meeting.py kanal ayırma, konuşmacı etiketi, temizleme, tutanak
assistant.py dikteyi Claude Code, Codex ya da OpenRouter'dan geçirme
api.py iki sağlayıcıda transkript + OpenRouter temizleme (yalnız stdlib)
api.py her sağlayıcıda transkript ve temizleme (yalnız stdlib)
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
hub.py GitHub ve Hugging Face'te bugün ne olduğu
worker.py transkript → temizleme → pano → yapıştırma
vad.py kayıtta gerçekten konuşma var mı kararı
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
+176 -32
View File
@@ -1,9 +1,14 @@
"""OpenAI and OpenRouter calls, stdlib only.
"""OpenAI, OpenRouter and this machine, stdlib only.
Transcription runs on either provider: OpenRouter mirrors OpenAI's
/audio/transcriptions endpoint field for field, so one multipart request serves
both and only the key, the base URL and the model id change. Cleanup is always
OpenRouter.
Transcription runs on any of three providers and cleanup on two, and none of
them needs code of its own. OpenRouter mirrors OpenAI's /audio/transcriptions
endpoint field for field, and ggml.py starts whisper.cpp on that same path, so
one multipart request serves all three; llama.cpp answers /chat/completions the
way OpenRouter does, so one JSON request serves both. What changes between them
is the key, the base URL and the model id.
The local ones have no key, and their base URL is not known until a server is
up, which is the one thing this module has to fill in for them.
"""
import collections
@@ -14,6 +19,7 @@ import secrets
import urllib.error
import urllib.request
import ggml
from i18n import t
APP_URL = "https://github.com/yusufipk/dikte"
@@ -21,14 +27,29 @@ USER_AGENT = f"dikte/1.0 (+{APP_URL})"
OPENAI_URL = "https://api.openai.com/v1"
OPENROUTER_URL = "https://openrouter.ai/api/v1"
# Where a transcription request goes; built by config.Config.transcribe_target().
# `service` is the name the user sees in an error, `provider` the one the code
# branches on.
Target = collections.namedtuple("Target", "provider service api_key base_url model")
# The floor for a local request. The timeouts elsewhere are sized for a hosted
# API, where a slow answer is a bill running; here the only thing being spent is
# time, and a long recording on a machine without a graphics card takes a good
# deal of it. Cutting that off would throw the work away for nothing.
LOCAL_TIMEOUT = 3600
# Where a request goes; built by config.Config's *_target() methods. `service`
# is the name the user sees in an error, `provider` the one the code branches
# on. `reasoning` is only read by cleanup, which is the only job with a model
# that might think about anything.
Target = collections.namedtuple(
"Target", "provider service api_key base_url model reasoning", defaults=("",))
def timestamp_model(provider):
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id."""
def timestamp_model(provider, model):
"""Only whisper-1 returns segment times, and OpenRouter namespaces the id.
Whisper is what the local server runs whatever the file is called, so there
it stays on the model that is already loaded; asking for another one would
name a model that server has never heard of.
"""
if provider == "local":
return model
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
@@ -106,7 +127,11 @@ def _multipart(fields, file_field, file_path):
def _headers(provider, api_key, content_type=None):
headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT}
headers = {"User-Agent": USER_AGENT}
# A server on this machine has nothing to authorise, and sending it a
# bearer token would only be a made-up one.
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if content_type:
headers["Content-Type"] = content_type
if provider == "openrouter":
@@ -116,17 +141,44 @@ def _headers(provider, api_key, content_type=None):
return headers
def _serving(target, server, timeout):
"""A local target with the address of a running server in it.
The server is started on demand and picks its own port, so this is the first
moment its address exists. serve() is idempotent: once it is up this costs
nothing.
"""
try:
return target._replace(base_url=server.serve()), max(timeout, LOCAL_TIMEOUT)
except ggml.LocalError as exc:
raise ApiError(str(exc)) from None
def _local_failure(target, server, exc):
"""A server that died mid-request, explained by its own output.
Without this the message is that the connection dropped, when the reason for
it was printed by the process at the other end.
"""
detail = server.error()
return ApiError(f"{target.service}: {exc}" + (f" ({detail})" if detail else ""),
exc.status)
def _transcribe_request(target, wav_path, language, prompt, response_format,
granularity=None, timeout=300):
if not target.api_key:
if target.provider == "local":
target, timeout = _serving(target, ggml.whisper, timeout)
elif not target.api_key:
raise ApiError(t("{service} API key is empty. Add it in Settings.",
service=target.service))
fields = [("model", target.model), ("response_format", response_format)]
if language and language != "auto":
fields.append(("language", language))
# 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.
if prompt and target.provider == "openai":
# The same words still reach the cleanup model as a glossary. whisper.cpp
# takes it as the initial prompt, the way OpenAI does.
if prompt and target.provider in ("openai", "local"):
fields.append(("prompt", prompt))
if granularity:
fields.append(("timestamp_granularities[]", granularity))
@@ -137,14 +189,58 @@ def _transcribe_request(target, wav_path, language, prompt, response_format,
_headers(target.provider, target.api_key, ctype), timeout=timeout,
)
except ApiError as exc:
if target.provider == "local":
raise _local_failure(target, ggml.whisper, exc) from None
raise explain(exc, target.service) from None
# Whisper marks the start of a word with a leading space, so a piece of text
# that does not begin with one continues the word before it rather than starting
# a new one. Both helpers below turn on that.
def _continues_a_word(previous, following):
return bool(previous) and not previous[-1:].isspace() and not following[:1].isspace()
def _local_text(text):
"""whisper.cpp's segments, joined back into the flowing line OpenAI returns.
Its plain text puts one segment per line, and a segment boundary falls
wherever the tokens fell, which in Turkish lands inside a word about as
often as between two. Nothing takes the line break's place: whisper's own
leading spaces are what separate the words, and a break inside "değ|iller"
has nothing on either side of it worth keeping.
"""
return "".join(text.split("\n"))
def _merge_word_splits(segments):
"""Fold a segment that begins mid-word into the one it continues.
The hosted whisper-1 hands back segments cut on sentences; whisper.cpp cuts
them on tokens, and a subtitle cue reading "değ" is not a cue. The times are
joined along with the text, so the merged segment still covers the whole
word.
"""
merged = []
for seg in segments:
text = seg.get("text") or ""
if merged and _continues_a_word(merged[-1]["text"], text):
merged[-1]["text"] += text
merged[-1]["end"] = seg.get("end") or merged[-1]["end"]
continue
merged.append({"text": text, "start": seg.get("start") or 0.0,
"end": seg.get("end") or 0.0})
return merged
def transcribe(target, wav_path, language="", prompt="", timeout=300):
data = _transcribe_request(
target, wav_path, language, prompt, "json", timeout=timeout
)
text = (data.get("text") or "").strip()
text = data.get("text") or ""
if target.provider == "local":
text = _local_text(text)
text = text.strip()
if not text:
raise ApiError(t("Transcript came back empty."))
return text
@@ -153,11 +249,13 @@ def transcribe(target, wav_path, language="", prompt="", timeout=300):
def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
data = _transcribe_request(
target._replace(model=timestamp_model(target.provider)),
target._replace(model=timestamp_model(target.provider, target.model)),
wav_path, language, prompt, "verbose_json",
granularity="segment", timeout=timeout,
)
segments = data.get("segments") or []
if target.provider == "local":
segments = _merge_word_splits(segments)
out = []
for seg in segments:
text = (seg.get("text") or "").strip()
@@ -166,45 +264,91 @@ def transcribe_segments(target, wav_path, language="", prompt="", timeout=300):
end = float(seg.get("end") or 0.0)
out.append((start, max(end, start), text))
if not out:
text = (data.get("text") or "").strip()
text = data.get("text") or ""
if target.provider == "local":
text = _local_text(text)
text = text.strip()
if not text:
raise ApiError(t("Transcript came back empty."))
out = [(0.0, 0.0, text)]
return out
def cleanup(text, api_key, model, system_prompt, reasoning="",
base_url=OPENROUTER_URL, timeout=180):
if not api_key:
def _thinking(target, payload):
"""Ask for as much thinking as this provider understands, or for none.
An empty level means "whatever the model does on its own", so nothing is
sent. The two providers mean opposite things by that, which is why the
setting is kept per provider: OpenRouter's cleanup models answer straight
away, while a local model that was trained to think will think, and cleanup
is punctuation rather than a job worth thinking about.
"""
if not target.reasoning:
return
if target.provider == "local-llm":
# What llama.cpp passes to the chat template. The models that think
# read it; the ones that do not ignore it.
payload["chat_template_kwargs"] = {
"enable_thinking": target.reasoning != "none"}
return
if target.reasoning != "none":
# The thinking itself is never shown, so ask for it to be left out.
payload["reasoning"] = {"effort": target.reasoning, "exclude": True}
def _local_ceiling(text):
"""How much of a reply is worth waiting for from a model on this machine.
Cleanup gives back what it was given, near enough, so a reply several times
the length of the transcript is a model that has lost the thread rather than
one doing the job. A small one will happily repeat the transcript until the
context is full, and every one of those tokens is a second of somebody
waiting. A hosted model is left alone: there the same runaway is rare, and a
ceiling would cut the minutes short instead.
"""
return max(512, len(text))
def cleanup(target, text, system_prompt, timeout=180):
if target.provider == "local-llm":
target, timeout = _serving(target, ggml.llm, timeout)
elif not target.api_key:
raise ApiError(t("{service} API key is empty. Add it in Settings.",
service="OpenRouter"))
service=target.service))
payload = {
"model": model,
"model": target.model,
"temperature": 0,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"<transcript>\n{text}\n</transcript>"},
],
}
# An empty level means "whatever the model does on its own"; anything else is
# one of OpenRouter's efforts. The thinking itself is never shown, so ask for
# it to be left out of the reply.
if reasoning:
payload["reasoning"] = {"effort": reasoning, "exclude": True}
if target.provider == "local-llm":
payload["max_tokens"] = _local_ceiling(text)
_thinking(target, payload)
try:
data = _request(
f"{base_url.rstrip('/')}/chat/completions",
f"{target.base_url.rstrip('/')}/chat/completions",
json.dumps(payload).encode("utf-8"),
_headers("openrouter", api_key, "application/json"),
_headers(target.provider, target.api_key, "application/json"),
timeout=timeout,
)
except ApiError as exc:
raise explain(exc, "OpenRouter") from None
if target.provider == "local-llm":
raise _local_failure(target, ggml.llm, exc) from None
raise explain(exc, target.service) from None
choices = data.get("choices") or []
if not choices:
raise ApiError(_extract_error(json.dumps(data)))
content = ((choices[0].get("message") or {}).get("content") or "").strip()
message = choices[0].get("message") or {}
content = (message.get("content") or "").strip()
if not content:
# A thinking model can spend the whole reply on the thinking and leave
# nothing to paste. Worth naming, because the fix is a setting rather
# than a retry: cleanup is not a job that wants thinking.
if message.get("reasoning_content") or message.get("reasoning"):
raise ApiError(t("The cleanup model spent its whole reply on "
"thinking. Set Thinking to “Off”."))
raise ApiError(t("The cleanup model returned an empty reply."))
return content
+108 -3
View File
@@ -6,7 +6,9 @@ import os
import pathlib
import api
import ggml
import i18n
from i18n import t
def _xdg(var, default):
@@ -365,14 +367,46 @@ DEFAULTS = {
"openai_base_url": "https://api.openai.com/v1",
"openrouter_api_key": "",
"openrouter_base_url": "https://openrouter.ai/api/v1",
"transcribe_provider": "openai", # openai | openrouter
"transcribe_provider": "local", # local | openai | openrouter
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
"language": "tr",
"transcribe_prompt": "",
# --- whisper.cpp, on this machine ---------------------------------------
# The program and the model are both fetched from Settings; empty means
# nothing has been downloaded yet, which is what opens Settings on a first
# run.
# Pointed at the suggestion rather than at nothing, so the settings window
# opens with the Download button already on the right model.
"local_model": ggml.SUGGESTED_WHISPER,
"local_threads": 0, # 0 -> whisper.cpp picks
"local_gpu": True,
"local_preload": True, # load the model while Dikte starts, rather
# than on the first dictation
"local_binary": "", # empty -> whichever copy ggml.py finds
"cleanup_enabled": True,
"cleanup_provider": "openrouter", # openrouter | local
"cleanup_model": "google/gemini-3.5-flash-lite",
"cleanup_reasoning": "", # empty -> whatever the model does by default
# --- llama.cpp, on this machine -----------------------------------------
# Kept apart from the meeting settings on purpose. Cleanup is punctuation
# and filler words, which a small model does in a moment; the minutes are a
# summary of an hour, which it does not.
"local_llm_model": "", # a file name, e.g. gemma-3-4b-it-Q4_K_M.gguf
# Where the model list is read from; the settings window offers the
# publishers ggml.py knows of and takes any other one that is typed in.
"local_llm_repo": ggml.SUGGESTED_LLM[0],
"local_llm_threads": 0,
"local_llm_gpu": True,
"local_llm_context": 8192,
"local_llm_binary": "",
"local_llm_preload": False, # heavier than whisper, so only when asked
# Off rather than empty: a model trained to think will, and 300 tokens of
# reasoning about a comma is 300 tokens of waiting.
"local_llm_reasoning": "none",
"cleanup_prompt": "", # empty -> language-specific default
"auto_paste": True,
"paste_shortcut": "ctrl+v",
@@ -400,6 +434,7 @@ DEFAULTS = {
"meeting_language": "", # empty -> the dictation speech language
"meeting_max_seconds": 14400, # 4 hours
"meeting_cleanup": True,
"meeting_provider": "openrouter", # openrouter | local
"meeting_model": "google/gemini-3.5-flash",
"meeting_reasoning": "",
"meeting_prompt": "", # empty -> language-specific default
@@ -495,14 +530,84 @@ class Config:
return self["openrouter_api_key"].strip() or os.environ.get("OPENROUTER_API_KEY", "").strip()
def transcribe_target(self):
"""Key, endpoint and model for whichever provider does speech to text."""
if self["transcribe_provider"] == "openrouter":
"""Key, endpoint and model for whichever provider does speech to text.
The local one leaves its base URL empty on purpose: the server picks a
port when it starts, and starting it here would make reading a setting
launch a process. api.py fills the address in when it is about to send
the request, which is the moment the server is needed anyway.
"""
provider = self["transcribe_provider"]
if provider == "local":
return api.Target("local", t("Local whisper"), "", "",
self["local_model"])
if provider == "openrouter":
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
self["openrouter_base_url"],
self["openrouter_transcribe_model"])
return api.Target("openai", "OpenAI", self.openai_key(),
self["openai_base_url"], self["transcribe_model"])
def cleanup_target(self):
"""The same, for the model that tidies a transcript up."""
if self["cleanup_provider"] == "local":
return api.Target("local-llm", t("Local model"), "", "",
self["local_llm_model"], self["local_llm_reasoning"])
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
self["openrouter_base_url"], self["cleanup_model"],
self["cleanup_reasoning"])
def minutes_target(self):
"""The same again, for the minutes.
Its own provider rather than the cleanup one. The two jobs are not the
same size: a 4B model on this machine will strip the filler words out of
a dictation perfectly well and will not write up an hour long meeting,
so choosing it for the first must not quietly choose it for the second.
"""
if self["meeting_provider"] == "local":
return api.Target("local-llm", t("Local model"), "", "",
self["local_llm_model"], self["local_llm_reasoning"])
return api.Target("openrouter", "OpenRouter", self.openrouter_key(),
self["openrouter_base_url"], self["meeting_model"],
self["meeting_reasoning"])
def transcribe_ready(self):
"""Whether speech to text could run right now, without opening Settings."""
if self["transcribe_provider"] == "local":
return self.local_whisper_ready()
return bool(self.transcribe_target().api_key)
def local_whisper_ready(self):
return bool(ggml.program_path(ggml.WHISPER, self["local_binary"])
and self["local_model"]
and ggml.have_model(ggml.whisper_model_path(self["local_model"])))
def local_llm_ready(self):
return bool(ggml.program_path(ggml.LLAMA, self["local_llm_binary"])
and self["local_llm_model"]
and ggml.have_model(ggml.llm_model_path(self["local_llm_model"])))
def apply_local(self):
"""Hand the local settings to the servers, restarting what they change."""
ggml.whisper.configure(
model=self["local_model"],
threads=int(self["local_threads"]),
gpu=bool(self["local_gpu"]),
binary=self["local_binary"],
)
ggml.llm.configure(
model=self["local_llm_model"],
threads=int(self["local_llm_threads"]),
gpu=bool(self["local_llm_gpu"]),
binary=self["local_llm_binary"],
context=int(self["local_llm_context"]),
)
def uses_local_llm(self):
"""Whether anything is set to run the local cleanup model."""
return "local" in (self["cleanup_provider"], self["meeting_provider"])
def cleanup_prompt(self, with_timestamps=False, with_speakers=False,
subtitles=False):
turkish = i18n.language() == "tr"
+92 -2
View File
@@ -7,16 +7,20 @@ terminal talks to. Every verb it answers is in cli.py, which is also what runs
command line says "there is no instance to talk to, so be one".
"""
import contextlib
import json
import os
import signal
import socket
import sys
import threading
# A Wayland client cannot place a window in a screen corner, so the indicator
# is drawn through XWayland.
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
@@ -25,6 +29,7 @@ import assistant # noqa: E402
import audio # noqa: E402
import cli # noqa: E402
import config as cfg # noqa: E402
import ggml # noqa: E402
import hotkey # noqa: E402
import i18n # noqa: E402
import ipc # noqa: E402
@@ -92,6 +97,9 @@ class Dikte:
self.meeting_recorder = audio.MeetingRecorder()
self.meetings = MeetingPipeline(self.conf)
self.evdev = hotkey.EvdevHotkey()
# Before anything of ours is started: a server from a Dikte that was
# killed outright is still holding a model in memory.
ggml.sweep()
self.recorder.level.connect(self._on_level)
self.recorder.stopped.connect(self._on_recorded)
@@ -802,9 +810,44 @@ class Dikte:
# Don't drop the object while its own signal is still being delivered.
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
def _apply_local(self):
"""Pass the local settings on, and hold the models ready if asked to.
Loading a model takes a second or two for whisper and longer for an LLM.
Doing it while Dikte starts rather than on the first dictation is the
whole reason a server is kept alive instead of running the program once
per recording; the checkboxes are there for the machine whose memory is
wanted elsewhere.
"""
self.conf.apply_local()
wanted = []
if self.conf["transcribe_provider"] == "local":
if self.conf["local_preload"] and self.conf.local_whisper_ready():
wanted.append((ggml.whisper, "whisper"))
else:
ggml.whisper.stop() # give the memory back when it is not in use
if self.conf.uses_local_llm():
if self.conf["local_llm_preload"] and self.conf.local_llm_ready():
wanted.append((ggml.llm, "llama"))
else:
ggml.llm.stop()
def warm():
for server, name in wanted:
try:
server.serve()
except ggml.LocalError as exc:
# Not worth an indicator: the first dictation raises the
# same thing where the user can act on it.
print(f"dikte: {name}: {exc}", file=sys.stderr)
if wanted:
threading.Thread(target=warm, daemon=True).start()
def _apply_settings(self):
self.overlay.corner = self.conf["overlay_corner"]
self.ask_overlay.corner = self.conf["overlay_corner"]
self._apply_local()
self._build_tray()
self._refresh_tray()
if self.conf["evdev_hotkey"]:
@@ -834,6 +877,9 @@ class Dikte:
self.meeting_recorder.stop()
self.overlay.dismiss()
self.ask_overlay.dismiss()
# Also on the restart path, which replaces the process without ever
# reaching atexit and would otherwise leave the models in memory.
ggml.stop_all()
self.tray.hide()
@@ -872,6 +918,41 @@ def main():
return run_app([arg for arg in argv if arg != "--gui"])
def install_signal_handlers(app):
"""Quit properly on the signals a session sends, rather than dying where we stand.
Qt spends its time blocked inside C, and a Python signal handler only runs
between bytecodes, so on its own it would not run until the next event
arrived, which for an idle tray icon may be never. set_wakeup_fd writes the
signal number to a socket instead, and a notifier turns that into an event
Qt does deliver.
Worth the trouble because of what shutdown() does: a logout sends SIGTERM,
and without this a whisper.cpp or llama.cpp server outlives the session
holding its model in memory. SIGKILL cannot be caught at all, which is what
ggml.sweep() is for.
Returns the objects it made; they have to stay alive to keep working.
"""
reader, writer = socket.socketpair()
reader.setblocking(False)
writer.setblocking(False)
signal.set_wakeup_fd(writer.fileno())
notifier = QSocketNotifier(reader.fileno(), QSocketNotifier.Type.Read)
def woken():
with contextlib.suppress(OSError):
reader.recv(64)
app.quit() # aboutToQuit runs shutdown()
notifier.activated.connect(woken)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
# A handler that does nothing, so that the default action, stopping the
# process where it stands, is replaced by the wakeup above.
signal.signal(sig, lambda *_: None)
return reader, writer, notifier
def run_app(args):
command = args[0] if args else ""
@@ -879,6 +960,12 @@ def run_app(args):
app.setApplicationName("Dikte")
app.setDesktopFileName("dikte")
app.setQuitOnLastWindowClosed(False)
# Before Dikte is built, because building it is what may start a server, and
# a signal arriving in the middle of that would otherwise take the default
# action and leave the server behind. A signal this early lands in the
# socket and is delivered as soon as the event loop starts. Held in a name
# so that the notifier and its socket outlive this function.
signal_plumbing = install_signal_handlers(app) # noqa: F841
if not QSystemTrayIcon.isSystemTrayAvailable():
print("dikte: no system tray found, running anyway")
@@ -927,7 +1014,10 @@ def run_app(args):
# No key for the chosen transcription provider means nothing can work yet,
# so the settings window is the only useful thing to open.
if command == "settings" or not dikte.conf.transcribe_target().api_key:
# A transcription provider that cannot run yet, whether that is a missing
# API key or a model nobody has downloaded, means nothing can work, so the
# settings window is the only useful thing to open.
if command == "settings" or not dikte.conf.transcribe_ready():
dikte.open_settings()
elif command == "toggle":
QTimer.singleShot(0, dikte.toggle)
+2 -8
View File
@@ -127,17 +127,11 @@ class FileTranscriber(QObject):
def _cleanup(self, text, timestamps):
conf = self.conf
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
target = conf.cleanup_target()
out = []
for block in split_text(text, timestamps):
self._check()
out.append(api.cleanup(
block,
conf.openrouter_key(),
conf["cleanup_model"],
prompt,
reasoning=conf["cleanup_reasoning"],
base_url=conf["openrouter_base_url"],
))
out.append(api.cleanup(target, block, prompt))
return ("\n" if timestamps else "\n\n").join(out)
+2 -1
View File
@@ -116,5 +116,6 @@ fi
echo
ok "Done. Start it with: dikte"
say "The settings window opens on first run; add an OpenAI, Groq or OpenRouter key."
say "The settings window opens on first run: download a speech model, or add"
say "an OpenAI or OpenRouter key instead."
echo
+6 -18
View File
@@ -118,18 +118,12 @@ class MeetingPipeline(QObject):
self._check()
self._say(t("Writing the minutes…"))
minutes = api.cleanup(
transcript,
self.conf.openrouter_key(),
self.conf["meeting_model"],
self.conf.meeting_prompt(),
reasoning=self.conf["meeting_reasoning"],
base_url=self.conf["openrouter_base_url"],
timeout=600,
)
writer = self.conf.minutes_target()
minutes = api.cleanup(writer, transcript, self.conf.meeting_prompt(),
timeout=600)
title = self._write(doc_path, minutes, transcript, entry)
cfg.update_meeting(base, status="done", error="", title=title,
model=self.conf["meeting_model"])
model=writer.model)
self._discard_audio(wav_path)
self.finished.emit(base, title)
@@ -201,6 +195,7 @@ class MeetingPipeline(QObject):
def _cleanup(self, transcript):
conf = self.conf
prompt = conf.cleanup_prompt(with_timestamps=True, with_speakers=True)
target = conf.cleanup_target()
out = []
blocks = filetranscribe.split_text(transcript, True)
for index, block in enumerate(blocks, start=1):
@@ -208,14 +203,7 @@ class MeetingPipeline(QObject):
if len(blocks) > 1:
self._say(t("Cleaning up {index}/{count}",
index=index, count=len(blocks)))
out.append(api.cleanup(
block,
conf.openrouter_key(),
conf["cleanup_model"],
prompt,
reasoning=conf["cleanup_reasoning"],
base_url=conf["openrouter_base_url"],
))
out.append(api.cleanup(target, block, prompt))
return "\n".join(out)
def _write(self, doc_path, minutes, transcript, entry):
+438 -14
View File
@@ -18,6 +18,7 @@ import assistant
import audio
import config as cfg
import filetranscribe
import ggml
import hotkey
import meeting
from filetranscribe import FileTranscriber
@@ -29,7 +30,10 @@ LANGUAGES = [
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
]
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
TRANSCRIBE_PROVIDERS = [("OpenAI", "openai"), ("OpenRouter", "openrouter")]
TRANSCRIBE_PROVIDERS = [("This machine (whisper.cpp)", "local"),
("OpenAI", "openai"), ("OpenRouter", "openrouter")]
CLEANUP_PROVIDERS = [("OpenRouter", "openrouter"),
("This machine (llama.cpp)", "local")]
# Starting points for the model box; "Fetch model list" replaces them with
# whatever the provider offers today.
TRANSCRIBE_MODELS = {
@@ -103,6 +107,316 @@ AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
"*.mp4 *.mkv *.webm *.mov *.avi")
class LocalModelBox(QGroupBox):
"""The program, the model, and the two downloads that put them there.
One class for whisper.cpp and llama.cpp, because the job is the same one
twice: say whether the program is here, offer the models somebody publishes,
fetch the chosen one, and stay usable while a gigabyte arrives. Nothing is
listed in the source; `repos` and `models` are asked at the moment the box is
opened, so a model published this morning is in the list this afternoon.
"""
_listed = pyqtSignal(list, str)
_quants = pyqtSignal(list, str)
_progress = pyqtSignal(int, int)
_finished = pyqtSignal(str, str)
_installed = pyqtSignal(str, str)
changed = pyqtSignal()
def __init__(self, program, title, models, model_path, repos=None, parent=None):
super().__init__(title, parent)
self.program = program
self._models = models # () -> [hub.Item], or (repo) -> [hub.Item]
self._model_path = model_path # (name) -> Path
self._repos = repos # None, or () -> [repo id]
self._downloading = False
self._pending = False
self._stop = False
self._wanted = "" # the model to select once a list arrives
form = QFormLayout(self)
self.program_label = QLabel("")
self.program_label.setWordWrap(True)
self.install_button = QPushButton(t("Download"))
self.install_button.clicked.connect(self._install_program)
form.addRow(t("Program"), self._side_by_side(self.program_label,
self.install_button))
if self._repos is not None:
self.repo = QComboBox()
self.repo.setEditable(True)
self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The "
"list is fetched; any other one can be typed in."))
self.repo.currentTextChanged.connect(self._repo_changed)
form.addRow(t("Publisher"), self.repo)
self.model = QComboBox()
self.download_button = QPushButton(t("Download"))
self.download_button.clicked.connect(self._download)
self.delete_button = QPushButton(t("Delete"))
self.delete_button.clicked.connect(self._delete)
form.addRow(t("Model"), self._side_by_side(self.model,
self.download_button,
self.delete_button))
self.model.currentIndexChanged.connect(self._model_changed)
self.status = QLabel("")
self.status.setWordWrap(True)
form.addRow(self.status)
self._listed.connect(self._on_listed)
self._quants.connect(self._on_listed)
self._progress.connect(self._on_progress)
self._finished.connect(self._on_finished)
self._installed.connect(self._on_installed)
@staticmethod
def _side_by_side(*widgets):
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
for index, widget in enumerate(widgets):
layout.addWidget(widget, 1 if index == 0 else 0)
holder = QWidget()
holder.setLayout(layout)
return holder
# ---- what is here ----------------------------------------------------
def selected(self):
return self.model.currentData() or ""
def repository(self):
return self.repo.currentText().strip() if self._repos is not None else ""
def load(self, model, repo=""):
"""Show what is stored. What else is on offer is asked for on the way up.
Nothing is fetched here: building the settings window is not the same as
opening it, and a list nobody is looking at is not worth a request. What
is already on this disk is shown straight away either way.
"""
self._wanted = model
self._pending = True
self._show_program()
if self._repos is not None:
self.repo.blockSignals(True)
self.repo.clear()
self.repo.addItems(list(ggml.SUGGESTED_LLM))
self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0])
self.repo.blockSignals(False)
self._fill_models([])
def showEvent(self, event):
super().showEvent(event)
if self._pending:
self._pending = False
if self._repos is not None:
self._fill_repos(self.repository())
self._fetch_models(self.repository())
def _show_program(self):
path = ggml.program_path(self.program)
if not path:
self.program_label.setText(t("Not installed."))
self.install_button.setVisible(True)
return
self.install_button.setVisible(not ggml.installed_program(self.program)
and not ggml.system_program(self.program))
if ggml.system_program(self.program):
# Worth saying which one is running: a distribution package is built
# for this machine and may reach the graphics card, while the
# released binaries carry processor backends only.
self.program_label.setText(t("Installed on the system: {path}", path=path))
else:
self.program_label.setText(
t("Downloaded, version {version}.",
version=ggml.installed_version(self.program) or "?"))
# ---- the lists -------------------------------------------------------
def _fill_repos(self, current):
def work():
self._listed.emit([("repos", ggml.llm_repos())], "")
threading.Thread(target=work, daemon=True).start()
def _repo_changed(self):
if not self._downloading:
self._fetch_models(self.repository())
def _fetch_models(self, repo=""):
self.status.setText(t("Fetching the model list…"))
def work():
try:
found = self._models(repo) if self._repos is not None else self._models()
self._quants.emit([("models", found)], "")
except ggml.LocalError as exc:
self._quants.emit([], str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_listed(self, payload, error):
if error:
self.status.setText(error)
self._refresh_buttons()
return
kind, found = payload[0]
if kind == "repos":
current = self.repo.currentText()
self.repo.blockSignals(True)
self.repo.clear()
self.repo.addItems(found)
self.repo.setCurrentText(current)
self.repo.blockSignals(False)
return
self._fill_models(found)
def _fill_models(self, items):
"""One row per model, saying what it weighs and whether it is here."""
wanted = self._wanted or self.selected()
here = [name for name in (self._model_path(i.name).name for i in items)]
self.model.blockSignals(True)
self.model.clear()
for item, name in zip(items, here):
mark = (t("downloaded") if ggml.have_model(self._model_path(item.name))
else ggml.human_size(item.size))
self.model.addItem(f"{name} ({mark})", name)
self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1)
# A model that was downloaded and then dropped from the list upstream is
# still on this disk and still works, so it stays on offer.
for name in self._on_disk():
if self.model.findData(name) < 0:
self.model.addItem(f"{name} ({t('downloaded')})", name)
# And one that is chosen but not here, because the file was deleted from
# underneath or the settings came from another machine, stays chosen:
# Save reads this box, and a row missing here would quietly empty the
# setting rather than showing that the model needs downloading again.
if wanted and self.model.findData(wanted) < 0:
self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted)
index = self.model.findData(wanted)
self.model.setCurrentIndex(max(index, 0))
self.model.blockSignals(False)
self._wanted = ""
self._model_changed()
def _on_disk(self):
return (ggml.installed_whisper_models() if self.program is ggml.WHISPER
else ggml.installed_llm_models())
# ---- fetching --------------------------------------------------------
def _install_program(self):
self.install_button.setEnabled(False)
self.program_label.setText(t("Downloading…"))
def work():
try:
ggml.install_program(self.program, on_progress=self._report)
self._installed.emit("", "")
except ggml.LocalError as exc:
self._installed.emit("", str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_installed(self, _, error):
self.install_button.setEnabled(True)
self._show_program()
if error:
self.program_label.setText(error)
self.changed.emit()
def _current_item(self):
return self.model.currentData(Qt.ItemDataRole.UserRole + 1)
def _download(self):
if self._downloading:
self._stop = True
return
item = self._current_item()
if item is None:
return
self._downloading, self._stop = True, False
self._refresh_buttons()
def work():
try:
landed = ggml.download(item, self._model_path(item.name),
on_progress=self._report,
should_stop=lambda: self._stop)
self._finished.emit(item.name if landed else "", "")
except ggml.LocalError as exc:
self._finished.emit("", str(exc))
threading.Thread(target=work, daemon=True).start()
def _report(self, done, total):
self._progress.emit(done, total)
def _on_progress(self, done, total):
share = f" ({done * 100 // total}%)" if total else ""
text = t("Downloading: {done} of {total}{share}",
done=ggml.human_size(done), total=ggml.human_size(total or done),
share=share)
if self._downloading:
self.status.setText(text)
else:
self.program_label.setText(text)
def _on_finished(self, name, error):
self._downloading = False
if error:
self.status.setText(error)
elif not name:
self.status.setText(t("Download stopped."))
self._fill_models_from_current()
self.changed.emit()
def _fill_models_from_current(self):
"""Redraw the rows without asking anybody anything again."""
items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1)
for i in range(self.model.count())]
self._wanted = self.selected()
self._fill_models([i for i in items if i is not None])
def _delete(self):
name = self.selected()
if not name or not ggml.have_model(self._model_path(name)):
return
if QMessageBox.question(self, t("Delete model"),
t("Delete {name} from this machine?", name=name)) \
!= QMessageBox.StandardButton.Yes:
return
try:
ggml.delete_model(self._model_path(name))
except ggml.LocalError as exc:
self.status.setText(str(exc))
self._fill_models_from_current()
self.changed.emit()
def _model_changed(self):
self._refresh_buttons()
self.changed.emit()
def _refresh_buttons(self):
name = self.selected()
here = bool(name) and ggml.have_model(self._model_path(name))
self.delete_button.setEnabled(here and not self._downloading)
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
self.download_button.setEnabled(self._downloading or (bool(name) and not here))
if self._downloading:
return
if not name:
self.status.setText(t("Nothing downloaded yet."))
elif here:
self.status.setText(t("Ready: {name}.", name=name))
else:
self.status.setText(t("{name} has not been downloaded yet.", name=name))
class SettingsWindow(QDialog):
applied = pyqtSignal()
@@ -127,9 +441,9 @@ class SettingsWindow(QDialog):
self.setWindowTitle(t("Dikte Settings"))
self.resize(680, 640)
tabs = QTabWidget(self)
tabs = self.tabs = QTabWidget(self)
tabs.addTab(self._general_tab(), t("General"))
tabs.addTab(self._api_tab(), t("API and models"))
self.api_tab_index = tabs.addTab(self._api_tab(), t("API and models"))
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
tabs.addTab(self._assistant_tab(), t("Agent"))
tabs.addTab(self._meeting_tab(), t("Meeting"))
@@ -161,6 +475,10 @@ class SettingsWindow(QDialog):
self.meetings.finished.connect(self._on_minutes_finished)
self.meetings.failed.connect(self._on_minutes_failed)
self._load()
# On a machine where nothing can transcribe yet, this window was opened
# because of that, so open it on the tab that fixes it.
if not conf.transcribe_ready():
self.tabs.setCurrentIndex(self.api_tab_index)
# ---- tabs ----------------------------------------------------------
@@ -269,20 +587,55 @@ class SettingsWindow(QDialog):
stt_form = QFormLayout(stt)
self.transcribe_provider = QComboBox()
for label, value in TRANSCRIBE_PROVIDERS:
self.transcribe_provider.addItem(label, value)
self.transcribe_provider.addItem(t(label), value)
stt_form.addRow(t("Provider"), self.transcribe_provider)
# The hosted providers take any model id that is typed at them; the
# local one offers what has been published, so the two are separate
# blocks and only one of them is ever visible.
self.hosted_stt = QWidget()
hosted_form = QFormLayout(self.hosted_stt)
hosted_form.setContentsMargins(0, 0, 0, 0)
self.transcribe_model = QComboBox()
self.transcribe_model.setEditable(True)
self.refresh_transcribe_models = QPushButton(t("Fetch model list"))
self.refresh_transcribe_models.clicked.connect(self._load_transcribe_models)
stt_form.addRow(t("Model"),
self._row(self.transcribe_model, self.refresh_transcribe_models))
hosted_form.addRow(t("Model"),
self._row(self.transcribe_model,
self.refresh_transcribe_models))
# A spanning row: in the narrow field column a wrapped label gets a
# height that fits one line, and the rest of the text is cut off.
self.transcribe_status = QLabel("")
self.transcribe_status.setWordWrap(True)
stt_form.addRow(self.transcribe_status)
hosted_form.addRow(self.transcribe_status)
stt_form.addRow(self.hosted_stt)
self.local_whisper = LocalModelBox(
ggml.WHISPER, t("On this machine"),
ggml.whisper_models, ggml.whisper_model_path)
stt_form.addRow(self.local_whisper)
self.local_gpu = QCheckBox(t("Use the graphics card"))
self.local_gpu.setToolTip(
t("whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the "
"build it is running was made with one. A build without any of them "
"runs on the processor whatever this says."))
self.local_preload = QCheckBox(t("Load the model when Dikte starts"))
self.local_preload.setToolTip(
t("A large model takes a second or two to load. Loading it up front "
"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)
self.local_threads.setSpecialValueText(t("Automatic"))
self.local_options = QWidget()
options_form = QFormLayout(self.local_options)
options_form.setContentsMargins(0, 0, 0, 0)
options_form.addRow("", self.local_gpu)
options_form.addRow("", self.local_preload)
options_form.addRow(t("Threads"), self.local_threads)
stt_form.addRow(self.local_options)
self.transcribe_provider.currentIndexChanged.connect(self._provider_changed)
outer.addWidget(stt)
@@ -291,12 +644,22 @@ class SettingsWindow(QDialog):
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
orr_form.addRow("", self.cleanup_enabled)
self.cleanup_provider = QComboBox()
for label, value in CLEANUP_PROVIDERS:
self.cleanup_provider.addItem(t(label), value)
self.cleanup_provider.currentIndexChanged.connect(self._cleanup_provider_changed)
orr_form.addRow(t("Provider"), self.cleanup_provider)
self.hosted_cleanup = QWidget()
cleanup_form = QFormLayout(self.hosted_cleanup)
cleanup_form.setContentsMargins(0, 0, 0, 0)
self.cleanup_model = QComboBox()
self.cleanup_model.setEditable(True)
self.cleanup_model.addItems(CLEANUP_MODELS)
self.refresh_models = QPushButton(t("Fetch model list"))
self.refresh_models.clicked.connect(self._load_models)
orr_form.addRow(t("Model"), self._row(self.cleanup_model, self.refresh_models))
cleanup_form.addRow(t("Model"), self._row(self.cleanup_model,
self.refresh_models))
self.cleanup_reasoning = QComboBox()
for label, value in REASONING_LEVELS:
@@ -306,11 +669,38 @@ class SettingsWindow(QDialog):
"a light job, so more thinking mostly costs time and tokens. Models "
"that cannot think ignore this.")
)
orr_form.addRow(t("Thinking"), self.cleanup_reasoning)
cleanup_form.addRow(t("Thinking"), self.cleanup_reasoning)
self.models_label = QLabel(t("Runs on OpenRouter."))
self.models_label.setWordWrap(True)
orr_form.addRow(self.models_label)
cleanup_form.addRow(self.models_label)
orr_form.addRow(self.hosted_cleanup)
self.local_llm = LocalModelBox(
ggml.LLAMA, t("On this machine"),
ggml.llm_quants, ggml.llm_model_path, repos=ggml.llm_repos)
orr_form.addRow(self.local_llm)
self.local_llm_gpu = QCheckBox(t("Use the graphics card"))
self.local_llm_preload = QCheckBox(t("Load the model when Dikte starts"))
self.local_llm_preload.setToolTip(
t("An LLM is slower to load than a whisper model and sits in more "
"memory. Off means it is loaded on the first cleanup instead."))
self.local_llm_reasoning = QComboBox()
for label, value in REASONING_LEVELS:
self.local_llm_reasoning.addItem(t(label), value)
self.local_llm_reasoning.setToolTip(
t("A model trained to think will think unless it is told not to, and "
"spending 300 tokens of reasoning on a comma is 300 tokens of "
"waiting. Off is what cleanup wants."))
self.local_llm_options = QWidget()
llm_form = QFormLayout(self.local_llm_options)
llm_form.setContentsMargins(0, 0, 0, 0)
llm_form.addRow("", self.local_llm_gpu)
llm_form.addRow("", self.local_llm_preload)
llm_form.addRow(t("Thinking"), self.local_llm_reasoning)
orr_form.addRow(self.local_llm_options)
outer.addWidget(orr)
outer.addStretch(1)
return page
@@ -926,9 +1316,20 @@ class SettingsWindow(QDialog):
self._shown_provider = ""
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
self._provider_changed() # selecting index 0 fires no signal
self.local_gpu.setChecked(conf["local_gpu"])
self.local_preload.setChecked(conf["local_preload"])
self.local_threads.setValue(int(conf["local_threads"]))
self.local_whisper.load(conf["local_model"])
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
self._select_data(self.cleanup_provider, conf["cleanup_provider"])
self._cleanup_provider_changed()
self.cleanup_model.setCurrentText(conf["cleanup_model"])
self._select_data(self.cleanup_reasoning, conf["cleanup_reasoning"])
self.local_llm_gpu.setChecked(conf["local_llm_gpu"])
self.local_llm_preload.setChecked(conf["local_llm_preload"])
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
self.file_cleanup_prompt.setPlainText(
conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt()
@@ -1003,16 +1404,27 @@ class SettingsWindow(QDialog):
conf["openai_api_key"] = self.openai_key.text().strip()
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
provider = self.transcribe_provider.currentData() or "openai"
self._models[provider] = self.transcribe_model.currentText().strip()
provider = self.transcribe_provider.currentData() or "local"
if provider in TRANSCRIBE_MODELS:
self._models[provider] = self.transcribe_model.currentText().strip()
conf["transcribe_provider"] = provider
for key, name in (("openai", "transcribe_model"),
("openrouter", "openrouter_transcribe_model")):
conf[name] = self._models[key].strip() or cfg.DEFAULTS[name]
conf["local_model"] = self.local_whisper.selected()
conf["local_gpu"] = self.local_gpu.isChecked()
conf["local_preload"] = self.local_preload.isChecked()
conf["local_threads"] = self.local_threads.value()
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
conf["cleanup_provider"] = self.cleanup_provider.currentData() or "openrouter"
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
conf["cleanup_reasoning"] = self.cleanup_reasoning.currentData() or ""
conf["local_llm_model"] = self.local_llm.selected()
conf["local_llm_repo"] = self.local_llm.repository()
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
# Store an empty prompt when it matches the default, so switching the
# interface language also switches the prompt language.
@@ -1093,15 +1505,27 @@ class SettingsWindow(QDialog):
def _provider_changed(self):
"""Swap the model box over to the newly chosen provider's own model."""
if self._shown_provider:
if self._shown_provider in TRANSCRIBE_MODELS:
self._models[self._shown_provider] = self.transcribe_model.currentText().strip()
provider = self.transcribe_provider.currentData() or "openai"
provider = self.transcribe_provider.currentData() or "local"
self._shown_provider = provider
local = provider == "local"
self.hosted_stt.setVisible(not local)
self.local_whisper.setVisible(local)
self.local_options.setVisible(local)
if local:
return
self.transcribe_model.clear()
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
self.transcribe_model.setCurrentText(self._models[provider])
self.transcribe_status.setText("")
def _cleanup_provider_changed(self):
local = (self.cleanup_provider.currentData() or "openrouter") == "local"
self.hosted_cleanup.setVisible(not local)
self.local_llm.setVisible(local)
self.local_llm_options.setVisible(local)
def _load_transcribe_models(self):
"""The model list of whichever provider is selected."""
provider = self.transcribe_provider.currentData() or "openai"
+12
View File
@@ -18,6 +18,7 @@ import sys
import tempfile
import unittest
import urllib.error
import urllib.request
import wave
from unittest import mock
@@ -40,6 +41,12 @@ linux_only = unittest.skipUnless(
)
def _no_network(*args, **kwargs):
raise AssertionError(
"a test reached the network; wrap the call in support.fake_urlopen"
)
def _no_exec(*args, **kwargs):
raise AssertionError(
"a test reached os.execv, which would replace the test process with the "
@@ -78,6 +85,11 @@ class DikteTest(unittest.TestCase):
# with it and hang, so it fails loudly here instead.
self.patch_attr(os, "execv", _no_exec)
# Every way out of here goes through urllib, so closing it is enough to
# keep the suite offline. A test that means to answer a request patches
# this again through fake_urlopen.
self.patch_attr(urllib.request, "urlopen", _no_network)
# ---- helpers ---------------------------------------------------------
def path(self, *parts):
+182 -10
View File
@@ -10,6 +10,7 @@ import os
import unittest
import api
import ggml
from tests.support import (
DikteTest,
fake_urlopen,
@@ -27,10 +28,18 @@ OPENROUTER = api.Target("openrouter", "OpenRouter", "sk-or-test",
class TimestampModel(unittest.TestCase):
def test_only_whisper_returns_segment_times(self):
self.assertEqual(api.timestamp_model("openai"), "whisper-1")
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
"whisper-1")
def test_openrouter_namespaces_the_id(self):
self.assertEqual(api.timestamp_model("openrouter"), "openai/whisper-1")
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe"),
"openai/whisper-1")
def test_the_local_server_stays_on_the_model_it_loaded(self):
# Asking it for whisper-1 would name a model it has never heard of, and
# it is running whisper whatever the file is called.
self.assertEqual(api.timestamp_model("local", "ggml-base.bin"),
"ggml-base.bin")
class Explain(DikteTest):
@@ -278,10 +287,15 @@ def chat_reply(content):
return {"choices": [{"message": {"content": content}}]}
def openrouter(model="some/model", key="sk-or-test", reasoning="",
base_url="https://openrouter.ai/api/v1"):
return api.Target("openrouter", "OpenRouter", key, base_url, model, reasoning)
class Cleanup(DikteTest):
def call(self, replies, **kwargs):
def call(self, replies, target=None, **kwargs):
with fake_urlopen(replies) as calls:
result = api.cleanup("uh, hello", "sk-or-test", "some/model",
result = api.cleanup(target or openrouter(), "uh, hello",
"you clean up text", **kwargs)
return result, calls
@@ -311,32 +325,34 @@ class Cleanup(DikteTest):
self.assertNotIn("reasoning", sent_json(calls[0]))
def test_an_effort_is_passed_on_and_the_thinking_left_out(self):
_, calls = self.call(chat_reply("Hello."), reasoning="high")
_, calls = self.call(chat_reply("Hello."),
target=openrouter(reasoning="high"))
self.assertEqual(sent_json(calls[0])["reasoning"],
{"effort": "high", "exclude": True})
def test_a_local_base_url(self):
_, calls = self.call(chat_reply("Hello."), base_url="http://localhost:1234/v1")
_, calls = self.call(chat_reply("Hello."),
target=openrouter(base_url="http://localhost:1234/v1"))
self.assertEqual(calls[0].full_url, "http://localhost:1234/v1/chat/completions")
def test_no_key(self):
with self.assertRaises(api.ApiError):
api.cleanup("hello", "", "some/model", "prompt")
api.cleanup(openrouter(key=""), "hello", "prompt")
def test_a_reply_with_no_choices_says_why(self):
with fake_urlopen({"error": {"message": "model is offline"}}), \
self.assertRaises(api.ApiError) as caught:
api.cleanup("hello", "k", "m", "p")
api.cleanup(openrouter(), "hello", "p")
self.assertIn("model is offline", str(caught.exception))
def test_an_empty_answer(self):
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
api.cleanup("hello", "k", "m", "p")
api.cleanup(openrouter(), "hello", "p")
def test_a_rate_limit_is_explained(self):
with fake_urlopen(http_error(429)), \
self.assertRaises(api.ApiError) as caught:
api.cleanup("hello", "k", "m", "p")
api.cleanup(openrouter(), "hello", "p")
self.assertIn("OpenRouter", str(caught.exception))
@@ -435,3 +451,159 @@ class ModelLists(DikteTest):
if __name__ == "__main__":
unittest.main()
class FakeServer:
"""A ggml.Server as far as api.py is concerned."""
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""):
self.url = url
self.fails = fails
self.log = log
self.starts = 0
def serve(self):
self.starts += 1
if self.fails:
raise ggml.LocalError(self.fails)
return self.url
def error(self):
return self.log
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
LOCAL_LLM = api.Target("local-llm", "Local model", "", "", "gemma.gguf", "none")
class TranscribeHere(DikteTest):
def setUp(self):
super().setUp()
self.wav = str(self.path("clip.wav"))
os.makedirs(self.root, exist_ok=True)
with open(self.wav, "wb") as fh:
fh.write(b"RIFFfake")
self.server = FakeServer()
self.patch_attr(ggml, "whisper", self.server)
def test_the_address_comes_from_the_server_it_starts(self):
with fake_urlopen({"text": "hello"}) as calls:
api.transcribe(LOCAL, self.wav)
self.assertEqual(self.server.starts, 1)
self.assertEqual(calls[0].full_url,
"http://127.0.0.1:9999/v1/audio/transcriptions")
def test_nothing_local_is_authorised(self):
with fake_urlopen({"text": "hello"}) as calls:
api.transcribe(LOCAL, self.wav)
self.assertNotIn("Authorization", calls[0].headers)
def test_a_server_that_will_not_start_is_the_error_shown(self):
self.patch_attr(ggml, "whisper", FakeServer(fails="no model downloaded"))
with self.assertRaises(api.ApiError) as caught:
api.transcribe(LOCAL, self.wav)
self.assertIn("no model downloaded", str(caught.exception))
def test_a_server_that_dies_mid_request_says_what_it_printed(self):
self.patch_attr(ggml, "whisper", FakeServer(log="out of memory"))
with fake_urlopen(url_error("connection reset")):
with self.assertRaises(api.ApiError) as caught:
api.transcribe(LOCAL, self.wav)
self.assertIn("out of memory", str(caught.exception))
def test_the_hint_reaches_whisper_as_its_initial_prompt(self):
with fake_urlopen({"text": "hi"}) as calls:
api.transcribe(LOCAL, self.wav, prompt="Dikte, Paraşüt")
self.assertEqual(multipart_fields(calls[0])["prompt"], "Dikte, Paraşüt")
def test_a_word_broken_over_two_lines_is_put_back_together(self):
# whisper.cpp cuts on tokens and writes one segment per line, which in
# Turkish lands inside a word about as often as between two.
with fake_urlopen({"text": "Onlar akraba değ\niller. Ve\n devamı."}):
# The line break inside a word leaves nothing in its place; the
# one between two words is where whisper's own leading space is.
self.assertEqual(api.transcribe(LOCAL, self.wav),
"Onlar akraba değiller. Ve devamı.")
def test_a_local_timeout_is_not_a_hosted_one(self):
# Nothing is being spent but time, and a long file on a machine without
# a graphics card takes a good deal of it.
with fake_urlopen({"text": "hi"}):
api.transcribe(LOCAL, self.wav, timeout=300)
self.assertGreaterEqual(api.LOCAL_TIMEOUT, 600)
def test_segments_that_continue_a_word_are_merged(self):
reply = {"segments": [
{"start": 0.0, "end": 1.0, "text": " Onlar akraba değ"},
{"start": 1.0, "end": 1.4, "text": "iller."},
{"start": 2.0, "end": 3.0, "text": " Başka bir cümle."},
]}
with fake_urlopen(reply):
out = api.transcribe_segments(LOCAL, self.wav)
self.assertEqual([text for _, _, text in out],
["Onlar akraba değiller.", "Başka bir cümle."])
self.assertEqual(out[0][1], 1.4) # the merged cue covers the whole word
def test_the_loaded_model_is_the_one_asked_for_again(self):
with fake_urlopen({"segments": [{"start": 0, "end": 1, "text": " hi"}]}) as calls:
api.transcribe_segments(LOCAL, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin")
class CleanupHere(DikteTest):
def setUp(self):
super().setUp()
self.server = FakeServer("http://127.0.0.1:8888/v1")
self.patch_attr(ggml, "llm", self.server)
def test_it_goes_to_the_server_it_starts(self):
with fake_urlopen(chat_reply("Hello.")) as calls:
result = api.cleanup(LOCAL_LLM, "uh, hello", "clean it up")
self.assertEqual(result, "Hello.")
self.assertEqual(calls[0].full_url,
"http://127.0.0.1:8888/v1/chat/completions")
def test_no_key_is_wanted_and_none_is_sent(self):
with fake_urlopen(chat_reply("Hello.")) as calls:
api.cleanup(LOCAL_LLM, "hello", "prompt")
self.assertNotIn("Authorization", calls[0].headers)
def test_thinking_is_turned_off_in_the_words_llama_cpp_uses(self):
with fake_urlopen(chat_reply("Hello.")) as calls:
api.cleanup(LOCAL_LLM, "hello", "prompt")
self.assertEqual(sent_json(calls[0])["chat_template_kwargs"],
{"enable_thinking": False})
def test_the_models_own_default_asks_for_nothing(self):
with fake_urlopen(chat_reply("Hello.")) as calls:
api.cleanup(LOCAL_LLM._replace(reasoning=""), "hello", "prompt")
self.assertNotIn("chat_template_kwargs", sent_json(calls[0]))
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
api.cleanup(LOCAL_LLM, "hello", "prompt")
self.assertIn("Thinking", str(caught.exception))
def test_a_reply_longer_than_the_transcript_is_cut_off(self):
# A small model will repeat the transcript until the context is full,
# and every one of those tokens is a second of somebody waiting.
with fake_urlopen(chat_reply("Hello.")) as calls:
api.cleanup(LOCAL_LLM, "x" * 4000, "prompt")
self.assertEqual(sent_json(calls[0])["max_tokens"], 4000)
def test_a_short_dictation_still_gets_room_to_answer(self):
with fake_urlopen(chat_reply("Hello.")) as calls:
api.cleanup(LOCAL_LLM, "uh, hi", "prompt")
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
def test_a_hosted_model_is_left_to_answer_at_length(self):
with fake_urlopen(chat_reply("Hello.")) as calls:
api.cleanup(openrouter(), "uh, hi", "prompt")
self.assertNotIn("max_tokens", sent_json(calls[0]))
def test_a_server_that_will_not_start_is_the_error_shown(self):
self.patch_attr(ggml, "llm", FakeServer(fails="llama.cpp is not installed"))
with self.assertRaises(api.ApiError) as caught:
api.cleanup(LOCAL_LLM, "hello", "prompt")
self.assertIn("llama.cpp", str(caught.exception))
+84 -3
View File
@@ -13,6 +13,7 @@ from unittest import mock
import api
import config as cfg
import ggml
import i18n
from tests.support import DikteTest
@@ -127,8 +128,17 @@ class Keys(DikteTest):
class TranscribeTarget(DikteTest):
def test_openai_by_default(self):
target = self.config(openai_api_key="sk-test").transcribe_target()
def test_this_machine_by_default(self):
target = cfg.Config().transcribe_target()
self.assertEqual(target.provider, "local")
self.assertEqual(target.api_key, "")
# Empty on purpose: the server picks a port when it starts, and reading
# a setting must not be what starts it.
self.assertEqual(target.base_url, "")
def test_openai_when_it_is_picked(self):
target = self.config(transcribe_provider="openai",
openai_api_key="sk-test").transcribe_target()
self.assertEqual(target.provider, "openai")
self.assertEqual(target.service, "OpenAI")
self.assertEqual(target.api_key, "sk-test")
@@ -146,7 +156,8 @@ class TranscribeTarget(DikteTest):
self.assertEqual(target.model, "openai/whisper-1")
def test_a_self_hosted_endpoint(self):
conf = self.config(openai_base_url="http://localhost:8080/v1")
conf = self.config(transcribe_provider="openai",
openai_base_url="http://localhost:8080/v1")
self.assertEqual(conf.transcribe_target().base_url, "http://localhost:8080/v1")
@@ -425,3 +436,73 @@ class Defaults(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class LocalTargets(DikteTest):
def test_cleanup_can_run_here_while_the_minutes_do_not(self):
# The two jobs are not the same size: a small model on this machine
# strips filler words perfectly well and will not write up an hour.
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf")
self.assertEqual(conf.cleanup_target().provider, "local-llm")
self.assertEqual(conf.minutes_target().provider, "openrouter")
self.assertEqual(conf.minutes_target().model, cfg.DEFAULTS["meeting_model"])
def test_the_minutes_can_run_here_on_their_own(self):
conf = self.config(meeting_provider="local", local_llm_model="gemma.gguf")
self.assertEqual(conf.minutes_target().model, "gemma.gguf")
self.assertEqual(conf.cleanup_target().provider, "openrouter")
def test_the_local_cleanup_target_carries_the_thinking_level(self):
conf = self.config(cleanup_provider="local", local_llm_model="gemma.gguf",
local_llm_reasoning="none")
target = conf.cleanup_target()
self.assertEqual(target.reasoning, "none")
self.assertEqual(target.api_key, "")
def test_either_of_them_counts_as_using_the_local_model(self):
self.assertFalse(cfg.Config().uses_local_llm())
self.assertTrue(self.config(cleanup_provider="local").uses_local_llm())
self.assertTrue(self.config(meeting_provider="local").uses_local_llm())
class ReadyToRun(DikteTest):
def setUp(self):
super().setUp()
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
def install(self, name):
path = ggml.whisper_model_path(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"model")
def test_a_missing_program_is_not_ready(self):
with mock.patch("shutil.which", return_value=None):
self.install("ggml-base.bin")
conf = self.config(local_model="ggml-base.bin")
self.assertFalse(conf.transcribe_ready())
def test_a_missing_model_is_not_ready_either(self):
with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"):
conf = self.config(local_model="ggml-base.bin")
self.assertFalse(conf.transcribe_ready())
def test_both_halves_in_place(self):
with mock.patch("shutil.which", return_value="/usr/bin/whisper-server"):
self.install("ggml-base.bin")
conf = self.config(local_model="ggml-base.bin")
self.assertTrue(conf.transcribe_ready())
def test_a_hosted_provider_is_ready_when_it_has_a_key(self):
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test")
self.assertTrue(conf.transcribe_ready())
def test_the_settings_reach_the_servers(self):
conf = self.config(local_model="ggml-base.bin", local_threads=4,
local_gpu=False, local_llm_model="gemma.gguf",
local_llm_context=4096)
conf.apply_local()
self.addCleanup(ggml.whisper.configure, model="", threads=0, gpu=True)
self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin")
self.assertEqual(ggml.whisper.settings()["threads"], 4)
self.assertFalse(ggml.whisper.settings()["gpu"])
self.assertEqual(ggml.llm.settings()["context"], 4096)
+1 -1
View File
@@ -197,7 +197,7 @@ class Transcriber(DikteTest):
def test_cleanup_is_told_it_is_writing_subtitles(self):
_, _, _, cleanup_call = self.run_chain(cleanup=True)
prompt = cleanup_call.call_args.args[3]
prompt = cleanup_call.call_args.args[2]
self.assertEqual(prompt, self.conf.cleanup_prompt(subtitles=True))
def test_timestamps_come_back_as_segments_and_as_stamped_lines(self):
+60
View File
@@ -41,8 +41,18 @@ CHANGED = {
"transcribe_model": "whisper-1",
"openrouter_transcribe_model": "openai/whisper-1",
"cleanup_enabled": False,
"cleanup_provider": "local",
"cleanup_model": "some/other-model",
"cleanup_reasoning": "high",
"local_model": "ggml-small.bin",
"local_gpu": False,
"local_preload": False,
"local_threads": 6,
"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,
"local_llm_preload": True,
"local_llm_reasoning": "low",
"cleanup_prompt": "Only fix the punctuation.",
"file_cleanup_prompt": "Keep the stamps where they are.",
"transcribe_prompt": "Paraşüt, OpenFrame",
@@ -259,3 +269,53 @@ class Overlay(DikteTest):
if __name__ == "__main__":
unittest.main()
class LocalModels(DikteTest):
"""The download boxes, without a network and without either program."""
def window(self, conf):
window = settings_ui.SettingsWindow(conf, "dikte toggle")
self.addCleanup(window.deleteLater)
self.addCleanup(window.close)
return window
def test_it_opens_where_the_missing_model_is_fixed(self):
# Nothing can transcribe on a fresh install, which is why this window
# was opened at all.
window = self.window(cfg.Config())
self.assertEqual(window.tabs.currentIndex(), window.api_tab_index)
def test_it_opens_where_it_was_left_when_everything_works(self):
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test")
self.assertEqual(self.window(conf).tabs.currentIndex(), 0)
def test_a_model_that_is_not_here_yet_survives_a_save(self):
# The box is filled from what is on this disk, so a model that was
# deleted from underneath is not in the list. Dropping it on save would
# quietly empty the setting instead of asking for the download again.
conf = self.config(local_model="ggml-large-v3-turbo-q5_0.bin")
with mock.patch.object(QMessageBox, "information"):
self.window(conf)._save()
self.assertEqual(conf["local_model"], "ggml-large-v3-turbo-q5_0.bin")
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.
window = self.window(cfg.Config())
self.assertTrue(window.local_whisper._pending)
def test_the_hosted_boxes_go_away_when_the_work_happens_here(self):
window = self.window(self.config(transcribe_provider="openai"))
self.assertTrue(window.hosted_stt.isVisibleTo(window))
self.assertFalse(window.local_whisper.isVisibleTo(window))
window._select_data(window.transcribe_provider, "local")
self.assertFalse(window.hosted_stt.isVisibleTo(window))
self.assertTrue(window.local_whisper.isVisibleTo(window))
def test_the_same_for_cleanup(self):
window = self.window(cfg.Config())
self.assertTrue(window.hosted_cleanup.isVisibleTo(window))
window._select_data(window.cleanup_provider, "local")
self.assertTrue(window.local_llm.isVisibleTo(window))
self.assertFalse(window.hosted_cleanup.isVisibleTo(window))
+1 -1
View File
@@ -234,7 +234,7 @@ class Chain(DikteTest):
self.assertEqual(row["raw"], "uh, book it for Thursday")
self.assertEqual(row["text"], "Book it for Thursday.")
self.assertEqual(row["duration"], 2.0)
self.assertEqual(row["model"], self.conf["transcribe_model"])
self.assertEqual(row["model"], self.conf.transcribe_target().model)
self.assertEqual(row["mode"], "")
def test_a_command_is_recorded_as_one(self):
+3 -9
View File
@@ -106,20 +106,14 @@ class Pipeline(QObject):
text = raw
warning = ""
cleaner = conf.cleanup_target()
# Claude reads through “eee” and “hani” without help, so a dictation
# on its way there is normally sent as it was heard, one API call and
# a second or two lighter.
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
self.stage.emit(t("Cleaning up…"))
try:
text = api.cleanup(
raw,
conf.openrouter_key(),
conf["cleanup_model"],
conf.cleanup_prompt(),
reasoning=conf["cleanup_reasoning"],
base_url=conf["openrouter_base_url"],
)
text = api.cleanup(cleaner, raw, conf.cleanup_prompt())
except api.ApiError as exc:
# Keep the transcript, but never let the failure pass unseen:
# a rejected key would otherwise look like working dictation.
@@ -159,7 +153,7 @@ class Pipeline(QObject):
"duration": round(duration, 1),
"elapsed": round(time.monotonic() - started, 1),
"model": target.model,
"cleanup_model": conf["cleanup_model"] if conf["cleanup_enabled"] else "",
"cleanup_model": cleaner.model if conf["cleanup_enabled"] else "",
"cleanup_error": warning,
"mode": "ask" if ask else "",
"question": question,