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 cleanup prompt is picked by interface language, so a Turkish
interface hands a Turkish system prompt to the model and English
dictation comes back translated. The rule against that was the fifth
line of a DO NOT list, which is not where a model looks when the whole
prompt around it is written in one language.
Move it up under the job, in both cleanup prompts, and say plainly that
the language the rules are written in decides nothing.
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.
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.
Only two tabs scrolled, so the rest handed their full content height to
the window as a minimum. The API tab alone asked for 668px, which put the
window's minimum at 756px: taller than a laptop screen has room for once
the menu bar and dock have taken theirs, and unshrinkable, so Save sat off
the bottom edge. Every tab scrolls now, and the opening size is clamped to
what the screen actually offers.
The program path in the local model box was clipped to one line for a
related reason: it shares a form row with a button, and the row is
measured before its width is known. WrappedLabel re-measures against the
width it ends up with and claims that height back.
A backslash only separates on Windows, and this test also runs on the
Linux that checks the Windows half.
Co-Authored-By: Claude Fable 5 <[email protected]>
Windows joins the three systems as its own entry in each table: DirectShow
through ffmpeg for capture, the Win32 clipboard and SendInput for the paste,
RegisterHotKey for the global shortcut, and the whisper.cpp and llama.cpp
Windows zips (the OpenBLAS whisper build, which transcribes about twice as
fast on a plain CPU). Settings go to APPDATA, data to LOCALAPPDATA, and
install.ps1 adds the Start Menu entry, the dikte command and an optional
autostart. Meetings are not supported yet: Windows offers nothing to record
the far side from.
Porting surfaced three fixes that were not Windows specific:
- A stopped or overlong download tried to delete its .part file while still
holding it open, which Windows refuses. The unlinks now wait for the handle.
- The CLI transcribed files without handing the local servers their settings
first, so a local provider failed with "no model downloaded" wherever the
GUI had not run in the same process.
- The audio content types are pinned instead of asked of the registry, which
answers differently machine to machine.
One fix is Windows specific but sits in shared code: shutdown() does not end
a blocked recv there, so stopping a request also closes the socket handle.
Co-Authored-By: Claude Fable 5 <[email protected]>
WA_TransparentForMouseEvents does nothing for a top-level window: Qt
takes the click and then drops it, so it still never reaches whatever is
underneath. Since the window stays mapped while idle, that turned its
corner of the screen into a dead zone for good. Qt::WindowTransparentForInput
is the one that leaves the window without an input region at all.
The dismissable one has to keep taking clicks, and the flag is read once
when the window is created, so it shrinks to a point while concealed
instead. Resizing keeps the surface alive, which is the whole reason
concealing does not simply hide it.
--raw arrived in 1.4, the same release that stopped reading a bare "-" as
raw on its own. The split is there rather than at 1.0: Ubuntu 24.10 and
anything else on 1.2 refuses the option too, and 1.4 onwards writes a
container around the stream without it.
Asking the installed binary meant the command tests ran pw-record --help
four times for real, on a machine whose answer decides what they see, which
the module docstring promises they never do. They pin the answer in setUp,
and the reading itself gets its own class, the empty help nobody covered
included.
recording_command became a table of sound systems while this branch was
open, so the pw-record command it patched now lives in _pulse_record. The
check moves there with it, and nothing else about it changes.
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 first line still named KDE and Wayland only, and Install stopped at
Ubuntu. A Mac has nothing for install.sh to install into, so what it needs
is ffmpeg, PyQt6 and python dikte.py, plus BlackHole for a meeting. The
settings also sit somewhere else there.
A Mac tester ran the suite on the 3.9 that ships with macOS and got 167
errors, all of them TestCase.enterContext, which arrived in 3.11. The
workflow already runs 3.11, 3.12 and 3.13; nothing said so out loud.