Reject failed Whisper GPU attempts, identify Llama devices from model buffers, and keep historical diagnostics independent of current settings. Report loaded backends without inferring build support, with regression coverage for each case.
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.
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.
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.
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.
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.
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.
Opening the connection takes ten or twenty seconds, and the byte counts
under the model box only start after it. Until then the line read "X has
not been downloaded yet" beside a button that had just turned into Stop,
so a download that was running looked like a click that had not landed.
The stop had the same gap the other way round: should_stop is read
between blocks, and the wait for the server to answer is not between
blocks, so pressing Stop during it changed nothing on screen either.
Two ways the tests were standing on this machine rather than on the one
they meant to describe.
Windows has no os.sysconf at all, and mock.patch.object insists the
attribute exists before it will replace it, so four tests failed at the
patch rather than in the body. `create=True` is what lets them stand
somewhere that has no such function, which is the case the code under test
already handles two lines down and which is checked on its own.
And the publisher order follows the memory on purpose: a runner with 7 GB
in it puts the two Gemma 4 rows last and is right to. The three tests that
read an order now say which machine they are standing on instead of
assuming the one that ran them has room for everything.
Three from the review of the change before this one.
sysconf answers -1 for a limit it holds to be indeterminate, and CPython
hands that back rather than raising, so the page count times the page size
came out negative. A negative is truthy, so it went past the check for a
machine nothing could be read from and floored at half a gigabyte: a 64 GB
workstation was told every model past 512 MB was too big for it, the
suggestion dropped to small-q5_1, and the machine line read "Memory:
-4096 B". Anything not positive is now the unknown machine it always was.
The memory is read once and kept. It does not change while Dikte runs, and
a thirty row list asked seventy times per draw, which on the Mac path is
seventy processes started on the interface thread every time a download
finished, a model was deleted or a publisher changed.
And "test-" is matched as a plain substring, so it was also inside
"Latest-" and dropped a publisher nothing is wrong with. Anchored the way
every other mark in that list already is.
The indicator asked QCursor.pos() which screen to appear on, and Wayland tells a client where the pointer is only while it is over one of that client's own windows. The indicator is never under the pointer, so the answer came back stale, or at the origin when the pointer had never been over a window of ours. Measured on Plasma 6: the origin under the Wayland platform, and a point frozen for the whole run through XWayland, which is the platform Dikte actually uses. Every indicator therefore landed in the corner of whichever screen holds 0,0, which on a two monitor desk is the wrong screen most of the time.
KWin knows, and answers for it over D-Bus with activeOutputName, naming outputs the way Qt names screens: by connector, natively and through XWayland alike. That answer now comes before the pointer, and the pointer still decides everywhere else, which is right on X11 and no worse than before on other Wayland desktops. It is the active output and not the pointer's, so on Plasma the two are the same screen only where the active screen is set to follow the mouse, and otherwise it is the focused window that decides, which is where the typing is going anyway. Nothing in the settings window promises the pointer any more.
Deciding the screen once, when the indicator appears, leaves it behind when the work moves to another monitor mid-recording, so overlay_follows_pointer keeps it up to date while it is up. Off by default: a ribbon that changes desks mid-sentence is one more thing moving while you are trying to talk. The compositor is asked four times a second rather than at the ribbon's 33 ms, because a hand moving a mouse across a desk is slower than that, and the call is given a 200 ms timeout so a wedged compositor cannot freeze the indicator with it.
One indicator stacking on another takes that one's screen and never asks for its own. Asked for itself it would answer where the session is now, which is not where the ribbon underneath was put a minute ago, and the pair would end up a monitor apart with the top one raised over nothing. That one is checked every tick, since its answer costs nothing.
The two local model boxes handed over a flat list sorted by size and left
every choice in it to the reader. For whisper that interleaved the models:
large-v3-turbo-q5_0 landed between the two medium quantisations, half a
screen from the turbo model it is a copy of. For cleanup it was forty
repository ids, half of which answer with nothing at all because what they
publish is split across files or larger than the cap, and an empty box read
as though the click had not registered.
Now each box says what the machine is, groups the list by model, and marks
the row to take:
- whisper rows are grouped by model, with the quantisations and the
English-only files under the model they are a copy of, and every row says
its bit depth rather than leaving q5_1 and Q4_K_M and BF16 to be decoded.
- the recommendation follows the machine. Under 4 GB it is small-q5_1;
with a graphics interface and 15 GB it is large-v3-q5_0, which is worth
about two and a half points of word error in the languages that are not
English; in between it is turbo, and a processor build where the Vulkan
one belongs is not counted as a card.
- a row larger than half the memory less a gigabyte says it is too big.
- the publisher box holds the five suggestions until the switch beside it
is turned on, and a line under it says in words what the chosen one is.
- a publisher that answers with nothing says why instead of going blank.
- the draft heads (dflash, dspark, eagle3) are no longer offered as models,
and neither are the base models that sit beside their tuned twin.
master grew _pick_asset() while this branch grew a second answer to the
same question inside install_program(). Two functions deciding which
archive this machine wants is one too many, so the managed build is
asked for at the top of the picker: Dikte's own Vulkan whisper-server
first where the machine is one it is built for, then upstream's newest,
then the nightly pointer, then the newest release that carries a build.
install_program() is back to master's three lines and reads which of the
two landed off the asset name.
The macOS path test set XDG_CONFIG_HOME to "/c" and asserted that "/c" was nowhere in the answer, but the home these run under is a mkdtemp path, so any TMPDIR with a "/c" in it failed the test. macOS is exactly where that happens: its temporary directories are /var/folders/<two letters>/, and one letter in thirty-six starts with c. The needle is now a word no directory can be called.
The other is the `ask` verb reading what was piped into it. Nothing was, but the runner's own stdin is not nothing either, so the verb read whatever the runner left there. It now reads an empty stream, whichever runner is in use.
Changing the publisher left the model box untouched: the old selection was carried over, added back as "not downloaded" and selected again, so a model the new repository does not publish could be saved against it. The selection is now only carried within the publisher it was made in, every keystroke in the publisher box no longer starts its own request, and a list that comes back for a publisher that is no longer chosen is dropped rather than answering the wrong one.
The status line grew two things it could not say before. A row rebuilt from a name alone carries no file to fetch, and the Download button stayed lit over it doing nothing; those rows now say the publisher does not offer the model, and the button is out. A model that is here while the program above it is not no longer reads "Ready", which is what had people asking why nothing transcribed.
"latest" for llama.cpp is a version marker carrying one file, nightly-tag.txt, and the archives it names hang off a prerelease that "latest" never points at. Reading only the latest release meant Dikte offered no llama-server for any machine, so _pick_asset now follows that pointer, and when there is none it walks the recent releases and takes the newest one that does carry a build for this machine. hub grows releases() for the listing and text() for the pointer file; the pointer is read rather than cached, because what it carries is a few bytes on the way to a download that is checksummed in full.
The extra lookups are best effort: whatever goes wrong in them leaves the caller's own message standing, but a first release that could not be fetched at all is kept and re-raised when nothing else turns up, so an unreachable GitHub still reads as an unreachable GitHub rather than as a machine nobody publishes for.
Enabling immutable releases, configuring the dependency-release
environment, which order the two dispatch runs go in, and which five
constants have to move together were all in the pull request
description, which is not a place anybody looks a year later.
The same file says what this costs: the pinned digest means a backend
update is a Dikte release, and the build is deterministic between two
runs of one builder rather than across time, because the apt metadata
behind the pinned packages is not pinned and LunarG drops superseded
ones. Both are worth knowing before the next version bump, not during
it.
The README keeps a clause instead of the paragraph it had grown.
The digest was a literal in the workflow while the version was an input,
so a dispatch for anything but 1.9.3 built the archive and then failed
its own gate. The digest of a version nobody has reviewed cannot be
known before it is built, which made the inputs unusable for the one job
they exist for.
An empty expected_sha256 now reports the digest of what was built and
refuses to publish; a digest handed in is checked the way the literal
was. The input shapes are also checked before the checkout that uses
them rather than after it.
The CPU run passed -ng, which tells whisper.cpp not to look for a
backend at all, so the claim the bundle rests on, that it starts and
transcribes where Vulkan cannot, was never tested. Dikte passes -ng only
when its GPU setting is off; every other run goes through the backend
registry.
Both runs lose the flag, and a third one is added between them: the
loader installed, nothing behind it. That is a real machine, libvulkan
pulled in by something else with no driver to go with it, and it is the
one whose answer nobody knew.
The button disappeared the moment anything landed, and nothing else on
the window asks for that download. So a machine that got the processor
build before its graphics driver was installed can never be given the
Vulkan one, and nobody can pick up a newer whisper.cpp either: both of
those are the same missing control.
It reads "Download again" once a copy is here, and stays hidden while a
system one is on the PATH, because that is the copy that would run.
The trigger listed dikte/ggml.py, both test files and the two READMEs,
which are among the files that change most often. A typo fix in the
README started a run that builds a container image, compiles every
Vulkan shader and starts two more containers, with a 45 minute timeout
on it because that is roughly what it costs.
Nothing is lost. What ties ggml.py to the release, the tag, version,
commit and reviewed digest, is asserted in tests/test_packaging.py, and
that file already runs on every pull request in milliseconds.
The install falls back to upstream's CPU archive whenever Dikte's own
release, the file in it, or its reviewed digest is not there, and until
the package is published by hand that is every download. It happened
without a word: the window said "Downloaded, version v1.9.3." either
way, and a graphics card sitting idle looks exactly like one being used.
The install record now carries which of the two builds landed, written
only where both were on offer, and the settings window says so on the
line that already reports the version.
Refusing the install on a Python without the extraction filters is a
change to how every archive on every platform is unpacked, and it has
nothing to do with shipping a Vulkan whisper-server. On those Pythons
the download stops working altogether, which is a worse answer than the
one that was there.
Worth doing on its own terms, in its own change, where the versions it
turns away can be argued about without a backend release riding on it.
A timestamped run on OpenRouter always asked openai/whisper-1 for the
segments, whatever model was picked for plain transcription. Not every
model there returns segment times, so the one to use is now its own
setting, openrouter_subtitle_model, shown in the speech-to-text box only
when OpenRouter is the provider. Empty keeps the old whisper-1 fallback.
Target carries the choice as subtitle_model and timestamp_model() reads
it; the other providers are unchanged.
The agent and meeting tabs opened with an intro paragraph, and the
cleanup provider box showed a long tooltip comparing the providers.
None of them earned the space: the intro texts were unclear and the
tooltip restated what picking a provider already shows. The orphaned
Turkish translations go with them.
Master grew Google AI Studio and Antigravity as providers, a doctor that
names each provider's own key, and settings that fetch every hosted
model list as the window opens. OpenCode Go is folded into each: its
name joins SERVICES and the doctor's key table, its key row sits beside
Google's, its Fetch button follows the per-provider pattern Google's
uses, and _load_hosted_models fetches its catalog at open when a key is
on file, filling the cleanup and agent boxes alike.
Qt's xcb plugin links libxkbcommon and libxkbcommon-x11, and the x11 half
hands keymap objects to the core half to free, so the pair must come from
one build. The build machine has only the core half installed, so
PyInstaller bundled that one alone and the other kept loading from the
user's system; an Ubuntu 22.04 core freeing what a current x11 half
allocated is the segfault of issue #57, which a Turkish layout happened
to move to startup. Every desktop that can show a window carries both
libraries from one build, so the bundle now carries neither.
Fixes#57
Codex's list already arrived on its own, because reading a cache on disk
costs nothing. The other three lists were behind a Fetch button, which
meant the built-in ones aged in front of anyone who never pressed it. Now
OpenRouter's and Google's lists are fetched when the settings window
opens, and Antigravity's comes off `agy models`, which prints one
id-tab-name line per model and answers over the network in a couple of
seconds; all three run off the interface thread.
Nobody asked, so nothing is reported: a failure changes nothing on
screen, the built-in lists stay, and the Fetch buttons remain both the
retry and the place an error is worth explaining. A provider whose key
has not been given is not called at all, so opening Settings is not by
itself a request to two vendors.
Also drops an import the merge had left in twice.
Co-Authored-By: Claude Fable 5 <[email protected]>
Codex already refreshes its boxes from the source at open, so the
built-in list is never the whole truth for longer than a window takes to
build. OpenCode Go now gets the same courtesy: one request to /models on
a background thread, both of its boxes refilled with the picked model
kept, skipped entirely when there is no key to send.
Nothing was measured that put OpenCode Go beside it, so the tooltip
claims only what is known: OpenRouter is the quickest, and OpenCode Go
merely needs nothing installed either. The translation follows word for
word.
ox-alpha-free is not in the /models answer, and the comment claimed the
chat endpoint hides Grok, Luna, MiniMax and Qwen when its own catalog
lists them. The built-in list is a starting set; the Fetch button asks
the endpoint for the full catalog of the day.
The tooltip gained OpenCode Go but its translation was added without the
llama.cpp sentence the tooltip actually carries, so a Turkish window
showed the English text. The full entry now matches the tooltip word for
word, and the two shorter variants nothing looks up any more are gone.
The one button lived in the OpenRouter model row, which leaves the
screen whenever another provider is chosen, so the OpenCode fetch path
could never be clicked. The OpenCode row now carries its own button into
the same handler, the row as a whole is what hides, and the fetched list
lands in the box it belongs to without touching the meeting box.
The settings-storage paragraph goes back to the one sentence it was. The screen list now shows native resolutions rather than the scaled ones, the Turkish tab is named Ekran, and the repositioning comment says what a named screen changes.
Codex now asks itself for its model list, doctor grew ready flags and a
per-provider line, and the Groq key joined the masked ones; the OpenCode
additions are folded into each. The no-CLI cleanup test follows the
fake_run to fake_cli rename.
Both sides rewrote the doctor: master rebuilt it around ready flags so a
fully local setup stops reading as broken, while this branch taught it
that Google has a key of its own and that the local model has neither a
key nor a program. Kept master's structure and folded the branch in: the
two hosted providers answer for their keys, a CLI for its program, and
the JSON keeps both the provider-aware "key" and master's "ready".
SECRET_KEYS gained groq on master and gemini here; the resolution keeps
all four. _conclude was also changed by both: master stopped matching
stderr wording and asks _API_TROUBLE instead, this branch renamed its
last parameter to the provider's short name so a session is stored under
the name it is read back by; the rename now rides on master's body.
codex_models() and the settings loaders landed beside the new Gemini
ones, so both stay. The new cleanup tests called the CLI fake by its old
name, fake_run, which master had renamed to fake_cli.
The paste block was rewritten by both sides: master taught press() to
put the remembered application back in front (focus), this branch moved
the history write ahead of the paste and stopped restoring the old
clipboard over a transcript the key press refused. Kept this branch's
order and error handling, and handed press() the focus it now takes.
deleteLater destroyed a window whose model boxes a daemon thread was
still reporting into: a running download died with a RuntimeError, the
.part stayed on disk and the UI said nothing. Dropping the reference is
how the ordinary close path lets a window go, and the closures a thread
holds keep the object alive until it is done, so the rebuild now does
the same. While the transcriber or either model box is working the
rebuild is skipped altogether; _built_language keeps the old language,
so the next quiet save asks for it by itself.
The new window is also placed and turned to the old tab before it is
shown, so it no longer comes up at the default size and jumps. And
tests/test_ui.py now says that a save with a changed language emits
language_changed, and one without does not.
Pressing the shortcut while a transcript was being transcribed or cleaned up
did nothing, and the thought you had while waiting was lost. The microphone is
free the moment a recording stops, so the next dictation can now be spoken at
once; the pipeline queues it and each one is finished, pasted and reported in
the order it was spoken. The corner indicator stays with the recording under
way rather than being wiped by the previous run's progress, and a stop that
lands behind an unfinished run says it is waiting its turn.
Every model box has taken a typed name all along, but nothing said so, and a
closed-looking list reads as the whole choice. The Claude Code and Codex boxes
now carry a tooltip saying the list is a starting point, not a fence. Claude
gets no fetched list of its own: its CLI has no catalog command, and the
aliases it takes (sonnet, opus, haiku, fable) already follow the newest model
of each line.
The Codex model boxes carried a hand-written list, which was already out of
date. The settings window now asks `codex debug models` for the catalog the
CLI's own picker reads, off the interface thread, and refills both boxes with
it; the built-in list is only what is on screen until Codex answers, or when
it is not installed at all.
A check on the releases page once a day: at start, on a timer while Dikte
runs, from the General tab on demand, and from `dikte update` at a terminal.
What it finds goes in the tray menu and in one notification per version, and
opens the release page.
Nothing is downloaded and nothing is installed. The four downloads are
installed four different ways and three of those belong to the platform: a
Mac bundle cannot rewrite itself while it is running, the Windows setup has
an uninstall entry of its own, an AppImage is a file kept wherever its owner
keeps it, and a checkout is updated with git. Being wrong about any one of
them means an installation somebody has to repair by hand.
Versions are compared by their numbers alone. A build off master carries the
released number with its commit after it, and that build is ahead of the
release it names rather than behind it; read as a version suffix, every
nightly would be told to go back to a release it had already passed.
The clock lives in its own file rather than in the settings, since a check
runs while the settings window may be open and a background write into
config.json is what would overwrite whatever it holds.
An hour and a half of speech came back as "OpenRouter: HTTP 502: The
operation was aborted due to timeout", every time. The upload limit was
the only thing deciding where the file was cut, and mp3 at 48 kbps
reaches 24 MB after an hour, so a 90 minute file became two chunks and
the first one was 63 minutes of audio in a single request. Nothing
between here and the model stays on the line that long.
A chunk is capped at fifteen minutes now, whatever it weighs, and the
call is finally handed a timeout of its own: it was going out on the 300
second default sized for a dictation, which the same chunk would have
hit first anyway.
The other half is not throwing the run away when one request fails.
ApiError carries whether a second try can fix it, which is true of the
statuses a gateway raises itself and of a dropped connection, and false
of a rejected key. A chunk is asked for three times, waiting five then
ten seconds, with the Stop button still able to get through. If it does
fail in the end, what was already heard goes to the output box rather
than the bin, with the status line saying where it stops.
Both Linux test jobs on this branch sat an hour in apt-get, on runners
where the same step takes seconds, and a job with nothing to time it out
holds the pull request red-free and unfinished until the six hour limit.
Three retries and a twenty second timeout give apt another mirror, and
five minutes on the step gives the job an end.
Windows matches a filename without regard to its case, so Dikte.exe and
dikte.exe were one file in the installation directory: PyInstaller wrote
the console build second and that is the only one that landed. Every
launcher then opened a console window behind the tray, and closing it
killed the application.
The console one is dikte-cli.exe now, which the setup's shim names. The
build reads the PE subsystem of both afterwards, since a filesystem that
keeps the two names apart is exactly what the check would run on
otherwise.
The releases page had nothing for Windows, so the only way in was a
checkout, a Python and a pip install. What goes out now is one setup
program per release: PyInstaller's directory, the pinned ffmpeg the
disk image already uses, and Inno Setup around both. It installs for
the account alone, so no administrator is asked for.
Two executables over the one program there, because a windowed one on
Windows has no standard output at all: Dikte.exe for the Start Menu and
dikte.exe for the terminal, sharing everything they carry. The icon is
drawn by Dikte itself into an .ico, the way the Mac's .icns and Linux's
PNGs already are, so there is still no image file in the repository.
Starting at sign-in is a registry value rather than a Startup shortcut,
which is what lets the setup program, the uninstaller and
`dikte integrate` all mean the same thing: the wizard asks once, and
typing the command changes the answer later.
The three builds move into build.yml, which release.yml now calls
instead of holding its own copy, and which a pull request touching the
packaging runs on its own. A broken build is then a red pull request
rather than a failed release.
The merge put a Windows runner under tests master wrote for two systems,
and 21 of them fell over on it. Two were the quoting: `command_for` goes
through shlex now, so a Windows path comes back in quotes, and the two
tests that read the command as a string were reading it as a Linux one.
They ask through the same join instead.
The other nineteen are `integrate.py`, which writes the menu entry and
the login item a downloaded build installs for itself. There are two
downloads, an AppImage and a disk image, so the module has a Linux half
and a macOS half and nothing a Windows host would run: its tests hand
the fake home over in $HOME, which Windows does not read, and compare
paths that start at the root. They carry `@posix_only`, which comes off
again the day there is a Windows build to integrate.
Three files disagreed. The test workflow gained a job on either side, so
both stay: the Mac now parses the release and packaging scripts as well,
and Windows keeps its own job below.
`restart` and `launch_gui` both start Dikte again, and master moved that
argv into `ipc.launcher()` because a packaged build has no `__main__.py`
to name. Windows still cannot use execv there, so the detached start it
needs now takes what the launcher hands it rather than spelling the
interpreter and the script itself.
The test counts in CONTRIBUTING are the suite as it stands after the
merge: 1104 of 1147 run anywhere, and the 43 left are the Linux ones.
The list was four of the six that Go's crypto/x509 and curl both carry.
The missing two are the bundle under /etc/pki/ca-trust, which is where
RHEL 7 and CentOS keep it, and OpenELEC's. Neither is likely and both are
one line.
The rest was checked in containers rather than assumed: the first entry
answers Debian, Ubuntu, Arch, Gentoo, Fedora and Alpine, and openSUSE is
the only common distribution that needs a different one.
A build carries the OpenSSL of the machine it was built on, and that
OpenSSL has one directory compiled into it as the only place it looks for
certificates. For an AppImage built on Ubuntu that is /usr/lib/ssl, which
Arch, Fedora and openSUSE do not have, so on any of them every HTTPS
request fails with CERTIFICATE_VERIFY_FAILED: transcription and cleanup
report it as a rejected key, and the model downloads fail too.
Ask the machine instead, from the list curl and Go use, and only when the
build's own answer turns out not to exist. The store on the machine
rather than a copy carried along, because a copy goes stale as roots are
rotated and would ignore a certificate somebody added themselves. Anybody
who has already set SSL_CERT_FILE is left alone.
A Mac keeps temporary directories under /var, which is a symlink to
/private/var, and target() resolves what it is handed because a bundle
reached through a symlink is still that bundle. So the login item named
/private/var/... while the test held /var/..., and the two spellings of
one path did not match.
Installing meant cloning the repository and running a shell script, which
is a fair ask of somebody who already has a terminal open and no ask at
all of anybody else. The releases page now carries an AppImage and a disk
image per Mac architecture: a push to master rebuilds a rolling "latest",
a v* tag publishes a version and leaves it there, and the Run button in
the Actions tab raises the number by running scripts/release.sh, which is
the same script and not a second copy of what it does.
Two things in the application had to give for that. A build has no
__main__.py on disk, and an AppImage is mounted somewhere new every run,
so the command a shortcut is registered with cannot go on being this
interpreter and this file; ipc.launcher() answers with the AppImage or
the bundle instead. And a build carries its own libstdc++, which every
process it starts inherits through LD_LIBRARY_PATH and none of them can
live with: ffmpeg, ydotool and wl-copy are the distribution's binaries
built against the distribution's libraries, and AppImageLauncher, which
is what starting the AppImage again goes through, refuses outright.
integrate.py puts that variable back before anything else runs.
Nothing installs itself over an installation that is already there.
install.sh's menu entry, install-mac.sh's login item and the desktop file
AppImageLauncher writes are each recognised and left alone, so trying a
download once does not quietly move the machine onto it. `dikte
integrate` is how you ask for it outright, and --remove takes it back.
The disk image carries an ffmpeg, pinned and checksummed, because macOS
records through one and ships nothing like it. It is signed ad-hoc and
not with an Apple certificate, so a first launch is refused until Open
Anyway and the permissions are asked for again after each update; both
READMEs and the release notes say so.
The assertion spelled the path with a forward slash, which is not the separator
Windows joins with. The Windows job this branch adds is the first to run it
there.
Every file this branch touches moved into dikte/, so the merge is mostly the
rename following the edits. What needed a hand:
hotkey.py: master replaced the _macos()/_gnome() pair with one backend()
chooser, and this branch had added _windows() to the pair. Windows is a fifth
value of the chooser now, and everything that used to ask "macOS or Windows?"
asks backend() instead. The key is held by the running process there, so
installs_shortcuts() and shortcut_needs_restart() are both false for it, and
desktop_name() says Windows.
install.ps1 and the Windows README name dikte/__main__.py, the entry point the
Linux and macOS installers were pointed at in the same commit. The Start Menu
entry, the autostart entry and the dikte.cmd shim all come off one $entry
variable.
settings_ui.py: the shortcut tab now has a Windows sentence of its own, with
the Turkish for it. Falling through to the branch master wrote for a desktop
with no registry would have told a Windows user to check /dev/input. Nothing
covers that branch: there is no Windows Settings test class, the way there is
one for macOS.
CONTRIBUTING: the chooser it names is backend() now, and the test count is the
merged one, 1067 of 1110 running anywhere.
Twenty-two files at the top of the tree was the first thing anybody saw of
this repository. They are one package now, imported relatively, and the three
scripts that are not the front door moved under scripts/. install.sh stays
where the README has always said it is.
What starts the application is dikte/__main__.py: python3 -m dikte runs it,
and so does naming the file, which is what the launcher symlink, both .desktop
files, the macOS bundle and every registered shortcut do. Run by path there is
no package around it, so it puts the checkout on sys.path itself.
The installers now keep the keys you chose when they are given none, which is
what an update is: update.sh no longer has to read them out and pass them back.
An updater from before this commit cannot read them at all, so the one thing it
can say, the default key with an empty discard key, is read as "nothing was
asked for" rather than obeyed. That guard can go once nobody is updating across
this commit.
Only GNOME was recognised, and everything else was handed to KWin. On i3,
XFCE, Cinnamon, MATE, sway and the rest, Dikte wrote an entry into
kglobalshortcutsrc that nothing reads, called the session KDE, and
promised that the keys would work after the next login. They never did.
There is no backend to write for any of them. The /dev/input listener is
already desktop-agnostic, so those sessions are the case macOS has always
been: no registry, nothing to install, nothing to remove, and the
combination held by the running process. One backend() function decides
which of the four this session has, and the name shown, the status read
back, what Install writes, what Settings explains and what the installer
promises are all taken from it, so they cannot disagree.
A desktop now only counts when the program that writes its registry is
there too. A GNOME session without gsettings and a Plasma one without
kwriteconfig6 fall to the listener rather than to a file, which is also
how Plasma 5 stops erroring on a kwriteconfig6 it never had.
What Settings shows on those desktops is the truth: no Install button, no
KWin, no listener checkbox (it is the mechanism, not a choice), what
reading /dev/input costs, that the focused application sees the keys too,
and the command to bind if you would rather your desktop owned them. The
evdev listener records what it is listening for the way the Carbon one
does, so the status line has something to say there at all.
Closes#28
A session that names no desktop, which is what i3 and a bare X11 login are,
leaves Qt with hicolor as its only icon theme, and hicolor has none of the four
names the tray asks for. So fromTheme returns nothing and the shapes drawn in
trayicon.py are used, as they are on macOS. They were drawn in black, which
macOS recolours through the mask and X11 does not, and i3's bar is black: the
icon was there all along, painted onto a bar of its own colour. Outside macOS
they are now white over a dark copy of themselves spread a pixel out, which
stands out on a dark bar and stays readable on a light one, and the mask is set
only where something reads it.
The .desktop files had the same hole from the other side. They named
audio-input-microphone, which is in Breeze and in Adwaita but not in hicolor, so
the menu entry and the autostart entry were blank on the same systems. install.sh
now draws the application icon into ~/.local/share/icons/hicolor and both entries
name it; uninstall.sh takes it back. The windows carry it too on X11, where a
window has no .desktop file to be looked up in.
Closes#27
A phone call in the middle of a dictation left two choices: send what
was said so far off to be transcribed, or throw it away. Both end the
sentence you were in the middle of. Now the recording can be held: the
microphone stays ours, what was said before the pause stays in the
buffer, and what is said during it is dropped.
The capture program keeps running and keeps handing blocks over, which
are read and thrown away rather than left in the pipe. Stopping it
instead would mean asking the sound server for the device again on the
way back, and that is the one moment another application can take it: a
recording would be lost to the phone call it was paused for.
The clock stops with it. Paused time is time the recording does not
have, so the indicator and the length limit both go by what was
actually captured, and a five minute limit is not spent waiting.
The indicator says so as well, since a pulsing dot, moving bars and a
counting clock otherwise all say the words are still going in: the
ribbon freezes where the pause found it and turns amber behind two
bars. The tray menu holds and resumes it, `dikte pause` does, and so
does a global shortcut, which starts empty because holding a recording
is not something a keyboard has a habit for.
Dictation and a command to the agent both, whichever is recording. A
meeting is left out: it writes to a file as it goes and keeps two
streams aligned itself, and neither of those wants a hole in it.
Two things the scrolling tabs brought with them.
A combo box and a spin box read the wheel as a change of value, and Qt
hands them the focus before delivering it. Now that every tab scrolls,
rolling down the API tab with the pointer over the model box picks a
different model on the way past, and Save writes it down. The boxes take
the focus by click or by tab only, and a wheel that arrives at one
without the focus is refused rather than swallowed, so it carries on up
to the scroll area and the page moves instead.
A tab that scrolls also asks for no height of its own, which left
nothing to stop the window being dragged down to a tab bar and half a
button. It has a floor now, and the floor never asks for more room than
the screen was just found to have.
The stand-in whisper server in the ggml tests is an http.server, and
http.server looks up the reverse name of the address it bound in between
the bind and the listen. On Linux that answers at once. On a Mac nothing
answers and the lookup sits in a resolver timeout for thirty-five
seconds, with the port closed the whole time and _wait_ready watching it.
Seven tests start a server and one of them starts two, so the macOS job
spent 330 of its 334 seconds inside that lookup while the same suite took
fifteen seconds on Linux. Binding through socketserver and naming the
server after the address it already has skips the question.
The comment claimed the new process would take itself for the second copy and
hand its command over. That is the command line's path, through cli.run and
ipc.send; run_app with --gui never asks whether anything is already running, it
calls listen() and prints if that fails. The change is the same one either way:
two processes asking for one name is either two servers answering on it or a
listen that fails into a console nobody reads, and closing first leaves neither.
Checked against the release listings rather than guessed: whisper.cpp publishes
Win32 and x64 for Windows and nothing else, while llama.cpp does publish
bin-win-cpu-arm64.zip. So a Snapdragon machine gets a native cleanup model and
an emulated transcriber, which is slow enough that the cloud is the better
answer there, and neither the code nor the README said so.
The test pins it, so that a whisper.cpp release which does start publishing an
arm64 build turns the choice red rather than being quietly ignored.
Sixty of them are about the Windows backends, and every one of them has only
ever run on Linux and on a Mac: user32 and kernel32 are faked at the one
function that loads them, which is the whole point and is also the whole limit.
The half that reads the real system has been taken on trust. The Mac has had a
job of its own for exactly this reason since it was ported, and the comment on
it says what it is for; this is the same job with the same reason.
What it should catch that nothing else does: whether %APPDATA% and
%LOCALAPPDATA% are the directories Windows actually hands out, whether a path
spelled with a backslash is still one the tests can read, whether the
config-permission test skips rather than fails where the mode bits mean
nothing, and whether install.ps1 parses. Running install.ps1 is not on the
table: it writes into the Start Menu and the user PATH.
Master was rewritten between the two merges: the macOS meeting commits came
back under new hashes with the same content, so the first merge points at a
commit the repository no longer has and audio.py collides with itself. Nothing
in the resolution is a choice between two versions of anything; both sides of
both hunks are this branch's own additions against nothing.
README.tr.md still offered three, so a Turkish reader could not tell Windows was
supported at all. CONTRIBUTING opens the section a port is written from, and it
described three tables and three systems; there are four of each now, plus
paths.py, which master separated out while this branch was adding a Windows case
to the two copies it replaced.
install.sh and install-mac.sh are in English, and README.tr.md is where Turkish
lives. install.ps1 arrived in Turkish, and in a Turkish with the diacritics
stripped out of it, which is neither one language nor the other.
Two things while it was open: the `dikte` command ran whichever `python` the
PATH answered with rather than the one checked a few lines above it, and a
machine with no WindowsApps directory got no command and no word about why.
`doctor` had the Wayland pair spelled into it, so an X11 machine was never
asked about the two it really pastes with, a Mac was told ydotool and
kwriteconfig6 were missing, and Windows, which shells out for neither half of
the clipboard, got five red marks for programs it was never going to have. The
two come out of `paste.Desktop` now, and the Linux-only three are added on
Linux. A row saying a program is missing on a machine that would never have run
it is not a diagnosis, it is a mark to explain away.
`devices` had the same shape of answer: "pactl found nothing; is PipeWire
running?" on a Windows machine with no microphone. It names whatever this sound
system is missing instead, which is the string the table already carries for it.
The Windows README's troubleshooting sends people to both, so it says so.
execv leaves nothing behind to answer, so this never came up on Linux or a Mac.
A Windows restart is two processes for a moment: the new one looks for an
instance to hand its command to, and if it finds the old one still listening it
takes itself for the second copy and exits, leaving nothing running at all. The
server is closed before the replacement is started rather than after.
Not reproduced, because it is a race and it lost: found by reading the order of
the two calls, and worth a restart or twenty on a real machine.
EmptyClipboard is the point of no return: after it, whatever was there is gone,
and the allocation that failed on the next line left the clipboard holding
nothing. That is the one path where restoring what a dictation borrowed could
lose it instead. The buffer is filled first, and the clipboard is opened only
once there is something to put in it.
Three things about the dshow backend, all of them found by reading rather
than by running, so all three want checking on a real Windows machine.
The device listing is parsed in both of the shapes ffmpeg has printed it in:
newer builds mark every device `(audio)` or `(video)`, older ones print a
heading and no marks, and only the first was read. Each pattern is anchored at
both ends now, so the error lines the command ends with, which quote the device
name it was told to look for, are no longer read as a device of that name.
What is stored for a device is the alternative name under it rather than the
friendly one. A laptop with a headset plugged in has two microphones called the
same thing, and `audio=Microphone` reaches the first of them whichever one was
picked; the alternative name is unique. The friendly name stays what is shown,
which is what the (id, description) pair in these lists has always been for.
An unset microphone meant "the first one listed", and the listing costs an
ffmpeg of its own, so every press of the key paid for a process before the
recording started. The last listing is remembered instead, and opening Settings
or running `dikte devices` takes a fresh one.
And a fourth thing, which is about what the interface says rather than what it
does: whether the far side of a meeting can be captured at all is now an entry
in `audio.Sound` instead of being read off an empty device list. The two are not
the same answer. An empty list on Linux means pactl is not installed, which a
user can go and fix; False on Windows means there is no such device and no
driver that would add one. The Meeting tab says so under the empty box, and
starting a meeting says it instead of sending somebody to Settings to pick from
a list that will never have anything in it.
A recording is never deleted for being disappointing. A microphone that
handed over nothing still leaves the right channel, which is everyone
else, and an hour of them is worth more than the empty channel costs; the
one thing the user cannot get back is the half that was there. So the
exact-zero check stays and stops throwing the file away: it says what the
microphone did, in a tray warning next to the recording being written up,
and the minutes are produced from what there is.
Reading the two capture pipes in turn from one thread put the failure it
was meant to fix back in a worse place. A microphone that stops delivering
leaves that read waiting forever, and the far side is not read either
until its pipe fills and its ffmpeg stops writing into it: the meeting
freezes, the levels sit still, and nothing is said for as long as nobody
looks. Each stream now has a reader of its own and a queue, so neither can
hold the other up, and a side that has said nothing for STALL_SECONDS ends
the recording the way a dead ffmpeg already did, out loud and keeping what
was captured.
Which system needs how many processes belongs in the table with everything
else that differs, so meeting() returns the list of commands it takes:
one on PulseAudio, one per device on a Mac. meeting_commands() is the
chooser again rather than a function with a Mac inside it, and the empty
entry in COREAUDIO is gone. The two AVFoundation targets are resolved
against a single device listing, which costs one ffmpeg run instead of two
and cannot see the indexes renumber between the microphone and the far
side.
Co-authored-by: benfirad <[email protected]>
Three of the four collisions were the same one: master moved the directory
rule into paths.py while this branch was adding a Windows case to the copy in
config.py and the second copy in ggml.py. The case moves to paths.py with the
rest of it, and the directories test moves to tests/test_paths.py where master
put its neighbours.
The fourth is MeetingRecorder, which now starts a process per capture device.
Windows keeps its two lines there: no console window for either process, and
a stop that terminates rather than sending a signal the platform does not have.
A recording is never deleted for being disappointing. A microphone that
handed over nothing still leaves the right channel, which is everyone
else, and an hour of them is worth more than the empty channel costs; the
one thing the user cannot get back is the half that was there. So the
exact-zero check stays and stops throwing the file away: it says what the
microphone did, in a tray warning next to the recording being written up,
and the minutes are produced from what there is.
Reading the two capture pipes in turn from one thread put the failure it
was meant to fix back in a worse place. A microphone that stops delivering
leaves that read waiting forever, and the far side is not read either
until its pipe fills and its ffmpeg stops writing into it: the meeting
freezes, the levels sit still, and nothing is said for as long as nobody
looks. Each stream now has a reader of its own and a queue, so neither can
hold the other up, and a side that has said nothing for STALL_SECONDS ends
the recording the way a dead ffmpeg already did, out loud and keeping what
was captured.
Which system needs how many processes belongs in the table with everything
else that differs, so meeting() returns the list of commands it takes:
one on PulseAudio, one per device on a Mac. meeting_commands() is the
chooser again rather than a function with a Mac inside it, and the empty
entry in COREAUDIO is gone. The two AVFoundation targets are resolved
against a single device listing, which costs one ffmpeg run instead of two
and cannot see the indexes renumber between the microphone and the far
side.