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
The macOS backends were already here: CoreAudio capture through ffmpeg,
pbcopy and CoreGraphics, Carbon hotkeys, the paths under ~/Library. What
was missing was everything that installs them, so install.sh hands over to
install-mac.sh on Darwin rather than growing a branch per line: the XDG
directories, the .desktop files and the shortcut registry mean nothing
there, and an application is a bundle rather than a path. The bundle
carries a copy of the interpreter, because macOS files the microphone and
Accessibility permissions against the process that asks, and a launcher
running Homebrew's python3 would have asked as python3 and shared the
grant with everything else on that interpreter. It is signed ad-hoc so a
reinstall is the same application rather than two more dialogs, and it
says so plainly when a brew upgrade has moved the tree it needs.
uninstall.sh and update.sh follow it.
The tray icon was invisible: QIcon.fromTheme wants a freedesktop icon
theme and hands back a null icon without one, which in a menu bar is the
whole interface gone. trayicon.py draws the three shapes as template
images, so they follow the menu bar into dark mode, and the bundle's icon
comes off the same glyph rather than a binary in the repository. Linux
keeps its own icons; these are used only where the theme has nothing.
paths.py is the fix that was never about a Mac. config.py imports ggml.py,
so ggml.py could not ask it where the data goes; each worked it out for
itself and only one of them knew about macOS. Settings went to ~/Library
while several gigabytes of models went to ~/.local/share, which is not a
place a Mac user looks and not a place uninstall.sh --purge would have
deleted from.
Ctrl+Space is the input-source switch there and Cmd+Space is Spotlight, so
the default is Ctrl+Option+Space, and hotkey.default_combo is the one
place that difference lives. The first paste asks for Accessibility with
kAXTrustedCheckOptionPrompt, which is what creates the row to switch on;
asking the other way opens a pane Dikte is not listed in. `dikte shortcut
status` asks the running instance, since the combination is held by that
process and by nothing else.
Local speech to text is the one piece a Mac builds by hand. whisper.cpp
publishes no macOS binary and Homebrew's is configured with
WHISPER_BUILD_SERVER=OFF, so it installs whisper-cli and not the server
Dikte talks to; program_path already takes a whisper-server off the PATH
or out of Settings, so the answer is the one a Linux distribution gets,
and the README carries the cmake line. CI grows a macOS job on 3.11 and
3.13, the only place the Carbon and CoreGraphics libraries have to be
there to be opened.
Written and tested on macOS 27.0 arm64. Two things are still unverified on
a Mac: the paste end to end, which waits on the Accessibility toggle, and
a meeting recording, which needs a loopback driver.
The section ran longer than Ubuntu and macOS put together, and most of it
answered questions nobody asked while installing: why ffmpeg-free is enough
in detail, why the runtime directory settles after login, and why the local
models need nothing added. That last one says "do nothing", which is what
the reader would have done anyway. The reasoning stays in the pull request,
where it belongs; the README keeps the package line and the drop-in.
install.sh printed the drop-in the README already carries, six lines where
every other warning there is two. It now points at the README.
Its readiness check also went from asking whether the unit was up to asking
whether the socket is writable, which reads a stopped daemon as ready: the
socket file outlives the process. It now wants both, with pgrep for the
process so a ydotoold started by hand still counts. All five paths were
exercised with systemctl and pgrep shims, and on this Arch box the real
user service still reports ready.
The README has Arch and Ubuntu on it, and the two things that differ on Fedora
are the two that decide whether anything works at all. The package names are
its own, and `systemctl --user enable --now ydotool` has nothing to enable
there: Fedora ships ydotool as a system service only, and its ydotoold holds a
root-owned socket at mode 600. The unit is green, the daemon is up, and every
paste is refused. The drop-in the README now spells out points ydotoold at
$XDG_RUNTIME_DIR/.ydotool_socket and hands it over by uid, which is the path
the client already looks at, so nothing has to carry YDOTOOL_SOCKET. Red Hat
closed the same report as NOTABUG in 2023, so this stays a step the user takes
rather than one a later package will take for them.
install.sh was asking whether the unit was up, which is the question that
answers wrongly here. It now asks whether the socket is one this user may write
to, which is what auto-paste actually needs, and it names which of the three
ways it failed: a socket handed to nobody, a daemon that put its socket
somewhere else, or no daemon at all. On Arch and everywhere else the answer is
the same one as before.
ffmpeg-free out of Fedora's own repositories is enough, against the usual
advice to reach for RPM Fusion. That build disables four video decoders, h264,
hevc, vc1 and vvc, and keeps every audio decoder it has, AAC among them, while
Dikte only ever asks a video file for its audio track.
The models that run on this machine ask for nothing either, which is worth
saying because it is now the way Dikte starts. The Ubuntu builds run on Fedora
unchanged; whisper.cpp transcribed a clip here without a package being added,
and llama.cpp came down in its Vulkan build and found the card. That last part
is not luck: kwin-libs requires libvulkan.so.1, so the loader is on every
Plasma desktop by way of the compositor, and it recommends the Mesa drivers
alongside itself.
Three of master's changes land on the same lines as this branch, so most of
this is picking the newer shape and putting the local half back into it.
cleanup.py arrived while this was being written and is the right place for
a fourth provider, so the Target refactor of api.cleanup goes away: llama.cpp
becomes a name in cleanup.PROVIDERS next to OpenRouter, Claude Code and
Codex, and worker.py, meeting.py and filetranscribe.py go back to master's.
The settings window keeps master's one row per provider, hidden with
setRowVisible, rather than the two wrapper widgets this branch had. So does
speech to text, which was doing the same thing its own way.
The transcriber table has no room for a provider with no key and no base
URL, so the local one is answered before the lookup rather than added to
it, and an unknown name now falls back to openai by name: the shipped
default is no longer a key of that table.
The minutes stay on OpenRouter, which master already decided by routing
only the transcript through cleanup.run, so meeting_provider goes.
whisper-server is started on --inference-path /v1/audio/transcriptions,
which is exactly the path api.py already builds for the hosted providers,
and llama-server answers /chat/completions the way OpenRouter does. So the
local half is one more base URL rather than a second code path: worker.py,
filetranscribe.py and meeting.py are untouched, and dictation, subtitles
and meetings all work here on the first try.
Three findings worth naming, none of them in the new code:
whisper.cpp cuts segments on tokens, which in Turkish lands inside a word
about as often as between two. Pasted raw that gives "akraba değ\niller.";
in a subtitle it gives a cue reading "değ". Whisper marks the start of a
word with a leading space, so a piece that does not begin with one
continues the word above it.
A small model will repeat the transcript until the context is full, and
every one of those tokens is a second of somebody waiting: measured at 206
seconds, and 25 with a ceiling on the reply. Hosted models are left alone,
where the same runaway is rare and a ceiling would cut the minutes short.
A server outlives SIGTERM and SIGKILL holding its model in memory. Signals
are now turned into an event Qt delivers, since Qt blocks in C where a
Python handler never runs, and a pid file lets the next start sweep up
what a SIGKILL left behind.
The minutes keep their own provider rather than following cleanup's. The
two jobs are not the same size: a 4B model here will strip the filler words
out of a dictation and will not write up an hour long meeting.
The suite runs offline now: a test that reaches the network says so instead
of quietly going there.
Stopping a recording is the step there is no taking back. It is what sends the
audio off, and a moment later the sentence you did not mean to dictate is in
the clipboard and pasted into whatever window you were typing in. The tray menu
was the only way out, and by the time it is open the recording has already
gone. Discarding needed to be as quick as starting, which means a key.
Ctrl+Alt+Space rather than Escape: the combination the recording started with,
one modifier along, so the two are one gesture with a modifier between them.
Escape is the obvious choice and the wrong one, because it belongs to whichever
window has focus, and while you are talking something else usually has it. It
works on a dictation and on a command for the agent alike, since the one you
want to take back is the one that is running.
That made four global shortcuts, and four is the number at which three copies
of the same forty lines stop being a coincidence. The command line already had
a table of them; hotkey.py now holds it, and the settings window reads it too,
so a shortcut is a row rather than a combination box, two buttons, a status
label and three methods written out again. The window no longer takes the
commands to run as arguments either, because ipc.command_for already knows them
from the verb. Adding the fourth key is what this buys: one line in the table
and one call per row.
Both places a key can live are told about it. The listener catching the press
and the KDE shortcut arriving behind it go through the same echo guard the
toggle already had, so the two are one discard rather than two, and the tray
calls the inner method as it already did for the toggle. Ctrl+Space and
Ctrl+Alt+Space land on the same evdev key code, so there is a test for the
modifier matching that keeps them apart.
install.sh takes the second key as a second argument and refuses to register
two of the same. It hands both to `dikte shortcut install` rather than writing
kglobalshortcutsrc itself, which is what makes it work on GNOME, and what
finally puts the chosen key in the settings as well: the built-in listener
reads it from there, so a key written to only one of the two places was a key
that half worked.
Left empty in the settings window the discard key stays empty, unlike the
dictation shortcut which falls back to Ctrl+Space: a recording can always be
thrown away from the tray menu, so there is nothing to guarantee here.
The screenshots were downscaled to 430 px wide, which made the UI text
blurry. Restore them at native 1292 px as lossless WebP, which is also
half the size of the original PNGs (72 KB against 155 KB for the largest).
Rewrite every em dash in prose, comments, docstrings and interface strings
as ordinary punctuation.
Ctrl+Space starts and stops a recording. The audio goes to OpenAI for
transcription, a model on OpenRouter strips the fillers and restores
punctuation, and the result is copied and pasted into the focused window.
Only the Python standard library and PyQt6 — HTTP, multipart uploads and
WAV writing are all hand-rolled.
- pw-record captures raw 16 kHz mono PCM with a live level meter
- the corner indicator is drawn through XWayland, since a Wayland client
cannot position its own window
- silence is caught before it costs an API call, relative to each
recording's own noise floor, plus a filter for the stock phrases models
invent when handed silence
- audio and video files can be transcribed too, optionally with [mm:ss]
timestamps, chunked through ffmpeg for long inputs
- global shortcut installs as a KDE custom shortcut, with an evdev
listener as a fallback until the session is restarted
- Turkish and English interface, following the system locale by default