Compare commits

...
Author SHA1 Message Date
yusufipek e85622aefb Build cues out of word times where a model marks no segments
Not every model behind /audio/transcriptions marks segments the way
whisper does. microsoft/mai-transcribe-2 answers a fourteen minute video
with three of them, one per paragraph, and to_srt turns each into a cue
that stays up for minutes. The model is worth keeping for what it hears,
so the times are taken from somewhere else instead: the same request now
asks for word timestamps too, and where the segments come back too long
to be cues, the cues are cut out of the words.

A cue ends where a sentence does, and failing that where it has grown
too long to read or to leave up. A full stop too early in a cue is not
the end of a sentence but a list marker or a shortened word, and one
that ends up short anyway is held on screen until the next needs the
space. Whisper still answers with its own segments and nothing on that
path changes; the local server is not asked for words it was never
asked for, and a hosted model that refuses the field falls back to the
request it used to answer.

A cue is short enough now that two can begin in the same second, so
to_srt hands out every timing a second holds rather than the first.
2026-09-07 11:56:19 +03:00
yusufipek 4b3ae8d70b Dikte 1.2.0 2026-09-05 12:21:48 +03:00
Yusuf İpek 93944f6c14 Merge pull request #80 from yusufipk/claude/transcript-cleaning-turkish-chars-6d8084
Unload a local model that has been sitting unused
2026-09-05 12:20:54 +03:00
yusufipek 2af5ec671c Merge master into the idle unload
Two conflicts, both where the local cleanup grew a second thing at once.

cleanup._local now passes the server's context alongside the timeout, and this
branch wrapped that same call in the busy() hold; the call takes both.

FakeServer gained a `context` attribute on master and a `held` counter here.
2026-09-05 12:18:50 +03:00
yusufipek 825f089fe9 Give the memory back when a local model has been sitting unused
A whisper.cpp or llama.cpp server started for one dictation stayed loaded
until Dikte quit. On this machine that is 1.3 GB of VRAM for large-v3 plus
whatever the cleanup LLM takes, held all day between dictations that last
seconds.

Each Server now carries an idle window. A watcher thread per launch stops the
server once nothing has asked it anything for that long, and the next request
loads it again through serve(), which already starts what is not running.
Settings has one checkbox and one number for both servers, on by default at ten
minutes, and it only appears for a machine that runs a model here. The tray menu
says which models are loaded and offers to unload them now.

Two things the clock alone gets wrong, both held off by a count of requests in
flight:

  * A file or a meeting is one address lookup and then minutes of work, which
    to a clock started at the lookup looks exactly like a model nobody wants.
    api.py and cleanup.py hold the count for the length of the request.

  * The count must survive the start it triggered. cleanup._local takes the
    hold and only then asks for the address, so a cold start happens inside it;
    neither serve() nor _stop_now() resets the count any more.

Unloading by hand runs on the interface's thread, so it asks for the start lock
rather than waiting on it: a model still being read in is refused, the way one
in the middle of a request is, instead of freezing the window for as long as
the load takes.
2026-09-05 12:15:14 +03:00
Yusuf İpek 5a4ae8c315 Merge pull request #79 from yusufipk/claude/bazen-publisher-change-blank-c6832e
Measure a wrapped label against a width it actually has
2026-09-05 12:12:46 +03:00
yusufipek 22d2a40341 Count the lines off the font, not off this machine's font
The new test pinned the wrapped height at four lines, which is four lines
on a Linux runner and four and a half on a Windows one, where the same
sentence in the same 400 pixels needs 54 of the box's 48. What the test is
actually about is that the height comes from the width the label has now
rather than the eight pixels it had while the window was being built, so it
measures that width itself and compares against the answer.
2026-09-05 12:10:58 +03:00
yusufipek 44db26c459 Measure a wrapped label against a width it actually has
The publisher note is written while the settings window is still being
built, when its label is eight pixels wide. Wrapped against that width the
sentence came out a hundred and twenty lines tall, and the minimum taken
from it did not stay a minimum: QLabel folds the widget's minimum size into
its own cached size hints and clears that cache only when the text changes.
So the row stood two thousand pixels tall, carrying the model box, the
status line and the options under it off the bottom of the window, and
picking another publisher was what brought them back.

Nothing to measure against yet means nothing to claim yet. The show and the
resize come back for it once there is a real width.
2026-09-05 12:07:13 +03:00
Yusuf İpek 3e3cb21bc3 Merge pull request #78 from yusufipk/claude/yerel-model-thinking-limit-66077e
Give a local model room to think without spending the answer on it
2026-09-05 12:05:04 +03:00
yusufipek 70bc4c16fa Give a local model room to think without spending the answer on it
llama.cpp counts the thinking towards max_tokens along with the answer it
precedes, and the local ceiling was sized for the answer alone. Turning
Thinking up therefore came out of the reply rather than being added to
it, and on a short dictation the 512 floor is the whole budget, so the
model spent it in the think block and came back with nothing to paste.

Each rung of the ladder now carries its own budget, doubling from 256 at
"minimal" to 8192 at "maximum", added on top of the answer's share
rather than taken out of it. The rungs are small because cleanup is
punctuation and locally every one of these tokens is also a second of
somebody standing in front of the screen. "Off" keeps the old tight
ceiling untouched, and an empty setting is given a middling amount,
since a template that can think thinks by default and there is no way to
ask which kind of model this is.

The ceiling is also held under what the server was started with. Above
the context it is not a ceiling at all: the runaway it exists to stop
would run to the end of the context instead, which on CPU is minutes of
waiting. The prompt keeps its share at two characters to the token,
which is under any tokeniser's rate for natural language and so reserves
too much rather than promising room that is not there.

Separately, a reply cut off at somebody's ceiling was returned as if it
were whole. Half a sentence looks like a cleaned-up transcript and is
not one, so finish_reason is now read in both cleanup and chat. The
callers already keep the transcript they started with, which is the
better of the two. This one is not local-only: a hosted provider
stopping at its own output limit was silently pasted the same way.
2026-09-05 12:02:28 +03:00
Yusuf İpek b13b08fc38 Merge pull request #77 from yusufipk/claude/download-start-indicator-4e6835
Say the download started before the first byte arrives
2026-09-05 11:39:48 +03:00
16 changed files with 855 additions and 42 deletions
+2 -1
View File
@@ -161,7 +161,8 @@ running.
- **It all runs on this machine by default.** Speech to text on whisper.cpp and - **It all runs on this machine by default.** Speech to text on whisper.cpp and
cleanup on llama.cpp, neither installed beforehand: the settings window fetches cleanup on llama.cpp, neither installed beforehand: the settings window fetches
the program and the model, verifies the sha256 and refuses a download published the program and the model, verifies the sha256 and refuses a download published
without one, then keeps a server alive while you dictate. The model list is without one, then keeps a server alive while you dictate and hands the memory
back once it has sat unused for ten minutes. The model list is
grouped by model rather than by file size, and the row this machine's memory grouped by model rather than by file size, and the row this machine's memory
and graphics can take is marked. The graphics card is and graphics can take is marked. The graphics card is
reached through CUDA, ROCm or Vulkan where the build allows. No key, no reached through CUDA, ROCm or Vulkan where the build allows. No key, no
+2 -1
View File
@@ -158,7 +158,8 @@ olmasını ister.
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez: whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular, ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
ayakta tutar. Model listesi dosya boyutuna değil modele göre gruplanır ve bu ayakta tutar ve on dakika kullanılmayan modelin belleğini geri verir. Model
listesi dosya boyutuna değil modele göre gruplanır ve bu
makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok. üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
+1 -1
View File
@@ -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 # 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 # 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. # bug report from someone running "latest" names a commit.
__version__ = "1.1.0" __version__ = "1.2.0"
+189 -19
View File
@@ -384,15 +384,20 @@ def _transcribe_request(target, audio_path, language, prompt, response_format,
# takes it as the initial prompt, the way OpenAI does. # takes it as the initial prompt, the way OpenAI does.
if prompt and target.provider != "openrouter": if prompt and target.provider != "openrouter":
fields.append(("prompt", prompt)) fields.append(("prompt", prompt))
if granularity: for level in granularity or ():
fields.append(("timestamp_granularities[]", granularity)) fields.append(("timestamp_granularities[]", level))
body, ctype = _multipart(fields, "file", audio_path) body, ctype = _multipart(fields, "file", audio_path)
# An hour of meeting takes the local server a while, and the idle unload has
# to count that as the model being used rather than as nobody wanting it.
held = (ggml.whisper.busy() if target.provider == "local"
else contextlib.nullcontext())
try: try:
return _request( with held:
f"{target.base_url.rstrip('/')}/audio/transcriptions", body, return _request(
_headers(target.provider, target.api_key, ctype), timeout=timeout, f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
aborter=aborter, _headers(target.provider, target.api_key, ctype), timeout=timeout,
) aborter=aborter,
)
except ApiError as exc: except ApiError as exc:
if target.provider == "local": if target.provider == "local":
raise local_failure(target.service, ggml.whisper, exc) from None raise local_failure(target.service, ggml.whisper, exc) from None
@@ -438,6 +443,96 @@ def _merge_word_splits(segments):
return merged return merged
# A cue built here is one a reader has time for: about two lines of subtitle,
# and no longer on screen than a sentence takes to say. Neither is a hard rule
# for a sentence that ends early, only the point past which one is broken.
MAX_CUE_SECONDS = 7.0
MAX_CUE_CHARS = 84
# The other end of it: a cue nobody can read because it was gone before they
# looked. A full stop this early in a cue is not the end of anything worth
# breaking on, which is what "1." and "Dr." are, and a cue that ends up short
# anyway is held on screen until the next one needs the space.
MIN_CUE_SECONDS = 1.2
# No whisper segment is longer than the window it was heard in, so a segment
# that runs past this came from a model that is not marking segments at all.
WHISPER_WINDOW = 30.0
SENTENCE_END = ".!?…"
def _too_coarse(segments):
"""Whether these segments are too long to be cues, or are not there at all.
Not every model behind /audio/transcriptions marks segments the way whisper
does. Some fill the field with one entry per paragraph, or with a single one
covering the whole file, which turns a fourteen minute video into three
subtitles. Word times are what those models do give, and cues built from
them are better than what the segments would have been.
"""
if not segments:
return True
return any(float(seg.get("end") or 0.0) - float(seg.get("start") or 0.0)
> WHISPER_WINDOW for seg in segments)
def cues_from_words(words):
"""[(start, end, text)] cut out of word times, where segments were no use.
A cue ends where a sentence does, and failing that wherever it has grown too
long to read or too long to leave up. Nothing is ever cut between two words:
the times that arrive are per word, and so are the ones that leave.
"""
cues = []
start = end = 0.0
current = []
def flush():
nonlocal current
if current:
cues.append((start, max(end, start), " ".join(current)))
current = []
for word in words:
text = (word.get("word") or "").strip()
if not text:
continue
at = float(word.get("start") or 0.0)
until = float(word.get("end") or at)
if current:
grown = len(" ".join(current)) + 1 + len(text)
if grown > MAX_CUE_CHARS or until - start > MAX_CUE_SECONDS:
flush()
if not current:
start = at
current.append(text)
end = until
# A sentence can end inside the punctuation that closes a quote. What
# is too short to have been a sentence is a list marker or a shortened
# word, and the cue goes on rather than ending on it.
if (end - start >= MIN_CUE_SECONDS
and text.rstrip("\"')]»”’").endswith(tuple(SENTENCE_END))):
flush()
flush()
return _held(cues)
def _held(cues):
"""Keep a cue that is still too short on screen, without covering the next.
A one word sentence is a fifth of a second of audio and so a fifth of a
second of subtitle, which is a flicker. It stays up until the cue after it
starts, or for as long as it takes to read, whichever comes first.
"""
out = []
for index, (start, end, text) in enumerate(cues):
if end - start < MIN_CUE_SECONDS:
room = start + MIN_CUE_SECONDS
if index + 1 < len(cues):
room = min(room, cues[index + 1][0])
end = max(end, room)
out.append((start, end, text))
return out
def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=None): def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=None):
data = _transcribe_request( data = _transcribe_request(
target, audio_path, language, prompt, "json", timeout=timeout, aborter=aborter target, audio_path, language, prompt, "json", timeout=timeout, aborter=aborter
@@ -454,15 +549,34 @@ def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=
def transcribe_segments(target, audio_path, language="", prompt="", timeout=300, def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
aborter=None): aborter=None):
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" """[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
data = _transcribe_request( target = target._replace(model=timestamp_model(target.provider, target.model,
target._replace(model=timestamp_model(target.provider, target.model, target.file_model))
target.file_model)), ask = dict(language=language, prompt=prompt, response_format="verbose_json",
audio_path, language, prompt, "verbose_json", timeout=timeout, aborter=aborter)
granularity="segment", timeout=timeout, aborter=aborter, # Word times are the way out of a model that does not mark segments, and
) # whisper.cpp is not one of those, so the local server is only ever asked
# for what it has always been asked for. A hosted model that refuses the
# field says so with a 400, and the request it used to answer is still
# there to fall back on rather than losing the run over a field it did not
# need in the first place.
if target.provider == "local":
data = _transcribe_request(target, audio_path, granularity=("segment",), **ask)
else:
try:
data = _transcribe_request(target, audio_path,
granularity=("segment", "word"), **ask)
except ApiError as exc:
if exc.status != 400:
raise
data = _transcribe_request(target, audio_path,
granularity=("segment",), **ask)
segments = data.get("segments") or [] segments = data.get("segments") or []
if target.provider == "local": if target.provider == "local":
segments = _merge_word_splits(segments) segments = _merge_word_splits(segments)
if _too_coarse(segments):
cues = cues_from_words(data.get("words") or [])
if cues:
return cues
out = [] out = []
for seg in segments: for seg in segments:
text = (seg.get("text") or "").strip() text = (seg.get("text") or "").strip()
@@ -518,22 +632,65 @@ def _thinking(payload, provider, reasoning):
payload["reasoning"] = {"effort": reasoning, "exclude": True} payload["reasoning"] = {"effort": reasoning, "exclude": True}
def local_ceiling(text): # Room for the thinking on this machine, one budget per rung of the settings
# ladder. llama.cpp counts the thinking towards max_tokens along with the answer
# it precedes, so a ceiling sized for the answer alone leaves a model that
# thinks nothing to answer with. The rungs double, starting where a small model
# lands when it barely thinks at all: cleanup is punctuation, and locally every
# one of these tokens is also a second of somebody standing in front of the
# screen, so the low rungs are the ones meant to be used.
THINKING_ROOM = {
"minimal": 256, "low": 512, "medium": 1024,
"high": 2048, "xhigh": 4096, "max": 8192,
}
# An empty setting leaves it to the model, and the templates that can think
# think by default. Room for a middling amount of it, since there is no way to
# ask which kind of model this is.
DEFAULT_THINKING_ROOM = THINKING_ROOM["medium"]
def local_ceiling(text, reasoning="", context=0, prompt=""):
"""How much of a reply is worth waiting for from a model on this machine. """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 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 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 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 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 waiting, with only the hour-long local timeout underneath. A hosted model is
ceiling would cut the minutes short instead. left alone: there the same runaway is rare, and a ceiling would cut the
minutes short instead.
The answer's share is the transcript's length in characters spent as a
budget in tokens, so what it really allows is two to four times the
transcript depending on how well the language tokenises. Turkish sits at the
tight end of that and still has room to spare for a reply that is meant to
come back the same length it went in.
Thinking is added on top of that share rather than taken out of it. Sharing
one budget is what makes turning thinking up quietly cost the answer, and on
a short dictation the 512 floor is the whole budget, so the answer is what
goes missing first.
`context` is what the server was started with, and the whole of it is the
real limit whatever is asked for here: a ceiling above it is not a ceiling,
because the runaway it exists to stop would run to the end of the context
instead. So the ceiling is held below what the prompt leaves. Two characters
to the token is under any tokeniser's rate for natural language, Turkish
included, which makes the reserve an over-estimate rather than a promise of
room that is not there.
""" """
return max(512, len(text)) answer = max(512, len(text))
if reasoning != "none":
answer += THINKING_ROOM.get(reasoning, DEFAULT_THINKING_ROOM)
context = int(context or 0)
if not context:
return answer
return max(256, min(answer, context - (len(prompt) + len(text)) // 2))
def cleanup(text, api_key, model, system_prompt, reasoning="", def cleanup(text, api_key, model, system_prompt, reasoning="",
base_url=OPENROUTER_URL, timeout=180, provider="openrouter", base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
service="OpenRouter", aborter=None): service="OpenRouter", aborter=None, context=0):
if not api_key and provider != "local-llm": if not api_key and provider != "local-llm":
raise ApiError(t("{service} API key is empty. Add it in Settings.", raise ApiError(t("{service} API key is empty. Add it in Settings.",
service=service)) service=service))
@@ -546,7 +703,8 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
], ],
} }
if provider == "local-llm": if provider == "local-llm":
payload["max_tokens"] = local_ceiling(text) payload["max_tokens"] = local_ceiling(text, reasoning, context,
system_prompt)
_thinking(payload, provider, reasoning) _thinking(payload, provider, reasoning)
try: try:
data = _request( data = _request(
@@ -570,6 +728,13 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
raise ApiError(t("The cleanup model spent its whole reply on " raise ApiError(t("The cleanup model spent its whole reply on "
"thinking. Set Thinking to \u201cOff\u201d.")) "thinking. Set Thinking to \u201cOff\u201d."))
raise ApiError(t("The cleanup model returned an empty reply.")) raise ApiError(t("The cleanup model returned an empty reply."))
if choices[0].get("finish_reason") == "length":
# Cut off at somebody's ceiling: ours locally, the provider's otherwise.
# What came back is a sentence that stops mid-word, and cleanup is meant
# to hand back the whole dictation, so the half is refused rather than
# returned. The callers keep the transcript they started with, which is
# the better of the two.
raise ApiError(t("The cleanup model was cut off before it finished."))
return content return content
@@ -605,6 +770,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="",
content = ((choices[0].get("message") or {}).get("content") or "").strip() content = ((choices[0].get("message") or {}).get("content") or "").strip()
if not content: if not content:
raise ApiError(t("The model returned an empty reply.")) raise ApiError(t("The model returned an empty reply."))
if choices[0].get("finish_reason") == "length":
# An answer that stops mid-sentence reads like a whole one once it has
# been pasted, so it is refused here for the same reason cleanup refuses
# a half transcript.
raise ApiError(t("The model was cut off before it finished."))
return content return content
+36
View File
@@ -289,6 +289,11 @@ class Dikte:
self.update_action.triggered.connect(self.open_release_page) self.update_action.triggered.connect(self.open_release_page)
self.menu.addAction(self.update_action) self.menu.addAction(self.update_action)
# Named in _refresh_tray, which is where the loaded models are known.
self.unload_action = QAction("", self.menu)
self.unload_action.triggered.connect(self.unload_models)
self.menu.addAction(self.unload_action)
self.settings_action = QAction(t("Settings…"), self.menu) self.settings_action = QAction(t("Settings…"), self.menu)
self.settings_action.triggered.connect(self.open_settings) self.settings_action.triggered.connect(self.open_settings)
self.menu.addAction(self.settings_action) self.menu.addAction(self.settings_action)
@@ -303,6 +308,10 @@ class Dikte:
self.menu.addAction(self.quit_action) self.menu.addAction(self.quit_action)
self.tray.setContextMenu(self.menu) self.tray.setContextMenu(self.menu)
# A model unloads itself in the background, so what the unload row says
# goes stale between state changes. Refreshed as the menu opens, which
# is the only moment anybody reads it.
self.menu.aboutToShow.connect(self._refresh_tray)
self.tray.setToolTip(t("Dikte: ready")) self.tray.setToolTip(t("Dikte: ready"))
self.tray.activated.connect(self._tray_clicked) self.tray.activated.connect(self._tray_clicked)
self._refresh_update() self._refresh_update()
@@ -398,6 +407,21 @@ class Dikte:
) )
self.ask_cancel_action.setEnabled(self.ask_state == BUSY) self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
# A local model holds its memory whether or not anything is using it, so
# the menu says which of the two are loaded and offers to give it back.
# Hidden on a machine that runs neither: there is nothing to unload and
# nothing to report.
loaded = [server for server in (ggml.whisper, ggml.llm) if server.running]
self.unload_action.setVisible(
self.conf["transcribe_provider"] == "local" or self.conf.uses_local_llm()
)
self.unload_action.setText(
t("Unload the models") if len(loaded) > 1
else t("Unload the model") if loaded
else t("No model loaded")
)
self.unload_action.setEnabled(bool(loaded))
# The agent speaks through the icon only when dictation has nothing to # The agent speaks through the icon only when dictation has nothing to
# say, since dictation is the one being waited on in front of a screen. # say, since dictation is the one being waited on in front of a screen.
if self.state == IDLE and self.ask_state != IDLE: if self.state == IDLE and self.ask_state != IDLE:
@@ -1209,6 +1233,18 @@ class Dikte:
QDesktopServices.openUrl( QDesktopServices.openUrl(
QUrl(release.url if release is not None else update.RELEASES_PAGE)) QUrl(release.url if release is not None else update.RELEASES_PAGE))
def unload_models(self):
"""Give the memory back now rather than when the idle window closes."""
held = [server for server in (ggml.whisper, ggml.llm)
if not server.unload()]
self._refresh_tray()
if held:
self.tray.showMessage(
"Dikte",
t("A model is loading or answering right now. Try again in a "
"moment."),
QSystemTrayIcon.MessageIcon.Information, 5000)
# ---- settings --------------------------------------------------------- # ---- settings ---------------------------------------------------------
def open_settings(self): def open_settings(self):
+14 -7
View File
@@ -115,13 +115,20 @@ def _local(text, conf, system_prompt, timeout, aborter=None):
""" """
service = t("Local model") service = t("Local model")
try: try:
return api.cleanup( # Held for the length of the request so that the idle unload does not
text, "", conf["local_llm_model"], system_prompt, # take the model away from a block still being cleaned up.
reasoning=conf["local_llm_reasoning"], with ggml.llm.busy():
base_url=api.serving(ggml.llm), return api.cleanup(
timeout=max(timeout, api.LOCAL_TIMEOUT), text, "", conf["local_llm_model"], system_prompt,
provider="local-llm", service=service, aborter=aborter, reasoning=conf["local_llm_reasoning"],
) base_url=api.serving(ggml.llm),
timeout=max(timeout, api.LOCAL_TIMEOUT),
provider="local-llm", service=service, aborter=aborter,
# The ceiling is only a ceiling while it sits under what the
# server was started with; above that the context is what stops
# the reply.
context=ggml.llm.settings()["context"],
)
except api.ApiError as exc: except api.ApiError as exc:
# A server that died mid-request would otherwise report only that the # A server that died mid-request would otherwise report only that the
# connection dropped, when the reason is in its own output. # connection dropped, when the reason is in its own output.
+17
View File
@@ -442,6 +442,15 @@ DEFAULTS = {
# Off rather than empty: a model trained to think will, and 300 tokens of # Off rather than empty: a model trained to think will, and 300 tokens of
# reasoning about a comma is 300 tokens of waiting. # reasoning about a comma is 300 tokens of waiting.
"local_llm_reasoning": "none", "local_llm_reasoning": "none",
# --- what happens to both of them when nothing is using them -------------
# One pair for the two servers rather than a pair each: what is being
# decided is whether a machine keeps gigabytes tied up between dictations,
# and nobody wants that answered one model at a time. On by default because
# a reload costs seconds and the memory costs the rest of the desktop.
"local_idle_unload": True,
"local_idle_minutes": 10,
"cleanup_prompt": "", # empty -> language-specific default "cleanup_prompt": "", # empty -> language-specific default
"auto_paste": True, "auto_paste": True,
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac "paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
@@ -710,6 +719,14 @@ class Config:
binary=self["local_llm_binary"], binary=self["local_llm_binary"],
context=int(self["local_llm_context"]), context=int(self["local_llm_context"]),
) )
ggml.whisper.set_idle(self.idle_seconds())
ggml.llm.set_idle(self.idle_seconds())
def idle_seconds(self):
"""How long a loaded model may sit unused. 0 means it is kept."""
if not self["local_idle_unload"]:
return 0
return max(1, int(self["local_idle_minutes"])) * 60
def uses_local_llm(self): def uses_local_llm(self):
"""Whether anything is set to run the local cleanup model.""" """Whether anything is set to run the local cleanup model."""
+11 -2
View File
@@ -283,11 +283,20 @@ def to_srt(text, segments):
hours, minutes, secs = (int(g or 0) for g in match.groups()) hours, minutes, secs = (int(g or 0) for g in match.groups())
cues.append([hours * 3600 + minutes * 60 + secs, None, body]) cues.append([hours * 3600 + minutes * 60 + secs, None, body])
# Several cues can share a whole second, so a second holds every segment
# that began in it and they are handed out in the order they were spoken.
timing = {} timing = {}
for start, end, _ in segments: for start, end, _ in segments:
timing.setdefault(int(start), (start, end)) timing.setdefault(int(start), []).append((start, end))
for cue in cues: for cue in cues:
cue[0], cue[1] = timing.get(cue[0], (float(cue[0]), 0.0)) found = timing.get(cue[0])
if found:
# The last one stays, so a second with more lines than it has
# timings hands the last of them out again rather than falling back
# to the bare second, which would run backwards from the line above.
cue[0], cue[1] = found.pop(0) if len(found) > 1 else found[0]
else:
cue[0], cue[1] = float(cue[0]), 0.0
for index, cue in enumerate(cues): for index, cue in enumerate(cues):
following = cues[index + 1][0] if index + 1 < len(cues) else 0.0 following = cues[index + 1][0] if index + 1 < len(cues) else 0.0
if following > cue[0]: if following > cue[0]:
+128 -2
View File
@@ -26,6 +26,7 @@ interface already knows how to show.
import atexit import atexit
import collections import collections
import contextlib
import ctypes import ctypes
import ctypes.util import ctypes.util
import hashlib import hashlib
@@ -68,6 +69,10 @@ STARTUP_TIMEOUT = 180.0
# to load takes longer than this to be read in first. The line between "worth # to load takes longer than this to be read in first. The line between "worth
# another port" and "would fail the same way again" is drawn on time. # another port" and "would fail the same way again" is drawn on time.
EARLY_EXIT_WINDOW = 5.0 EARLY_EXIT_WINDOW = 5.0
# How often the watcher looks at a model it has been asked to unload when idle.
# Short next to any window worth setting, so the memory goes back within seconds
# of the window closing rather than a minute after it.
IDLE_CHECK_SECONDS = 5.0
DOWNLOAD_CHUNK = 1 << 20 DOWNLOAD_CHUNK = 1 << 20
# `health` is the path that answers only once the model is in memory. whisper # `health` is the path that answers only once the model is in memory. whisper
@@ -1116,6 +1121,15 @@ class Server:
# The pid this instance last wrote to its pid file, so _forget never # The pid this instance last wrote to its pid file, so _forget never
# removes a file some other Dikte wrote after us. # removes a file some other Dikte wrote after us.
self._pid = 0 self._pid = 0
# The idle unload. `_idle` is the window in seconds, zero meaning the
# model stays loaded until something else stops it; `_used` is when the
# address was last handed out or a request last finished; `_busy` counts
# the requests still in flight. The count is there because a file being
# transcribed is one address lookup and then minutes of work, which to a
# clock started at the lookup looks exactly like a model nobody wants.
self._idle = 0.0
self._used = 0.0
self._busy = 0
# ---- settings -------------------------------------------------------- # ---- settings --------------------------------------------------------
@@ -1133,6 +1147,23 @@ class Server:
with self._lock: with self._lock:
return dict(self._settings) return dict(self._settings)
def set_idle(self, seconds):
"""How long a loaded model may sit unused before the memory goes back.
Deliberately not one of the settings above: those describe the server
that is running, and changing one has to restart it. This describes how
long to keep it, which the server it is applied to never needs to know.
Zero keeps the model until something else stops it.
"""
with self._lock:
self._idle = max(0.0, float(seconds))
@property
def idle(self):
"""The window `set_idle` was last given, in seconds."""
with self._lock:
return self._idle
def _settings_key(self): def _settings_key(self):
"""What a running server would have to be restarted for.""" """What a running server would have to be restarted for."""
return json.dumps(self._settings, sort_keys=True, default=str) return json.dumps(self._settings, sort_keys=True, default=str)
@@ -1172,13 +1203,83 @@ class Server:
proc, port, log = self._launch(settings) proc, port, log = self._launch(settings)
with self._lock: with self._lock:
self._proc, self._port, self._log, self._key = proc, port, log, key self._proc, self._port, self._log, self._key = proc, port, log, key
# 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
# spend the whole request unprotected.
self._used = time.monotonic()
threading.Thread(target=self._watch, args=(proc,), daemon=True).start()
return self.base_url() return self.base_url()
def _current_url(self): def _current_url(self):
"""The address of a server running the current settings, or "".
Asking counts as using it. Everything that asks is about to send a
request, and the idle watcher reads the same clock, so the stamp has to
be set here rather than where the answer comes back.
"""
with self._lock: with self._lock:
up = self._proc is not None and self._proc.poll() is None up = self._proc is not None and self._proc.poll() is None
return (f"http://{HOST}:{self._port}/v1" if not (up and self._key == self._settings_key()):
if up and self._key == self._settings_key() else "") return ""
self._used = time.monotonic()
return f"http://{HOST}:{self._port}/v1"
@contextlib.contextmanager
def busy(self):
"""Hold the model for the length of one request.
A dictation is over a second after the address was handed out, but a
file is minutes of it, and an hour of meeting is longer still. Without
the count the watcher would unload the model out from under the request
that started it.
"""
with self._lock:
self._busy += 1
try:
yield
finally:
with self._lock:
# Nothing else moves the count, so every hold that was taken is
# given back here and it stays balanced across a restart. A hold
# outliving the server it was taken against only keeps the next
# one loaded a moment longer, which is the safe way round.
self._busy -= 1
self._used = time.monotonic()
def _idle_now(self, proc):
"""Whether `proc` is still ours and has been sitting unused long enough."""
with self._lock:
if self._proc is not proc or not self._idle or self._busy:
return False
return time.monotonic() - self._used >= self._idle
def _watch(self, proc):
"""Give the memory back when nothing has asked anything for a while.
One thread per launch, holding the process it was started for, so that a
server stopped and started again is watched by the new thread alone and
this one leaves on the first pass that finds its own process gone.
Started whatever the window is, zero included: turning the unload on in
Settings has to reach a model that is already loaded, and a thread that
wakes every few seconds to read one number is cheaper than the machinery
for starting one later.
"""
while True:
time.sleep(IDLE_CHECK_SECONDS)
with self._lock:
if self._proc is not proc:
return # stopped, or replaced by a later launch
if not self._idle_now(proc):
continue
with self._starting:
# Asked once more under the lock a start has to take. An address
# handed out while this thread waited its turn stamps _used, and
# the request behind it must not arrive at a server killed here.
if self._idle_now(proc):
self._stop_now()
return
def _launch(self, settings): def _launch(self, settings):
args = self._build(settings) # raises LocalError when unusable args = self._build(settings) # raises LocalError when unusable
@@ -1285,6 +1386,31 @@ class Server:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
def unload(self):
"""Stop the server unless it is in the middle of something.
The same rule the idle watcher goes by, taken by hand from the menu, and
it says no for the same reason: the memory is worth having back, but not
at the price of the dictation waiting on it. A model still being read in
counts as in the middle of something too, and that is why the lock is
asked for rather than waited on: this runs on the interface's own
thread, and a start holds _starting for as long as the load takes, which
for a large model on a cold cache is most of a minute. True when nothing
is loaded any more, either way.
"""
if not self._starting.acquire(blocking=False):
return False
try:
with self._lock:
if self._proc is None:
return True
if self._busy:
return False
self._stop_now()
return True
finally:
self._starting.release()
def stop(self): def stop(self):
# Taking _starting means a stop cannot slide past a launch in flight: # Taking _starting means a stop cannot slide past a launch in flight:
# serve() finishes registering its child first, and the child is then # serve() finishes registering its child first, and the child is then
+24
View File
@@ -782,6 +782,26 @@ TR = {
"On this machine": "Bu makinede", "On this machine": "Bu makinede",
"Use the graphics card": "Ekran kartını kullan", "Use the graphics card": "Ekran kartını kullan",
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle", "Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
"Models on this machine": "Bu makinedeki modeller",
"Unload a model that is sitting unused": "Kullanılmayan modeli bellekten çıkar",
"A loaded model holds its memory whether anything is using it or "
"not: over a gigabyte for whisper, several for an LLM. Unloading "
"gives that back to the rest of the desktop, and the next "
"dictation loads it again at the cost of the seconds that takes.":
"Yüklü bir model, kullanılsa da kullanılmasa da belleği tutar: whisper "
"için bir gigabaytın üzerinde, bir LLM için birkaç gigabayt. Bellekten "
"çıkarmak bunu masaüstünün geri kalanına iade eder, sonraki dikte de "
"modeli birkaç saniye bekleyerek yeniden yükler.",
" minute": " dakika",
" minutes": " dakika",
"After": "Şu kadar sonra",
"Unload the model": "Modeli bellekten çıkar",
"Unload the models": "Modelleri bellekten çıkar",
"No model loaded": "Yüklü model yok",
"A model is loading or answering right now. Try again in a "
"moment.":
"Bir model şu anda yükleniyor ya da cevap veriyor. Az sonra tekrar "
"deneyin.",
"Local whisper": "Yerel whisper", "Local whisper": "Yerel whisper",
"Local model": "Yerel model", "Local model": "Yerel model",
"Not installed.": "Kurulu değil.", "Not installed.": "Kurulu değil.",
@@ -959,6 +979,10 @@ TR = {
"“Off”.": "“Off”.":
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi " "Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
"“Kapalı” yap.", "“Kapalı” yap.",
"The cleanup model was cut off before it finished.":
"Temizleme modeli bitiremeden kesildi.",
"The model was cut off before it finished.":
"Model bitiremeden kesildi.",
# --- this pass's new messages --------------------------------------- # --- this pass's new messages ---------------------------------------
"Audio recorder stopped before receiving sound": "Audio recorder stopped before receiving sound":
+66 -5
View File
@@ -181,18 +181,34 @@ class WrappedLabel(QLabel):
super().setText(text) super().setText(text)
self._fit() self._fit()
def showEvent(self, event):
# Text set while the window was still being built was measured against
# nothing; this is the first moment the width means anything.
super().showEvent(event)
self._fit()
def resizeEvent(self, event): def resizeEvent(self, event):
super().resizeEvent(event) super().resizeEvent(event)
self._fit() self._fit()
def _fit(self): def _fit(self):
# A label the layout has not placed yet is a handful of pixels wide,
# and wrapping a sentence against that width invents a hundred lines.
# The minimum set from it does not stay a minimum either: QLabel folds
# it into its own cached size hints and clears that cache only when the
# text changes, so the row stands thousands of pixels tall and carries
# the model box and everything under it off the bottom of the window
# until another publisher is picked. Nothing to measure against yet
# means nothing to claim yet, and the show and resize above come back
# for it.
if not self.isVisible() or self.width() <= 0:
return
# Measured off the font rather than asked of the label, whose own answer # Measured off the font rather than asked of the label, whose own answer
# is floored by the minimum set here a moment ago and so only ever grows. # is floored by the minimum set here a moment ago and so only ever grows.
if self.width() > 0: wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere box = QRect(0, 0, self.width(), 0)
box = QRect(0, 0, self.width(), 0) self.setMinimumHeight(
self.setMinimumHeight( self.fontMetrics().boundingRect(box, wrap, self.text()).height())
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
class WheelGuard(QObject): class WheelGuard(QObject):
@@ -1309,9 +1325,47 @@ class SettingsWindow(QDialog):
orr_form.addRow(self.local_llm_options) orr_form.addRow(self.local_llm_options)
outer.addWidget(orr) outer.addWidget(orr)
# One box for both servers rather than a row inside each: what is being
# decided is whether this machine keeps gigabytes tied up between
# dictations, and that is not a question anybody wants to answer once
# per model.
self.local_box = QGroupBox(t("Models on this machine"))
local_form = QFormLayout(self.local_box)
self.local_idle_unload = QCheckBox(t("Unload a model that is sitting unused"))
self.local_idle_unload.setToolTip(
t("A loaded model holds its memory whether anything is using it or "
"not: over a gigabyte for whisper, several for an LLM. Unloading "
"gives that back to the rest of the desktop, and the next "
"dictation loads it again at the cost of the seconds that takes."))
self.local_idle_minutes = QSpinBox()
self.local_idle_minutes.setRange(1, 720)
self.local_idle_minutes.valueChanged.connect(self._idle_suffix)
self._idle_suffix(self.local_idle_minutes.value())
self.local_idle_unload.toggled.connect(self.local_idle_minutes.setEnabled)
local_form.addRow("", self.local_idle_unload)
local_form.addRow(t("After"), self.local_idle_minutes)
outer.addWidget(self.local_box)
outer.addStretch(1) outer.addStretch(1)
return page return page
def _idle_suffix(self, minutes):
"""The spin box's own noun, since its lowest value is one of them.
Turkish is handed both and translates them the same: a number there is
followed by the singular however many it counts.
"""
self.local_idle_minutes.setSuffix(
t(" minute") if minutes == 1 else t(" minutes"))
def _refresh_local_box(self):
"""The idle unload is only on screen when something here runs locally."""
self.local_box.setVisible(
(self.transcribe_provider.currentData() or "local") == "local"
or (self.cleanup_provider.currentData() or "openrouter") == "local"
)
def _prompt_tab(self): def _prompt_tab(self):
page = QWidget() page = QWidget()
layout = QVBoxLayout(page) layout = QVBoxLayout(page)
@@ -2078,6 +2132,9 @@ class SettingsWindow(QDialog):
self.local_llm_preload.setChecked(conf["local_llm_preload"]) self.local_llm_preload.setChecked(conf["local_llm_preload"])
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"]) self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"]) self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
self.local_idle_unload.setChecked(conf["local_idle_unload"])
self.local_idle_minutes.setValue(int(conf["local_idle_minutes"]))
self.local_idle_minutes.setEnabled(conf["local_idle_unload"])
# The defaults as they read NOW, kept for the save comparison: after a # The defaults as they read NOW, kept for the save comparison: after a
# language switch the boxes still hold the old language's default, and # language switch the boxes still hold the old language's default, and
# comparing against the new one would store that text as a custom # comparing against the new one would store that text as a custom
@@ -2209,6 +2266,8 @@ class SettingsWindow(QDialog):
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked() conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
conf["local_llm_preload"] = self.local_llm_preload.isChecked() conf["local_llm_preload"] = self.local_llm_preload.isChecked()
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or "" conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
conf["local_idle_unload"] = self.local_idle_unload.isChecked()
conf["local_idle_minutes"] = self.local_idle_minutes.value()
# Store an empty prompt when it matches a default: the one it was # Store an empty prompt when it matches a default: the one it was
# loaded with, or today's (a Reset click in a session that switched # loaded with, or today's (a Reset click in a session that switched
@@ -2351,6 +2410,7 @@ class SettingsWindow(QDialog):
self.stt_form.setRowVisible(self.transcribe_status, not local) self.stt_form.setRowVisible(self.transcribe_status, not local)
self.stt_form.setRowVisible(self.local_whisper, local) self.stt_form.setRowVisible(self.local_whisper, local)
self.stt_form.setRowVisible(self.local_options, local) self.stt_form.setRowVisible(self.local_options, local)
self._refresh_local_box()
if local: if local:
return return
self.transcribe_model.clear() self.transcribe_model.clear()
@@ -2859,6 +2919,7 @@ class SettingsWindow(QDialog):
provider != "local") provider != "local")
self.cleanup_form.setRowVisible(self.local_llm, 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_options, provider == "local")
self._refresh_local_box()
binary = cleanup.executable(provider) binary = cleanup.executable(provider)
found = shutil.which(binary) if binary else "" found = shutil.which(binary) if binary else ""
if provider == "local": if provider == "local":
+121 -2
View File
@@ -9,6 +9,7 @@ is blocked on, and a faked urlopen has no socket to cut, so those tests talk to
a server of their own on the loopback interface. a server of their own on the loopback interface.
""" """
import contextlib
import http.server import http.server
import json import json
import os import os
@@ -321,7 +322,12 @@ class TranscribeSegments(DikteTest):
fields = multipart_fields(calls[0]) fields = multipart_fields(calls[0])
self.assertEqual(fields["model"], "whisper-1") self.assertEqual(fields["model"], "whisper-1")
self.assertEqual(fields["response_format"], "verbose_json") self.assertEqual(fields["response_format"], "verbose_json")
self.assertEqual(fields["timestamp_granularities[]"], "segment") # Both are asked for: whisper answers with segments, and a model that
# does not mark them still answers with word times.
body = calls[0].data.decode("utf-8", "replace")
for level in ("segment", "word"):
self.assertIn(
f'name="timestamp_granularities[]"\r\n\r\n{level}\r\n', body)
def test_openrouter_uses_the_namespaced_id(self): def test_openrouter_uses_the_namespaced_id(self):
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
@@ -362,6 +368,74 @@ class TranscribeSegments(DikteTest):
self.assertEqual(api.transcribe_segments(OPENAI, self.wav), self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
[(5.0, 5.0, "hi")]) [(5.0, 5.0, "hi")])
def test_a_long_sentence_is_broken_where_it_gets_too_long_to_read(self):
words = [{"word": "word", "start": i * 0.2, "end": i * 0.2 + 0.2}
for i in range(60)]
cues = api.cues_from_words(words)
self.assertGreater(len(cues), 1)
for start, end, text in cues:
self.assertLessEqual(len(text), api.MAX_CUE_CHARS)
self.assertLessEqual(end - start, api.MAX_CUE_SECONDS + 0.2)
def test_a_pause_between_short_sentences_does_not_join_them(self):
cues = api.cues_from_words([
{"word": "Yes.", "start": 0.0, "end": 0.3},
{"word": "No.", "start": 9.0, "end": 9.3},
])
self.assertEqual([(start, text) for start, _, text in cues],
[(0.0, "Yes."), (9.0, "No.")])
def test_a_cue_too_short_to_read_is_held_until_the_next_one(self):
cues = api.cues_from_words([
{"word": "Yes.", "start": 0.0, "end": 0.3},
{"word": "No.", "start": 9.0, "end": 9.3},
])
# The first has the room for it, the last has nothing after it to wait for.
self.assertEqual(cues[0][1], api.MIN_CUE_SECONDS)
self.assertEqual(cues[1][1], 9.0 + api.MIN_CUE_SECONDS)
def test_a_list_marker_does_not_end_a_cue_on_its_own(self):
cues = api.cues_from_words([
{"word": "1.", "start": 0.0, "end": 0.2},
{"word": "Antivirus.", "start": 0.4, "end": 1.6},
])
self.assertEqual([text for _, _, text in cues], ["1. Antivirus."])
def test_a_sentence_ending_inside_a_quote_still_ends_the_cue(self):
cues = api.cues_from_words([
{"word": '"Stop', "start": 0.0, "end": 1.0},
{"word": 'there."', "start": 1.1, "end": 2.0},
{"word": "Then", "start": 2.2, "end": 2.6},
])
self.assertEqual([text for _, _, text in cues],
['"Stop there."', "Then"])
def test_word_times_take_over_from_segments_too_long_to_read(self):
# What a model that does not mark segments answers with: one entry for
# the whole file, and the real timing in the words beside it.
reply = {
"text": "One. Two.",
"segments": [{"start": 0, "end": 60, "text": "One. Two."}],
"words": [
{"word": "One.", "start": 0.1, "end": 1.5},
{"word": "Two.", "start": 1.7, "end": 3.0},
],
}
with fake_urlopen(reply):
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
[(0.1, 1.5, "One."), (1.7, 3.0, "Two.")])
def test_whisper_segments_are_left_alone_when_words_come_too(self):
reply = {
"text": "hi there",
"segments": [{"start": 0, "end": 2, "text": "hi there"}],
"words": [{"word": "hi", "start": 0.0, "end": 0.5},
{"word": "there", "start": 0.5, "end": 2.0}],
}
with fake_urlopen(reply):
self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
[(0.0, 2.0, "hi there")])
def test_a_model_that_returned_no_segments_still_gives_its_text(self): def test_a_model_that_returned_no_segments_still_gives_its_text(self):
with fake_urlopen(self.reply([], text="the whole thing")): with fake_urlopen(self.reply([], text="the whole thing")):
self.assertEqual(api.transcribe_segments(OPENAI, self.wav), self.assertEqual(api.transcribe_segments(OPENAI, self.wav),
@@ -463,6 +537,29 @@ class Cleanup(DikteTest):
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError): with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
api.cleanup("hello", "k", "m", "p") api.cleanup("hello", "k", "m", "p")
def test_a_reply_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
# Half a sentence looks like a cleaned-up transcript and is not one. The
# caller keeps what it was given, which is the whole dictation.
reply = {"choices": [{"message": {"content": "Hello, and then the"},
"finish_reason": "length"}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
api.cleanup("hello", "k", "m", "p")
self.assertIn("cut off", str(caught.exception))
def test_a_reply_that_stopped_on_its_own_is_kept(self):
reply = {"choices": [{"message": {"content": "Hello."},
"finish_reason": "stop"}]}
with fake_urlopen(reply):
self.assertEqual(api.cleanup("hello", "k", "m", "p"), "Hello.")
def test_all_thinking_is_named_before_the_ceiling_it_was_cut_at(self):
"""Both are true at once, and only one of them says what to change."""
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"},
"finish_reason": "length"}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
api.cleanup("hello", "k", "m", "p")
self.assertIn("Thinking", str(caught.exception))
def test_a_rate_limit_is_explained(self): def test_a_rate_limit_is_explained(self):
with fake_urlopen(http_error(429)), \ with fake_urlopen(http_error(429)), \
self.assertRaises(api.ApiError) as caught: self.assertRaises(api.ApiError) as caught:
@@ -471,6 +568,14 @@ class Cleanup(DikteTest):
class Chat(DikteTest): class Chat(DikteTest):
def test_an_answer_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
# Half an answer reads like a whole one once it is on the screen.
reply = {"choices": [{"message": {"content": "Booked it for the"},
"finish_reason": "length"}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
api.chat([{"role": "user", "content": "book it"}], "k", "m", "p")
self.assertIn("cut off", str(caught.exception))
def test_the_history_is_sent_after_the_system_prompt(self): def test_the_history_is_sent_after_the_system_prompt(self):
history = [{"role": "user", "content": "book it"}, history = [{"role": "user", "content": "book it"},
{"role": "assistant", "content": "done"}] {"role": "assistant", "content": "done"}]
@@ -616,11 +721,14 @@ if __name__ == "__main__":
class FakeServer: class FakeServer:
"""A ggml.Server as far as api.py is concerned.""" """A ggml.Server as far as api.py is concerned."""
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""): def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log="",
context=8192):
self.url = url self.url = url
self.fails = fails self.fails = fails
self.log = log self.log = log
self.starts = 0 self.starts = 0
self.held = 0
self.context = context
def serve(self): def serve(self):
self.starts += 1 self.starts += 1
@@ -628,9 +736,20 @@ class FakeServer:
raise ggml.LocalError(self.fails) raise ggml.LocalError(self.fails)
return self.url return self.url
@contextlib.contextmanager
def busy(self):
self.held += 1
try:
yield
finally:
self.held -= 1
def error(self): def error(self):
return self.log return self.log
def settings(self):
return {"context": self.context}
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin") LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
+46
View File
@@ -411,6 +411,52 @@ class Here(DikteTest):
cleanup.run("uh, done", self.conf, "the rules") cleanup.run("uh, done", self.conf, "the rules")
self.assertEqual(sent_json(calls[0])["max_tokens"], 512) self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
def test_thinking_is_given_room_of_its_own_rather_than_the_answer_s(self):
# llama.cpp counts the thinking towards the same ceiling, so a rung that
# took its budget out of the answer would leave a short dictation with
# nothing to reply with. On a context roomy enough that the clamp the
# top rung would otherwise meet is not what is being measured.
self.patch_attr(ggml, "llm", FakeServer(context=32768))
for rung, room in api.THINKING_ROOM.items():
with self.subTest(rung=rung):
self.conf["local_llm_reasoning"] = rung
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("uh, done", self.conf, "the rules")
self.assertEqual(sent_json(calls[0])["max_tokens"], 512 + room)
def test_each_rung_of_the_ladder_thinks_longer_than_the_one_below(self):
rungs = [api.THINKING_ROOM[name] for name in
("minimal", "low", "medium", "high", "xhigh", "max")]
self.assertEqual(rungs, sorted(rungs))
self.assertEqual(len(set(rungs)), len(rungs))
def test_the_models_own_default_is_given_room_to_think_in_too(self):
# Nothing is sent, so a template that thinks will think, and the ceiling
# has to survive that as well.
self.conf["local_llm_reasoning"] = ""
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("uh, done", self.conf, "the rules")
self.assertEqual(sent_json(calls[0])["max_tokens"],
512 + api.DEFAULT_THINKING_ROOM)
def test_the_ceiling_stays_under_the_context_the_server_was_started_with(self):
# Above the context there is no ceiling at all: the runaway would run to
# the end of the context instead of stopping where this says.
self.patch_attr(ggml, "llm", FakeServer(context=2048))
self.conf["local_llm_reasoning"] = "max"
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("uh, done", self.conf, "the rules")
self.assertLess(sent_json(calls[0])["max_tokens"], 2048)
def test_the_prompt_keeps_its_share_of_a_small_context(self):
self.patch_attr(ggml, "llm", FakeServer(context=2048))
self.conf["local_llm_reasoning"] = "max"
with fake_urlopen(chat_reply("Done.")) as calls:
cleanup.run("x" * 2000, self.conf, "the rules")
# 2048 less half the characters of prompt and transcript together.
self.assertEqual(sent_json(calls[0])["max_tokens"],
2048 - (len("the rules") + 2000) // 2)
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self): def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]} reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught: with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
+21
View File
@@ -689,3 +689,24 @@ class ReadyToRun(DikteTest):
self.assertEqual(ggml.whisper.settings()["threads"], 4) self.assertEqual(ggml.whisper.settings()["threads"], 4)
self.assertFalse(ggml.whisper.settings()["gpu"]) self.assertFalse(ggml.whisper.settings()["gpu"])
self.assertEqual(ggml.llm.settings()["context"], 4096) self.assertEqual(ggml.llm.settings()["context"], 4096)
def test_the_idle_window_is_in_seconds(self):
conf = self.config(local_idle_unload=True, local_idle_minutes=15)
self.assertEqual(conf.idle_seconds(), 900)
def test_an_unchecked_box_keeps_the_model(self):
conf = self.config(local_idle_unload=False, local_idle_minutes=15)
self.assertEqual(conf.idle_seconds(), 0)
def test_a_window_of_no_minutes_is_still_a_window(self):
"""The spin box will not go below one; a config edited by hand can."""
conf = self.config(local_idle_unload=True, local_idle_minutes=0)
self.assertEqual(conf.idle_seconds(), 60)
def test_both_servers_are_told_the_window(self):
conf = self.config(local_idle_unload=True, local_idle_minutes=3)
self.addCleanup(ggml.llm.set_idle, 0)
self.addCleanup(ggml.whisper.set_idle, 0)
conf.apply_local()
self.assertEqual(ggml.whisper.idle, 180)
self.assertEqual(ggml.llm.idle, 180)
+121 -1
View File
@@ -783,7 +783,9 @@ STAND_IN = textwrap.dedent("""
""") """)
class Servers(Local): class ServerCase(Local):
"""The stand-in server and the fixture around it, with no tests of its own."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.path("data").mkdir(parents=True, exist_ok=True) self.path("data").mkdir(parents=True, exist_ok=True)
@@ -806,6 +808,8 @@ class Servers(Local):
self.addCleanup(made.stop) self.addCleanup(made.stop)
return made return made
class Servers(ServerCase):
def test_a_started_server_hands_back_its_address(self): def test_a_started_server_hands_back_its_address(self):
server = self.server() server = self.server()
url = server.serve() url = server.serve()
@@ -1037,6 +1041,122 @@ class Servers(Local):
self.assertFalse(server.sweep()) # and the pid file went with it self.assertFalse(server.sweep()) # and the pid file went with it
class IdleUnload(ServerCase):
"""Giving the memory back when nothing has asked anything for a while."""
IDLE = 0.3
def setUp(self):
super().setUp()
# The real check runs every five seconds against a window of minutes.
# Both are scaled down here; what is being tested is the decision, and
# nothing in it reads the clock in units of its own.
self.patch_attr(ggml, "IDLE_CHECK_SECONDS", 0.05)
def idle_server(self, seconds=None, **settings):
server = self.server(**settings)
server.set_idle(self.IDLE if seconds is None else seconds)
return server
def wait_for(self, predicate, timeout=5.0):
"""True as soon as `predicate` holds, False once the wait runs out."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.02)
return False
def test_a_model_nobody_is_using_is_unloaded(self):
server = self.idle_server()
server.serve()
self.assertTrue(self.wait_for(lambda: not server.running))
def test_the_default_is_to_keep_it(self):
"""A server nobody set a window on stays until something stops it."""
server = self.server()
server.serve()
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
def test_a_window_of_zero_keeps_it_too(self):
server = self.idle_server(0)
server.serve()
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
def test_a_request_in_flight_holds_the_model(self):
"""A file is one address lookup and then minutes of work: the clock
alone would call that idle and unload it mid-transcription."""
server = self.idle_server()
server.serve()
with server.busy():
self.assertFalse(
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
self.assertTrue(self.wait_for(lambda: not server.running))
def test_asking_for_the_address_puts_the_window_back(self):
server = self.idle_server()
first = server.serve()
for _ in range(4):
time.sleep(self.IDLE / 2)
self.assertEqual(server.serve(), first) # never restarted
self.assertTrue(server.running)
def test_the_next_request_loads_it_again(self):
server = self.idle_server()
first = server.serve()
self.assertTrue(self.wait_for(lambda: not server.running))
second = server.serve()
self.assertTrue(server.running)
self.assertNotEqual(second, first) # a new process, a new port
def test_the_watcher_of_a_stopped_server_does_not_touch_the_next_one(self):
server = self.idle_server()
server.serve()
server.stop()
server.set_idle(0)
server.serve()
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
def test_unloading_by_hand_does_not_wait_for_the_window(self):
server = self.idle_server(0)
server.serve()
self.assertTrue(server.unload())
self.assertFalse(server.running)
def test_a_hold_taken_before_the_start_survives_it(self):
"""The local cleanup takes the hold and only then asks for the address,
so the start it triggers must not be what drops the hold."""
server = self.idle_server()
with server.busy():
server.serve()
self.assertFalse(
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
self.assertTrue(self.wait_for(lambda: not server.running))
def test_unloading_is_refused_while_the_model_is_still_loading(self):
"""It runs on the interface's thread, and a start holds its lock for as
long as the load takes: waiting there would freeze the whole window."""
server = self.idle_server(0, extra=["--wait", "0.6"])
thread = threading.Thread(target=server.serve)
thread.start()
try:
began = time.monotonic()
self.assertFalse(server.unload())
self.assertLess(time.monotonic() - began, 0.2)
finally:
thread.join(timeout=10)
def test_unloading_is_refused_while_a_request_is_in_flight(self):
server = self.idle_server(0)
server.serve()
with server.busy():
self.assertFalse(server.unload())
self.assertTrue(server.running)
def test_unloading_nothing_is_not_a_refusal(self):
self.assertTrue(self.server().unload())
class Arguments(Local): class Arguments(Local):
"""What the two command lines say, since neither program is here to say it.""" """What the two command lines say, since neither program is here to say it."""
+56 -1
View File
@@ -14,7 +14,7 @@ import unittest
from typing import ClassVar from typing import ClassVar
from unittest import mock from unittest import mock
from PyQt6.QtCore import QPoint, QPointF, Qt from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
from PyQt6.QtGui import QWheelEvent from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox from PyQt6.QtWidgets import QApplication, QMessageBox
@@ -83,6 +83,8 @@ CHANGED = {
"local_llm_gpu": False, "local_llm_gpu": False,
"local_llm_preload": True, "local_llm_preload": True,
"local_llm_reasoning": "low", "local_llm_reasoning": "low",
"local_idle_unload": False,
"local_idle_minutes": 45,
"cleanup_prompt": "Only fix the punctuation.", "cleanup_prompt": "Only fix the punctuation.",
"file_cleanup_prompt": "Keep the stamps where they are.", "file_cleanup_prompt": "Keep the stamps where they are.",
"transcribe_prompt": "Paraşüt, OpenFrame", "transcribe_prompt": "Paraşüt, OpenFrame",
@@ -244,6 +246,39 @@ class Settings(DikteTest):
label.resize(2000, line) label.resize(2000, line)
self.assertLessEqual(label.minimumHeight(), line) self.assertLessEqual(label.minimumHeight(), line)
def test_a_label_written_before_the_layout_places_it_claims_nothing(self):
# The publisher note is written while the settings window is still
# being built, when the label is a handful of pixels wide. Wrapped
# against that width the sentence became a hundred lines, and the
# minimum taken from it did not stay a minimum: QLabel folds it into
# its own cached size hints and clears that cache only when the text
# changes. The group box stood thousands of pixels tall, with the
# model box and everything under it off the bottom of the window,
# until another publisher was picked.
label = settings_ui.WrappedLabel()
self.addCleanup(label.deleteLater)
line = label.fontMetrics().height()
label.resize(8, line)
label.setText("Google Gemma 4, the small one. The default: nothing "
"else this size follows an instruction as closely, and "
"cleanup is all instruction.")
self.assertEqual(label.minimumHeight(), 0)
# Placed and shown, which is the first width worth measuring against.
# The room the wrapping needs is claimed then, and it is the lines the
# sentence takes at this width rather than at the last one. Counted
# off the font rather than written down here, because how many lines
# 400 pixels hold is a different answer on every machine.
label.resize(400, line)
label.show()
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
needed = label.fontMetrics().boundingRect(
QRect(0, 0, 400, 0), wrap, label.text()).height()
self.assertGreater(needed, line) # or the sentence never wrapped
self.assertEqual(label.minimumHeight(), needed)
# And the label's own hints are the wrapping at this width too, not
# the hundred lines the eight pixel one asked for.
self.assertLessEqual(label.sizeHint().height(), 3 * needed)
def test_saving_without_touching_anything_changes_nothing(self): def test_saving_without_touching_anything_changes_nothing(self):
"""Every widget has to load what is stored, or Save writes its default """Every widget has to load what is stored, or Save writes its default
over it. This says so for the whole table at once.""" over it. This says so for the whole table at once."""
@@ -1701,6 +1736,26 @@ class LocalModels(DikteTest):
# Its own thinking box, because the two default to opposite things. # Its own thinking box, because the two default to opposite things.
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning)) self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
def test_the_idle_unload_is_offered_to_whoever_runs_a_model_here(self):
for transcriber, cleaner in (("local", "openrouter"),
("openai", "local"),
("local", "local")):
with self.subTest(transcriber=transcriber, cleaner=cleaner):
window = self.window(self.config(transcribe_provider=transcriber,
cleanup_provider=cleaner))
self.assertTrue(window.local_box.isVisibleTo(window))
def test_a_machine_that_runs_neither_is_not_asked_about_memory(self):
window = self.window(self.config(transcribe_provider="openai",
cleanup_provider="openrouter"))
self.assertFalse(window.local_box.isVisibleTo(window))
def test_the_minutes_follow_the_checkbox(self):
window = self.window(self.config(local_idle_unload=False))
self.assertFalse(window.local_idle_minutes.isEnabled())
window.local_idle_unload.setChecked(True)
self.assertTrue(window.local_idle_minutes.isEnabled())
def test_each_cleaner_brings_its_own_model_row_and_no_other(self): def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
window = self.window(cfg.Config()) window = self.window(cfg.Config())
rows = {"openrouter": window.cleanup_model_row, rows = {"openrouter": window.cleanup_model_row,