From 3436e6b426ad58a123b1ffdfa268a59b7225e733 Mon Sep 17 00:00:00 2001 From: huseyin-emre-tigci Date: Fri, 14 Aug 2026 16:53:21 +0300 Subject: [PATCH 01/16] Add Windows support 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 --- README.md | 12 ++- README.windows.md | 72 +++++++++++++++ api.py | 14 ++- assistant.py | 1 + audio.py | 109 +++++++++++++++++++++-- cleanup.py | 1 + cli.py | 14 +++ config.py | 6 ++ dikte.py | 18 +++- filetranscribe.py | 1 + ggml.py | 92 +++++++++++++++++--- hotkey.py | 200 +++++++++++++++++++++++++++++++++++++++--- i18n.py | 10 +++ install.ps1 | 74 ++++++++++++++++ ipc.py | 4 +- overlay.py | 5 +- paste.py | 182 +++++++++++++++++++++++++++++++++++++- tests/test_audio.py | 78 +++++++++++++++++ tests/test_cli.py | 23 +++++ tests/test_config.py | 26 ++++-- tests/test_ggml.py | 85 ++++++++++++++++++ tests/test_hotkey.py | 203 +++++++++++++++++++++++++++++++++++++++++++ tests/test_paste.py | 127 +++++++++++++++++++++++++++ tests/test_ui.py | 8 ++ 24 files changed, 1317 insertions(+), 48 deletions(-) create mode 100644 README.windows.md create mode 100644 install.ps1 diff --git a/README.md b/README.md index 5517d69..b2cc0f9 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the missing punctuation), and the result lands in your clipboard and is pasted into whatever window you were typing in. -Built for KDE Plasma 6 on Wayland, and runs on GNOME X11 and macOS too. No -dependencies beyond system packages: just the Python standard library, 3.11 or -newer, and PyQt6. +Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS and +[Windows](README.windows.md) too. No dependencies beyond system packages: just +the Python standard library, 3.11 or newer, and PyQt6. *[Türkçe README](README.tr.md)* @@ -61,6 +61,12 @@ that catches the keys is the mechanism there and there is nothing to run: needs BlackHole or Loopback, because nothing else offers what the speakers are playing. +Windows works the same way, holding the keys through the system's own hotkey +service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`, +then `python dikte.py`, with an optional `install.ps1` for the Start Menu entry +and the `dikte` command. Meetings are not supported there yet; the details are +in the [Windows README](README.windows.md). + `install.sh` adds the `dikte` command, a menu entry, an autostart entry and the two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away diff --git a/README.windows.md b/README.windows.md new file mode 100644 index 0000000..2a9f0c3 --- /dev/null +++ b/README.windows.md @@ -0,0 +1,72 @@ +# Dikte on Windows + +Press `Ctrl+Space`, talk, press again: what you said is transcribed, cleaned +up and pasted where your cursor is. + +## Requirements + +- **Windows 10/11** +- **Python 3.11+** with **PyQt6** (`pip install PyQt6`; install.ps1 installs + it when it is missing) +- **ffmpeg** for microphone capture: `winget install Gyan.FFmpeg` + +## Installing + +```powershell +powershell -ExecutionPolicy Bypass -File install.ps1 +``` + +This adds a **Dikte** entry to the Start Menu and a **`dikte`** command to the +terminal. Add `-Autostart` to also start it at sign-in; `-Uninstall` removes +all of it and leaves the repository and your settings alone. + +To try it without installing anything: + +```sh +python dikte.py +``` + +## First run + +1. The tray icon appears and the Settings window opens. +2. Under **API and models**, download a local whisper model (the whisper.cpp + Windows build is fetched automatically) or enter an OpenAI, Groq or + OpenRouter key. +3. The shortcut defaults to `Ctrl+Space` and is changed under Shortcuts. + While Dikte runs, Windows' own hotkey service (RegisterHotKey) listens for + it: nothing to install and no permission to grant. + +## What is different from Linux and macOS + +- **Meeting recording (microphone + speakers) is not supported yet.** Windows + does not offer what the speakers are playing as a capture device, so there + is nothing to record the far side from. Everything else works, including + transcribing audio and video files. +- **The shortcut is swallowed**: while Dikte holds `Ctrl+Space`, the focused + application does not see it. This is how macOS behaves too, and unlike the + Linux listener, which shares the key. +- No external tools for the clipboard or the key press: both go straight + through the Windows API (the clipboard, SendInput). +- Settings live under `%APPDATA%\Dikte`, models and recordings under + `%LOCALAPPDATA%\Dikte`. + +## Performance + +- The local install fetches whisper.cpp's **OpenBLAS build**, which + transcribes about twice as fast as the stock one on a plain CPU. There is + no GPU build to fetch for machines without an NVIDIA card. +- Setting Settings → API and models → **Threads** near your physical core + count helps noticeably; the server's own default is 4. +- If speed matters more than accuracy, `ggml-small` and `ggml-base` are much + faster; `ggml-large-v3-turbo-q5_0` transcribes best. + +## Troubleshooting + +- **Recording does not start:** does `ffmpeg -version` run? Does + `dikte devices` list your microphone? +- **Nothing is pasted:** a normal-privilege process cannot type into an + elevated (administrator) window; run Dikte elevated too, or paste by hand. + The text lands on the clipboard either way. +- **The shortcut does nothing:** another application already holds the + combination. Dikte says so in a tray notification when it asks for the key; + pick a different one under Settings → Shortcuts. diff --git a/api.py b/api.py index 775fe31..ee0222e 100644 --- a/api.py +++ b/api.py @@ -18,6 +18,7 @@ import mimetypes import os import secrets import socket +import sys import threading import urllib.error import urllib.request @@ -148,6 +149,12 @@ def _stop_using(conn): if sock is not None: with contextlib.suppress(OSError): sock.shutdown(socket.SHUT_RDWR) + if sys.platform == "win32": + # On Windows the shutdown leaves a blocked recv exactly where it + # was; only closing the OS handle ends it, and close() on the + # object would wait for the blocked reader to let go of it first. + with contextlib.suppress(OSError): + socket.close(sock.detach()) with contextlib.suppress(OSError): conn.close() @@ -258,7 +265,12 @@ def _multipart(fields, file_field, file_path): out += str(value).encode("utf-8") + b"\r\n" filename = os.path.basename(file_path) - ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream" + # The two types a dictation actually sends are pinned: on Windows, + # guess_type answers from the registry and differs machine to machine. + known = {".wav": "audio/x-wav", ".mp3": "audio/mpeg"} + extension = os.path.splitext(filename)[1].lower() + ctype = (known.get(extension) or mimetypes.guess_type(filename)[0] + or "application/octet-stream") with open(file_path, "rb") as fh: payload = fh.read() out += f"--{boundary}\r\n".encode() diff --git a/assistant.py b/assistant.py index f708ab3..0dcf180 100644 --- a/assistant.py +++ b/assistant.py @@ -378,6 +378,7 @@ def _stream(cmd, conf, on_event, should_stop): cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", bufsize=1, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except OSError as exc: raise AssistantError(t("Could not run {binary}: {error}", diff --git a/audio.py b/audio.py index 55beb50..5108043 100644 --- a/audio.py +++ b/audio.py @@ -30,6 +30,10 @@ from PyQt6.QtCore import QObject, pyqtSignal from i18n import t +# Console programs started from a windowless process would otherwise each open +# a console window of their own on Windows. +NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0 + RATE = 16000 CHANNELS = 1 SAMPLE_WIDTH = 2 # s16 @@ -39,6 +43,19 @@ CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000) MIN_FRAMES = int(RATE * 0.25) +def _interrupt(proc): + """Ask a recorder process to end. + + SIGINT is the polite way everywhere it exists; Windows has no equivalent a + child can be sent, so the process is terminated outright. The captured + audio is not lost either way: it has already been read from the pipe. + """ + if sys.platform == "win32": + proc.terminate() + else: + proc.send_signal(signal.SIGINT) + + class Recorder(QObject): """Runs the available sound-server recorder and reads raw PCM from stdout.""" @@ -70,7 +87,8 @@ class Recorder(QObject): try: self._proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0 + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0, + creationflags=NO_WINDOW, ) except OSError as exc: self.failed.emit(t("Could not start recording: {error}", error=exc)) @@ -125,7 +143,7 @@ class Recorder(QObject): proc = self._proc if proc and proc.poll() is None: try: - proc.send_signal(signal.SIGINT) + _interrupt(proc) proc.wait(timeout=1.5) except (subprocess.TimeoutExpired, OSError): try: @@ -246,7 +264,8 @@ class MeetingRecorder(QObject): # nobody drains would eventually block it, so it writes to a file. self._log = tempfile.TemporaryFile() self._proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0 + cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0, + creationflags=NO_WINDOW, ) except (OSError, wave.Error) as exc: self._close_file() @@ -298,7 +317,7 @@ class MeetingRecorder(QObject): proc = self._proc if proc and proc.poll() is None: try: - proc.send_signal(signal.SIGINT) + _interrupt(proc) proc.wait(timeout=2) except (subprocess.TimeoutExpired, OSError): try: @@ -603,6 +622,71 @@ def _avfoundation_default_output(): return "" +# Windows records through DirectShow, the one capture API ffmpeg's Windows +# builds all ship with. What the speakers are playing is not offered as a +# device at all, so a meeting has nothing to record the far side from yet. + + +def _dshow_devices(): + """[(name, name)] for every DirectShow audio capture device. + + The list comes out on stderr of a command that then fails, the same + documented trick AVFoundation uses above. Names are the only stable handle + dshow offers a user; they are what the recorder is given back. + """ + if not shutil.which("ffmpeg"): + return [] + try: + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-list_devices", "true", + "-f", "dshow", "-i", "dummy"], + capture_output=True, timeout=8, check=False, creationflags=NO_WINDOW, + ) + except (subprocess.SubprocessError, OSError): + return [] + + devices = [] + for line in result.stderr.decode("utf-8", "replace").splitlines(): + if "(audio)" not in line: + continue + match = re.search(r'"([^"]+)"\s*\([^)]*audio[^)]*\)', line) + if match: + devices.append((match.group(1), match.group(1))) + return devices + + +def _dshow_record(target): + if not shutil.which("ffmpeg"): + return [] + # dshow has no "default" device: an unset target means the first one listed. + device = target + if not device: + inputs = _dshow_devices() + if not inputs: + return [] + device = inputs[0][0] + return [ + "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", + # dshow holds half a second of audio before handing anything over; + # asked for the chunk the level meter is measured in instead. + "-f", "dshow", "-audio_buffer_size", str(CHUNK_LATENCY_MS), + "-i", f"audio={device}", + "-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-", + ] + + +def _dshow_meeting(mic_target, system_target): + return [] # no monitor devices to record the far side from + + +def _dshow_no_outputs(): + return [] + + +def _dshow_no_default_output(): + return "" + + Sound = collections.namedtuple( "Sound", # How to capture one source and two at once, the two device lists, which @@ -633,9 +717,24 @@ COREAUDIO = Sound( ) +DSHOW = Sound( + record=_dshow_record, + meeting=_dshow_meeting, + inputs=_dshow_devices, + outputs=_dshow_no_outputs, + default_output=_dshow_no_default_output, + missing="ffmpeg or a microphone was not found. Install ffmpeg with: " + "winget install Gyan.FFmpeg", +) + + def sound(): """The programs this machine records through.""" - return COREAUDIO if sys.platform == "darwin" else PULSE + if sys.platform == "darwin": + return COREAUDIO + if sys.platform == "win32": + return DSHOW + return PULSE def list_sources(): diff --git a/cleanup.py b/cleanup.py index b6485b3..d59eb36 100644 --- a/cleanup.py +++ b/cleanup.py @@ -199,6 +199,7 @@ def _output(cmd, timeout, service): cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except subprocess.TimeoutExpired: raise CleanupError(t("{service} did not finish within {seconds} seconds.", diff --git a/cli.py b/cli.py index 7992bd0..d282b2b 100644 --- a/cli.py +++ b/cli.py @@ -17,6 +17,7 @@ import json import os import shutil import signal +import subprocess import sys import time @@ -134,6 +135,16 @@ def launch_gui(verb=""): if verb: args.append(verb) args.append("--gui") + if sys.platform == "win32": + # execv on Windows mangles arguments with spaces and would leave the + # application tied to this console; start it detached instead. + subprocess.Popen( + args, + creationflags=(subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP), + close_fds=True, + ) + sys.exit(0) os.execv(sys.executable, args) @@ -297,6 +308,9 @@ def cmd_transcribe(opts): return fail(opts, f"no such file: {path}") conf = cfg.Config() + # This runs here rather than in the instance, so the local servers have to + # be handed their settings here too; the GUI does this at startup. + conf.apply_local() timestamps = opts.srt or _pick(opts.timestamps, conf["file_timestamps"]) worker = filetranscribe.FileTranscriber(conf) diff --git a/config.py b/config.py index c80bbfb..529c3c1 100644 --- a/config.py +++ b/config.py @@ -27,6 +27,12 @@ def _directories(platform=None): if (platform or sys.platform) == "darwin": support = pathlib.Path.home() / "Library/Application Support/Dikte" return support, support + if (platform or sys.platform) == "win32": + roaming = pathlib.Path( + os.environ.get("APPDATA") or pathlib.Path.home() / "AppData/Roaming") + local = pathlib.Path( + os.environ.get("LOCALAPPDATA") or pathlib.Path.home() / "AppData/Local") + return roaming / "Dikte", local / "Dikte" return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") diff --git a/dikte.py b/dikte.py index cda7f54..d76a82a 100755 --- a/dikte.py +++ b/dikte.py @@ -12,6 +12,7 @@ import json import os import signal import socket +import subprocess import sys import threading @@ -881,6 +882,17 @@ class Dikte: self.settings_window.close() self.shutdown() QLocalServer.removeServer(SERVER_NAME) + if sys.platform == "win32": + # execv on Windows mangles arguments with spaces and leaves the two + # processes sharing a console; a detached start does neither. + subprocess.Popen( + [sys.executable, ipc.script_path(), "--gui"], + creationflags=(subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP), + close_fds=True, + ) + QApplication.instance().quit() + return os.execv(sys.executable, [sys.executable, ipc.script_path(), "--gui"]) def shutdown(self): @@ -951,7 +963,11 @@ def install_signal_handlers(app): app.quit() # aboutToQuit runs shutdown() notifier.activated.connect(woken) - for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + # SIGHUP does not exist on Windows, and neither does a session to hang up. + signals = [signal.SIGINT, signal.SIGTERM] + if hasattr(signal, "SIGHUP"): + signals.append(signal.SIGHUP) + for sig in signals: # A handler that does nothing, so that the default action, stopping the # process where it stands, is replaced by the wakeup above. signal.signal(sig, lambda *_: None) diff --git a/filetranscribe.py b/filetranscribe.py index 4c8b4b7..d6401b4 100644 --- a/filetranscribe.py +++ b/filetranscribe.py @@ -283,6 +283,7 @@ def _ffmpeg(args, out, aborter=None): ["ffmpeg", "-nostdin", "-y", *args], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) # A two hour film is a minute of ffmpeg, which is a minute of a Stop button # doing nothing unless the abort reaches the process itself. diff --git a/ggml.py b/ggml.py index 5ba85a7..890b641 100644 --- a/ggml.py +++ b/ggml.py @@ -43,6 +43,7 @@ import threading import time import urllib.error import urllib.request +import zipfile import hub from i18n import t @@ -51,8 +52,15 @@ HOST = "127.0.0.1" # The path api.py asks for, so its URL and the server's line up. INFERENCE_PATH = "/v1/audio/transcriptions" -DATA_DIR = (pathlib.Path(os.environ.get("XDG_DATA_HOME") - or os.path.expanduser("~/.local/share")) / "dikte") +def _data_dir(): + if sys.platform == "win32": + return pathlib.Path(os.environ.get("LOCALAPPDATA") + or os.path.expanduser("~/AppData/Local")) / "Dikte" + return pathlib.Path(os.environ.get("XDG_DATA_HOME") + or os.path.expanduser("~/.local/share")) / "dikte" + + +DATA_DIR = _data_dir() BIN_DIR = DATA_DIR / "bin" MODELS_DIR = DATA_DIR / "models" @@ -151,11 +159,14 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True try: with urllib.request.urlopen(request, timeout=60) as response: total = int(response.headers.get("Content-Length") or item.size or 0) + # Windows refuses to delete a file that is open, so nothing is + # unlinked until the handle is closed again. + stopped = overlong = False with open(part, "wb") as out: while True: if should_stop is not None and should_stop(): - part.unlink(missing_ok=True) - return False + stopped = True + break block = response.read(DOWNLOAD_CHUNK) if not block: break @@ -165,11 +176,17 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True # More than was announced: a body that does not end is the # one way this loop could run until the disk is full. if total and done > total: - part.unlink(missing_ok=True) - raise LocalError(t("{name} is longer than it said it " - "would be.", name=item.name)) + overlong = True + break if on_progress is not None: on_progress(done, total) + if stopped: + part.unlink(missing_ok=True) + return False + if overlong: + part.unlink(missing_ok=True) + raise LocalError(t("{name} is longer than it said it " + "would be.", name=item.name)) # A proxy notice or an error page that came back as 200 would otherwise # be renamed into place and only fail when something tries to read it. if total and done != total: @@ -215,9 +232,11 @@ def _has_vulkan(): llama.cpp publishes no CUDA build for Linux, so Vulkan is what a graphics card gets here. The build without it is smaller and runs on the CPU, and fetching the Vulkan one for a machine that cannot load it would only make - the download bigger. + the download bigger. Windows spells the loader vulkan-1.dll. """ - return bool(ctypes.util.find_library("vulkan")) + return bool(ctypes.util.find_library("vulkan") + or (sys.platform == "win32" + and ctypes.util.find_library("vulkan-1"))) def _wanted_assets(program): @@ -230,6 +249,16 @@ def _wanted_assets(program): arch = _arch() if sys.platform == "darwin": return () if program is WHISPER else (f"bin-macos-{arch}.tar.gz",) + if sys.platform == "win32": + if program is WHISPER: + # The BLAS build first: on a plain CPU it transcribes about twice + # as fast as the stock one, and it carries everything it needs. + # Full names, because "bin-x64.zip" alone would also match the + # CUDA archives, whichever the release happened to list first. + return ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip") + if _has_vulkan() and arch == "x64": + return ("bin-win-vulkan-x64.zip", f"bin-win-cpu-{arch}.zip") + return (f"bin-win-cpu-{arch}.zip",) if program is LLAMA and _has_vulkan(): return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz") return (f"bin-ubuntu-{arch}.tar.gz",) @@ -275,6 +304,11 @@ def system_program(program): return bool(shutil.which(program.binary)) +def _binary_file(program): + """What the program's file is called on disk here.""" + return f"{program.binary}.exe" if sys.platform == "win32" else program.binary + + def _find_binary(root, name): for path in sorted(pathlib.Path(root).rglob(name)): if path.is_file(): @@ -283,19 +317,24 @@ def _find_binary(root, name): def _extract(archive, into): - """Unpack a release tarball, refusing anything that reaches outside `into`. + """Unpack a release archive, refusing anything that reaches outside `into`. The archives lay their libraries next to their binaries and are linked with an $ORIGIN runpath, so a whole directory is what has to survive the trip and - the binary cannot be lifted out of it. + the binary cannot be lifted out of it. Linux and macOS releases come as + tarballs, Windows ones as zips; zipfile never writes outside its target. """ try: + if str(archive).endswith(".zip"): + with zipfile.ZipFile(archive) as bundle: + bundle.extractall(into) + return with tarfile.open(archive, "r:gz") as tar: try: tar.extractall(into, filter="data") except TypeError: # Python without the extraction filters tar.extractall(into) - except (tarfile.TarError, OSError) as exc: + except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc: raise LocalError(t("Could not unpack {name}: {error}", name=os.path.basename(str(archive)), error=exc)) from exc @@ -334,7 +373,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None, if not download(item, archive, on_progress, should_stop): return "" _extract(archive, into) - binary = _find_binary(into, program.binary) + binary = _find_binary(into, _binary_file(program)) if binary is None: raise LocalError(t("{name} was not in the download.", name=program.binary)) @@ -491,6 +530,26 @@ def _tail(path, lines=3): return " | ".join(found[-lines:]) +def _win_image_name(pid): + """The lower-cased file name of the process's executable, or ''.""" + import ctypes + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.restype = ctypes.c_void_p + kernel32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32] + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION + if not handle: + return "" + try: + buffer = ctypes.create_unicode_buffer(260) + size = ctypes.c_uint32(len(buffer)) + ok = kernel32.QueryFullProcessImageNameW( + ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size)) + return os.path.basename(buffer.value).lower() if ok else "" + finally: + kernel32.CloseHandle(handle) + + class Server: """One process, started when something needs it and stopped when nothing does. @@ -592,6 +651,8 @@ class Server: args + ["--host", HOST, "--port", str(port)], stdout=sink, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, + # No console window of its own on Windows. + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except OSError as exc: raise LocalError(t("Could not start {name}: {error}", @@ -689,8 +750,11 @@ class Server: number could belong to something else entirely, and killing it would be a good deal worse than the leak being cleaned up. The program name alone could be somebody else's copy; the name together with Dikte's own data - directory on the command line could not. + directory on the command line could not. Windows offers no command line + to read, so the executable's name is the whole of the answer there. """ + if sys.platform == "win32": + return _win_image_name(pid) == _binary_file(self.program).lower() try: blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes() except OSError: diff --git a/hotkey.py b/hotkey.py index e7e10ea..89a6898 100644 --- a/hotkey.py +++ b/hotkey.py @@ -418,12 +418,175 @@ def _carbon(): return carbon +# --- Windows: RegisterHotKey ------------------------------------------------ + +# Windows virtual-key codes: where a key sits, not what a layout prints on it. +WIN_KEYS = { + "space": 0x20, "tab": 0x09, "enter": 0x0D, "return": 0x0D, + "esc": 0x1B, "escape": 0x1B, "backspace": 0x08, "insert": 0x2D, + "delete": 0x2E, "home": 0x24, "end": 0x23, "pgup": 0x21, "pgdown": 0x22, + "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27, + **{str(digit): 0x30 + digit for digit in range(10)}, + **{chr(ord("a") + i): 0x41 + i for i in range(26)}, + **{f"f{n}": 0x6F + n for n in range(1, 13)}, +} +WIN_MODS = { + "alt": 0x0001, "ctrl": 0x0002, "control": 0x0002, "shift": 0x0004, + "meta": 0x0008, "super": 0x0008, "win": 0x0008, +} +WIN_MOD_NOREPEAT = 0x4000 # holding the combination fires it once +WM_HOTKEY = 0x0312 +WM_QUIT = 0x0012 + + +def _win_input(): + """user32 and kernel32, which is all the listener talks to. + + Loaded on the first start rather than at import: this module is read on + every system, and these two libraries exist on one of them. + """ + return ctypes.windll.user32, ctypes.windll.kernel32 + + +def parse_windows_shortcut(text): + """'Ctrl+Space' -> (2, 32), or (None, None) when unusable.""" + parts = [part.strip().lower() for part in str(text).split("+") if part.strip()] + modifiers, key = 0, None + for part in parts: + if part in WIN_MODS: + modifiers |= WIN_MODS[part] + elif key is None and part in WIN_KEYS: + key = WIN_KEYS[part] + else: + return None, None + if key is None: + return None, None + return modifiers, key + + +class WinHotkey(QObject): + """Catches global shortcuts through Windows' own hotkey service. + + RegisterHotKey asks for one combination rather than reading the keyboard, + so it needs no permission at all. Like Carbon's and unlike the evdev + listener it swallows the key: while Dikte holds a combination, nothing + else on the machine receives it. + + RegisterHotKey only fires on the thread that called it, so registration + and the message loop live together on one worker thread; start() hands the + bindings over and waits for it to report what Windows actually gave us. + """ + + triggered = pyqtSignal(str) # the name the binding was registered under + failed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._user32 = None + self._kernel32 = None + self._thread = None + self._thread_id = None + self._count = 0 + + @property + def running(self): + return self._count > 0 and self._thread is not None and self._thread.is_alive() + + def start(self, bindings): + """`bindings` is {name: 'Ctrl+Space'}; an empty combination is skipped.""" + self.stop() + try: + self._user32, self._kernel32 = _win_input() + except (AttributeError, OSError) as exc: + self.failed.emit(t("Could not reach the Windows shortcut service: " + "{error}", error=exc)) + return False + wanted = [] + for identifier, (name, shortcut) in enumerate(bindings.items(), 1): + if not shortcut: + continue + modifiers, key = parse_windows_shortcut(shortcut) + if key is None: + self.failed.emit( + t("Could not parse the shortcut: {shortcut}", shortcut=shortcut) + ) + continue + wanted.append((identifier, name, shortcut, modifiers, key)) + if not wanted: + return False + + ready = threading.Event() + outcome = {"count": 0, "thread_id": None} + self._thread = threading.Thread( + target=self._loop, args=(wanted, ready, outcome), daemon=True + ) + self._thread.start() + ready.wait(timeout=5) + self._thread_id = outcome["thread_id"] + self._count = outcome["count"] + if not self._count: + self._thread = None + return self._count > 0 + + def stop(self): + if self._thread and self._thread_id and self._user32: + self._user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) + self._thread.join(timeout=1.5) + self._thread = None + self._thread_id = None + self._count = 0 + _REGISTERED.clear() + + def _loop(self, wanted, ready, outcome): + import ctypes.wintypes + user32, kernel32 = self._user32, self._kernel32 + outcome["thread_id"] = kernel32.GetCurrentThreadId() + + # The message queue a PostThreadMessage needs only exists once the + # thread has asked for messages; peek once before reporting ready. + message = ctypes.wintypes.MSG() + user32.PeekMessageW(ctypes.byref(message), None, WM_QUIT, WM_QUIT, 0) + + names = {} + for identifier, name, shortcut, modifiers, key in wanted: + if user32.RegisterHotKey(None, identifier, + modifiers | WIN_MOD_NOREPEAT, key): + names[identifier] = name + spec = SHORTCUTS.get(name) + if spec: + _REGISTERED[spec.desktop_id] = shortcut + else: + # This is the conflict warning on Windows: there is no list to + # read beforehand, the answer comes from asking for the key. + self.failed.emit(t( + "Windows would not give Dikte {shortcut}; another " + "application already holds it.", shortcut=shortcut)) + outcome["count"] = len(names) + ready.set() + if not names: + return + + try: + while user32.GetMessageW(ctypes.byref(message), None, 0, 0) > 0: + if message.message == WM_HOTKEY: + name = names.get(int(message.wParam)) + if name: + self.triggered.emit(name) + finally: + for identifier in names: + user32.UnregisterHotKey(None, identifier) + + # --- the desktop's own shortcut ------------------------------------------- def _macos(): return sys.platform == "darwin" +def _windows(): + return sys.platform == "win32" + + def _gnome(): desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() return "gnome" in desktop and shutil.which("gsettings") is not None @@ -548,37 +711,44 @@ def gnome_shortcut_status(desktop_id=DESKTOP_ID): def listener(parent=None): """The thing that hears the key, for whichever system this is.""" - return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent) + if _macos(): + return CarbonHotkey(parent) + if _windows(): + return WinHotkey(parent) + return EvdevHotkey(parent) def valid_shortcut(text): """Whether this machine can bind the combination as it was typed.""" - parse = parse_macos_shortcut if _macos() else parse_shortcut - return parse(text)[1] is not None + if _macos(): + return parse_macos_shortcut(text)[1] is not None + if _windows(): + return parse_windows_shortcut(text)[1] is not None + return parse_shortcut(text)[1] is not None def installs_shortcuts(): """Whether this system keeps a shortcut registry to write into. KDE and GNOME do, and something outside Dikte reads it, so the combination - survives Dikte being closed. macOS does not: there is nothing to install, - nothing to remove, and Settings should not offer either. + survives Dikte being closed. macOS and Windows do not: there is nothing to + install, nothing to remove, and Settings should not offer either. """ - return not _macos() + return not _macos() and not _windows() def shortcut_needs_restart(): """Whether an installed shortcut waits for the next login before it works. KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding - up as it is written, and macOS never had one to write. + up as it is written, and macOS and Windows never had one to write. """ - return not _macos() and not _gnome() + return not _macos() and not _windows() and not _gnome() def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", desktop_id=DESKTOP_ID): - if _macos(): + if _macos() or _windows(): _REGISTERED[desktop_id] = shortcut return True, t( "Shortcut saved: {shortcut}\nDikte holds this one itself while it " @@ -591,7 +761,7 @@ def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", def remove_shortcut(desktop_id=DESKTOP_ID): - if _macos(): + if _macos() or _windows(): _REGISTERED.pop(desktop_id, None) elif _gnome(): remove_gnome_shortcut(desktop_id) @@ -600,7 +770,7 @@ def remove_shortcut(desktop_id=DESKTOP_ID): def shortcut_status(desktop_id=DESKTOP_ID): - if _macos(): + if _macos() or _windows(): return _REGISTERED.get(desktop_id) return (gnome_shortcut_status(desktop_id) if _gnome() else kde_shortcut_status(desktop_id)) @@ -609,6 +779,8 @@ def shortcut_status(desktop_id=DESKTOP_ID): def desktop_name(): if _macos(): return "macOS" + if _windows(): + return "Windows" return "GNOME" if _gnome() else "KDE" @@ -693,9 +865,9 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): """Names of other KDE entries bound to the same combination.""" - if _macos(): - # There is no list to read: macOS answers the question by refusing the - # registration, which CarbonHotkey reports when it asks for the key. + if _macos() or _windows(): + # There is no list to read: both answer the question by refusing the + # registration, which their listeners report when they ask for the key. return [] try: text = SHORTCUTS_FILE.read_text(encoding="utf-8") diff --git a/i18n.py b/i18n.py index 436f917..761416d 100644 --- a/i18n.py +++ b/i18n.py @@ -106,6 +106,10 @@ TR = { "Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.", "ffmpeg not found. Install it with: brew install ffmpeg": "ffmpeg bulunamadı. Şununla kur: brew install ffmpeg", + "ffmpeg or a microphone was not found. Install ffmpeg with: " + "winget install Gyan.FFmpeg": + "ffmpeg ya da bir mikrofon bulunamadı. ffmpeg'i şununla kur: " + "winget install Gyan.FFmpeg", "Audio recorder stopped before receiving sound: {error}": "Ses kayıt aracı veri alamadan kapandı: {error}", "Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}", @@ -355,6 +359,12 @@ TR = { "macOS would not give Dikte {shortcut}; another application already holds it.": "macOS {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama " "onu şimdiden tutuyor.", + "Could not reach the Windows shortcut service: {error}": + "Windows kısayol servisine ulaşılamadı: {error}", + "Windows would not give Dikte {shortcut}; another application already " + "holds it.": + "Windows {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama " + "onu şimdiden tutuyor.", "Cannot read /dev/input. Your user needs to be in the 'input' group:\n" " sudo usermod -aG input $USER (then log out and back in)": "/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n" diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..cbd86de --- /dev/null +++ b/install.ps1 @@ -0,0 +1,74 @@ +# Dikte'yi bu Windows kullanicisi icin kurar: Baslat Menusu kisayolu, istege +# bagli otomatik baslangic ve her yerden calisan bir `dikte` komutu. +# +# powershell -ExecutionPolicy Bypass -File install.ps1 # kur +# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + oturum acilisinda baslat +# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # kaldir +param( + [switch]$Autostart, + [switch]$Uninstall +) + +$ErrorActionPreference = "Stop" +$repo = $PSScriptRoot +$startMenu = [Environment]::GetFolderPath("Programs") +$startup = [Environment]::GetFolderPath("Startup") +$shortcut = Join-Path $startMenu "Dikte.lnk" +$autostartLink = Join-Path $startup "Dikte.lnk" +# WindowsApps kullanici PATH'inde hazir durur; oraya birakilan dikte.cmd her +# terminalden calisir. +$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd" + +if ($Uninstall) { + foreach ($path in @($shortcut, $autostartLink, $cmdShim)) { + if (Test-Path $path) { Remove-Item $path -Force; Write-Host "silindi: $path" } + } + Write-Host "Dikte kisayollari kaldirildi. Depo klasoru ve ayarlar duruyor." + exit 0 +} + +# --- gereksinimler ---------------------------------------------------------- +$python = Get-Command python -ErrorAction SilentlyContinue +if (-not $python) { + Write-Error "Python bulunamadi. Kurun: winget install Python.Python.3.12" +} +$version = & python -c "import sys; print('%d.%d' % sys.version_info[:2])" +if ([version]$version -lt [version]"3.11") { + Write-Error "Python 3.11+ gerekli, bulunan: $version" +} +& python -c "import PyQt6.QtWidgets" 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "PyQt6 kuruluyor..." + & python -m pip install PyQt6 + if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 kurulamadi." } +} +if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + Write-Warning "ffmpeg bulunamadi. Ses kaydi icin gerekli: winget install Gyan.FFmpeg" +} + +# pythonw.exe konsol penceresi acmadan calistirir. +$pythonw = Join-Path (Split-Path $python.Source) "pythonw.exe" +if (-not (Test-Path $pythonw)) { $pythonw = $python.Source } + +# --- Baslat Menusu kisayolu ------------------------------------------------- +$shell = New-Object -ComObject WScript.Shell +foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) { + $link = $shell.CreateShortcut($path) + $link.TargetPath = $pythonw + $link.Arguments = "`"$repo\dikte.py`" --gui" + $link.WorkingDirectory = $repo + $link.Description = "Dikte: sesli dikte" + $link.Save() + Write-Host "kisayol: $path" +} + +# --- dikte komutu ----------------------------------------------------------- +$shimDir = Split-Path $cmdShim +if (Test-Path $shimDir) { + "@echo off`r`npython `"$repo\dikte.py`" %*" | Out-File $cmdShim -Encoding ascii + Write-Host "komut: dikte ($cmdShim)" +} + +Write-Host "" +Write-Host "Kurulum tamam. Baslat Menusu'nden 'Dikte' ile ya da terminalden 'dikte' yazarak baslatin." +Write-Host "Ilk acilista Ayarlar penceresi acilir: oradan model indirin ve kisayolu secin (varsayilan Ctrl+Space)." diff --git a/ipc.py b/ipc.py index 541e067..659811c 100644 --- a/ipc.py +++ b/ipc.py @@ -14,7 +14,9 @@ import sys from PyQt6.QtNetwork import QLocalSocket -SERVER_NAME = "dikte-" + str(os.getuid()) +SERVER_NAME = "dikte-" + ( + str(os.getuid()) if hasattr(os, "getuid") + else os.environ.get("USERNAME", "user")) # Long enough for a process that is already running to answer, short enough that # "nothing is running" is not a noticeable pause in front of a key press. diff --git a/overlay.py b/overlay.py index f843306..b0cc39e 100644 --- a/overlay.py +++ b/overlay.py @@ -61,9 +61,10 @@ class Overlay(QWidget): | Qt.WindowType.Tool | Qt.WindowType.WindowDoesNotAcceptFocus ) - if sys.platform != "darwin": + if sys.platform not in ("darwin", "win32"): # It is the window manager that would otherwise move this out of - # the corner. macOS has no such hint, and Qt warns about it. + # the corner. macOS has no such hint, and Qt warns about it; + # Windows places tool windows where they ask to be anyway. flags |= Qt.WindowType.X11BypassWindowManagerHint # One that can be clicked away has to receive the click, which means it # also swallows one aimed at whatever is underneath it. The rest stay diff --git a/paste.py b/paste.py index 030f203..1ca172b 100644 --- a/paste.py +++ b/paste.py @@ -278,6 +278,159 @@ def _macos_press(shortcut, delay): core.CFRelease(up) +def _win_keys(shortcut): + """'Ctrl+V' -> [0x11, 0x56]: Windows virtual-key codes, modifiers first.""" + codes = [] + for key in _keys(shortcut): + if key not in WIN_KEYCODES: + raise PasteError(t("Unknown key: {key}", key=key)) + codes.append(WIN_KEYCODES[key]) + return codes + + +# Windows virtual-key codes (winuser.h). Like Apple's, they say where the key +# sits rather than what a layout prints on it. +WIN_KEYCODES = { + "ctrl": 0x11, "control": 0x11, "shift": 0x10, "alt": 0x12, + "super": 0x5B, "meta": 0x5B, + "v": 0x56, "insert": 0x2D, "enter": 0x0D, "return": 0x0D, +} +_WIN_KEYUP = 0x0002 # KEYEVENTF_KEYUP +_WIN_CF_UNICODETEXT = 13 # what the clipboard calls UTF-16 text +_WIN_GMEM_MOVEABLE = 0x0002 + + +@functools.lru_cache(maxsize=1) +def _win_api(): + """user32 and kernel32 with their prototypes spelled out. + + The default return type is a 32-bit int, which silently truncates the + 64-bit handles and pointers every one of these calls trades in. + """ + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + user32.OpenClipboard.argtypes = [ctypes.c_void_p] + user32.GetClipboardData.restype = ctypes.c_void_p + user32.GetClipboardData.argtypes = [ctypes.c_uint] + user32.SetClipboardData.restype = ctypes.c_void_p + user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p] + kernel32.GlobalAlloc.restype = ctypes.c_void_p + kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t] + kernel32.GlobalLock.restype = ctypes.c_void_p + kernel32.GlobalLock.argtypes = [ctypes.c_void_p] + kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p] + kernel32.GlobalFree.argtypes = [ctypes.c_void_p] + return user32, kernel32 + + +def _win_error(): + """GetLastError where it exists, so the failure paths run under any test.""" + return getattr(ctypes, "get_last_error", lambda: 0)() + + +def _win_open_clipboard(user32): + """The clipboard is a lock another program may hold for a moment.""" + for _ in range(10): + if user32.OpenClipboard(None): + return True + time.sleep(0.01) + return False + + +def _win_read_text(): + """The clipboard's text, '' when it holds none, None when it cannot be read.""" + user32, kernel32 = _win_api() + if not _win_open_clipboard(user32): + return None + try: + handle = user32.GetClipboardData(_WIN_CF_UNICODETEXT) + if not handle: + return "" + pointer = kernel32.GlobalLock(handle) + if not pointer: + return None + try: + return ctypes.wstring_at(pointer) + finally: + kernel32.GlobalUnlock(handle) + finally: + user32.CloseClipboard() + + +def _win_write_text(text): + user32, kernel32 = _win_api() + payload = str(text).encode("utf-16-le") + b"\x00\x00" + if not _win_open_clipboard(user32): + raise PasteError(t("Could not copy to clipboard: {error}", + error="the clipboard is held by another program")) + handle = None + try: + user32.EmptyClipboard() + handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload)) + pointer = kernel32.GlobalLock(handle) if handle else None + if not pointer: + raise PasteError(t("Could not copy to clipboard: {error}", + error="out of memory")) + ctypes.memmove(pointer, payload, len(payload)) + kernel32.GlobalUnlock(handle) + if not user32.SetClipboardData(_WIN_CF_UNICODETEXT, handle): + raise PasteError(t("Could not copy to clipboard: {error}", + error=f"error {_win_error()}")) + handle = None # the clipboard owns it now + finally: + if handle: + kernel32.GlobalFree(handle) + user32.CloseClipboard() + + +class _WinKeybdInput(ctypes.Structure): + _fields_ = [("wVk", ctypes.c_ushort), ("wScan", ctypes.c_ushort), + ("dwFlags", ctypes.c_ulong), ("time", ctypes.c_ulong), + ("dwExtraInfo", ctypes.c_size_t)] + + +class _WinMouseInput(ctypes.Structure): + _fields_ = [("dx", ctypes.c_long), ("dy", ctypes.c_long), + ("mouseData", ctypes.c_ulong), ("dwFlags", ctypes.c_ulong), + ("time", ctypes.c_ulong), ("dwExtraInfo", ctypes.c_size_t)] + + +class _WinInputUnion(ctypes.Union): + _fields_ = [("mi", _WinMouseInput), ("ki", _WinKeybdInput)] + + +class _WinInput(ctypes.Structure): + # The union carries the mouse shape too: SendInput sizes its argument by + # the biggest member whether or not it is the one being sent. + _fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)] + + +def _win_press(shortcut, delay): + """Post the presses and releases straight into the input queue. + + No permission stands in front of SendInput the way Accessibility does on + macOS: whatever window has focus receives the combination. + """ + codes = _win_keys(shortcut) + user32, _ = _win_api() + time.sleep(delay) # let the selection settle and focus come back + + events = ([(code, 0) for code in codes] + + [(code, _WIN_KEYUP) for code in reversed(codes)]) + inputs = (_WinInput * len(events))() + for entry, (code, flags) in zip(inputs, events): + entry.type = 1 # INPUT_KEYBOARD + entry.union.ki = _WinKeybdInput(code, 0, flags, 0, 0) + sent = user32.SendInput(len(inputs), inputs, ctypes.sizeof(_WinInput)) + if sent != len(inputs): + raise PasteError(t("Could not run {tool}: {error}", tool="SendInput", + error=f"error {_win_error()}")) + + +def _win_ready(): + return True + + # --- which of them is here ------------------------------------------------- Desktop = collections.namedtuple( @@ -310,6 +463,17 @@ X11 = Desktop( **_program_keyboard("xdotool", _xdotool_command), ) +WINDOWS = Desktop( + clipboard="", # no program: both directions are calls into the system + packages="", + read_command=[], + copy_command=[], + shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"], + keyboard="", + ready=_win_ready, + press=_win_press, +) + MACOS = Desktop( clipboard="pbcopy", packages="", # both are part of macOS; there is nothing to install @@ -331,6 +495,8 @@ def desktop(): """ if sys.platform == "darwin": return MACOS + if sys.platform == "win32": + return WINDOWS if os.environ.get("XDG_SESSION_TYPE") == "x11": return X11 if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): @@ -375,6 +541,9 @@ def _macos_restore(snapshot): def read_clipboard(): here = desktop() + if here is WINDOWS: + text = _win_read_text() + return None if text is None else text.encode("utf-8") if here is MACOS and shutil.which("osascript"): snapshot = _macos_snapshot() if snapshot is not None: @@ -402,6 +571,9 @@ def _run_copy(payload): def copy(text): here = desktop() + if here is WINDOWS: + _win_write_text(text) + return if not shutil.which(here.clipboard): raise PasteError( t("{tool} not found. Install {packages}.", @@ -421,7 +593,15 @@ def copy_bytes(data): if isinstance(data, _MAC_SNAPSHOT): _macos_restore(data) return - if data is None or not shutil.which(desktop().clipboard): + if data is None: + return + if desktop() is WINDOWS: + try: + _win_write_text(data.decode("utf-8", "replace")) + except PasteError: + pass + return + if not shutil.which(desktop().clipboard): return try: _run_copy(data) diff --git a/tests/test_audio.py b/tests/test_audio.py index 22d4f2a..0f9582f 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -606,5 +606,83 @@ class MacRecordingCommand(OnMacOS, DikteTest): self.assertFalse(recorder.active) +class OnWindows: + """A test that runs as if the machine ran Windows.""" + + def setUp(self): + super().setUp() + self.enterContext(mock.patch.object(sys, "platform", "win32")) + + +class WindowsDevices(OnWindows, DikteTest): + """The one ffmpeg listing the device questions are answered from. + + dshow names devices rather than numbering them, and the names carry + whatever alphabet the machine speaks, so the listing here does too. + """ + + LISTING = ( + '[dshow @ 0000020c] "Integrated Camera" (video)\n' + '[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n' + '[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n' + '[dshow @ 0000020c] Alternative name "@device_cm_{33D9A762}...."\n' + '[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n' + "dummy: Immediate exit requested\n" + ).encode("utf-8") + + @contextlib.contextmanager + def listing(self, stderr=None, tools=("ffmpeg",)): + completed = FakeCompleted( + returncode=1, stderr=self.LISTING if stderr is None else stderr) + with only_these_tools(*tools), \ + mock.patch.object(subprocess, "run", return_value=completed): + yield + + def test_windows_records_through_dshow(self): + self.assertIs(audio.sound(), audio.DSHOW) + + def test_the_audio_lines_are_the_only_ones_read(self): + with self.listing(): + self.assertEqual(audio.list_sources(), [ + ("Mikrofon Dizisi (Intel Smart Sound)", + "Mikrofon Dizisi (Intel Smart Sound)"), + ("Kulaklık (Soundcore Life Q30)", + "Kulaklık (Soundcore Life Q30)"), + ]) + + def test_no_ffmpeg_installed(self): + with only_these_tools(): + self.assertEqual(audio.list_sources(), []) + self.assertEqual(audio.recording_command(), []) + + def test_the_name_is_what_the_recorder_is_given_back(self): + with self.listing(): + cmd = audio.recording_command("Kulaklık (Soundcore Life Q30)") + self.assertEqual(cmd[cmd.index("-f") + 1], "dshow") + self.assertIn("audio=Kulaklık (Soundcore Life Q30)", cmd) + + def test_no_microphone_named_means_the_first_one_listed(self): + """dshow has no default device for an empty target to mean.""" + with self.listing(): + self.assertIn("audio=Mikrofon Dizisi (Intel Smart Sound)", + audio.recording_command()) + + def test_a_machine_with_no_microphone_at_all(self): + with self.listing(stderr=b'[dshow @ 0] "Integrated Camera" (video)\n'): + self.assertEqual(audio.recording_command(), []) + + def test_an_ffmpeg_that_will_not_run(self): + with only_these_tools("ffmpeg"), \ + mock.patch.object(subprocess, "run", side_effect=OSError("nope")): + self.assertEqual(audio.list_sources(), []) + + def test_nothing_offers_the_far_side_of_a_meeting(self): + """What the speakers play is not a capture device Windows hands out.""" + with self.listing(): + self.assertEqual(audio.list_monitors(), []) + self.assertEqual(audio.default_monitor(), "") + self.assertEqual(audio.meeting_command("mic", "sys"), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 9547df2..af42b2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,7 @@ from unittest import mock import cli import config as cfg +import ggml import hotkey import ipc from tests.support import DikteTest, fake_urlopen @@ -564,5 +565,27 @@ class Replies(DikteTest): self.assertEqual(cli.run(["cancel"]), 0) +class TranscribeRunsHere(DikteTest): + """`dikte transcribe` runs in this process, not in the instance.""" + + def test_the_local_servers_are_handed_the_settings_first(self): + # The GUI does this at startup; a CLI run has no GUI to have done it, + # and without it the whisper server holds an empty model name. + wav = self.path("clip.wav") + wav.write_bytes(b"RIFF not really audio") + self.write_config({"local_model": "ggml-base.bin"}) + self.addCleanup(ggml.whisper.configure, + model="", threads=0, gpu=True, binary="") + + opts = cli.build_parser().parse_args(["transcribe", str(wav)]) + with mock.patch.object(cli.filetranscribe, "FileTranscriber"), \ + mock.patch.object(cli, "_headless", + return_value={"error": "stopped"}), \ + captured(): + cli.cmd_transcribe(opts) + + self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index 31e153c..fec1417 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,6 +8,7 @@ config and now shadows the default. import json import os +import sys import unittest from unittest import mock @@ -89,6 +90,8 @@ class Saving(DikteTest): cfg.Config().save() self.assertTrue(cfg.CONFIG_FILE.exists()) + @unittest.skipIf(sys.platform == "win32", + "NTFS access is decided by ACLs, not by the mode bits") def test_the_file_is_readable_by_nobody_else(self): """It holds two API keys.""" cfg.Config().save() @@ -467,25 +470,33 @@ class Directories(unittest.TestCase): with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", "XDG_DATA_HOME": "/d"}): config_dir, data_dir = cfg._directories("linux") - self.assertEqual(str(config_dir), "/c/dikte") - self.assertEqual(str(data_dir), "/d/dikte") + self.assertEqual(config_dir.as_posix(), "/c/dikte") + self.assertEqual(data_dir.as_posix(), "/d/dikte") def test_linux_without_the_variables_set(self): with mock.patch.dict(os.environ, {}, clear=True): config_dir, data_dir = cfg._directories("linux") - self.assertTrue(str(config_dir).endswith("/.config/dikte")) - self.assertTrue(str(data_dir).endswith("/.local/share/dikte")) + self.assertTrue(config_dir.as_posix().endswith("/.config/dikte")) + self.assertTrue(data_dir.as_posix().endswith("/.local/share/dikte")) def test_a_mac_keeps_both_in_application_support(self): config_dir, data_dir = cfg._directories("darwin") self.assertEqual(config_dir, data_dir) - self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte")) + self.assertTrue(config_dir.as_posix() + .endswith("/Library/Application Support/Dikte")) def test_a_mac_does_not_read_the_xdg_variables(self): """A Mac with them set from some other tool still stores in one place.""" with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): config_dir, _ = cfg._directories("darwin") - self.assertNotIn("/c", str(config_dir)) + self.assertNotIn("/c", config_dir.as_posix()) + + def test_windows_keeps_settings_and_data_apart(self): + with mock.patch.dict(os.environ, {"APPDATA": r"C:\roam", + "LOCALAPPDATA": r"C:\local"}): + config_dir, data_dir = cfg._directories("win32") + self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte") + self.assertEqual(data_dir.as_posix(), "C:/local/Dikte") if __name__ == "__main__": @@ -519,6 +530,9 @@ class ReadyToRun(DikteTest): def setUp(self): super().setUp() self.patch_attr(ggml, "MODELS_DIR", self.path("models")) + # A machine Dikte is actually installed on would otherwise answer for + # the "missing program" below through the real install record. + self.patch_attr(ggml, "BIN_DIR", self.path("bin")) def install(self, name): path = ggml.whisper_model_path(name) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 161e688..2e3075f 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -15,6 +15,7 @@ import tarfile import textwrap import threading import time +import zipfile from unittest import mock import ggml @@ -77,6 +78,15 @@ def tarball(entries): return buf.getvalue() +def zipball(entries): + """A .zip laid out the way the Windows releases are.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as bundle: + for name, content in entries.items(): + bundle.writestr(name, content) + return buf.getvalue() + + class Local(DikteTest): """A test with its own bin, models and cache directories.""" @@ -681,3 +691,78 @@ class Sizes(DikteTest): self.assertEqual(ggml.human_size(512), "512 B") self.assertEqual(ggml.human_size(574041195), "547.4 MB") self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB") + + +# --- Windows ---------------------------------------------------------------- + + +class WindowsAssets(Local): + """Which archive a Windows machine is handed.""" + + def setUp(self): + super().setUp() + self.patch_attr(sys, "platform", "win32") + self.patch_attr(ggml, "_arch", lambda: "x64") + + def test_whisper_prefers_the_blas_build(self): + # On a plain CPU it transcribes about twice as fast as the stock one. + self.assertEqual(ggml._wanted_assets(ggml.WHISPER), + ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip")) + + def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self): + self.patch_attr(ggml, "_has_vulkan", lambda: True) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), + ("bin-win-vulkan-x64.zip", "bin-win-cpu-x64.zip")) + + def test_llama_falls_back_to_the_cpu_build_without_one(self): + self.patch_attr(ggml, "_has_vulkan", lambda: False) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), + ("bin-win-cpu-x64.zip",)) + + def test_an_arm_machine_is_not_handed_the_x64_build(self): + self.patch_attr(ggml, "_arch", lambda: "arm64") + self.patch_attr(ggml, "_has_vulkan", lambda: True) + self.assertEqual(ggml._wanted_assets(ggml.LLAMA), + ("bin-win-cpu-arm64.zip",)) + + +class InstallOnWindows(Local): + """The Windows releases are zips, and the binary carries .exe.""" + + def setUp(self): + super().setUp() + self.patch_attr(sys, "platform", "win32") + self.patch_attr(ggml, "_arch", lambda: "x64") + self.archive = zipball({ + "Release/whisper-server.exe": b"MZ not really a program", + "Release/whisper.dll": b"not really a library", + }) + + def release(self, *names): + digest = hashlib.sha256(self.archive) + return {"tag_name": "v1.9.1", "assets": [ + {"name": name, "browser_download_url": f"https://example.invalid/{name}", + "size": 10, "digest": "sha256:" + digest.hexdigest()} + for name in names]} + + def test_the_zip_lands_and_the_exe_inside_it_is_found(self): + with serving(self.release("whisper-blas-bin-x64.zip"), self.archive): + path = ggml.install_program(ggml.WHISPER) + self.assertTrue(path.endswith("whisper-server.exe")) + self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), + "whisper.dll"))) + self.assertEqual(ggml.installed_program(ggml.WHISPER), path) + + def test_the_blas_build_is_the_one_fetched_when_both_are_offered(self): + listing = self.release("whisper-bin-x64.zip", "whisper-blas-bin-x64.zip") + with serving(listing, self.archive) as calls: + ggml.install_program(ggml.WHISPER) + urls = [call.args[0].full_url for call in calls.call_args_list] + self.assertTrue(urls[1].endswith("whisper-blas-bin-x64.zip")) + + def test_a_release_with_nothing_for_windows_says_so(self): + with fake_urlopen(json_body(self.release("whisper-bin-ubuntu-x64.tar.gz"))): + with self.assertRaises(ggml.LocalError) as caught: + ggml.install_program(ggml.WHISPER) + self.assertIn("this machine", str(caught.exception)) diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index c6ec50f..d466b89 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -2,10 +2,14 @@ import contextlib import os +import queue import subprocess +import time import unittest from unittest import mock +from PyQt6.QtCore import Qt + import config as cfg import hotkey from tests.support import DikteTest, FakeCompleted, linux_only @@ -681,5 +685,204 @@ class MacChooser(DikteTest): self.assertFalse(hotkey.valid_shortcut("Cmd+Space")) +# --- Windows ---------------------------------------------------------------- + +class ParseWindowsShortcut(unittest.TestCase): + def test_the_default(self): + self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Space"), + (hotkey.WIN_MODS["ctrl"], 0x20)) + + def test_case_and_spacing_do_not_matter(self): + self.assertEqual(hotkey.parse_windows_shortcut(" ctrl + SPACE "), + hotkey.parse_windows_shortcut("Ctrl+Space")) + + def test_several_modifiers_are_one_number(self): + modifiers, key = hotkey.parse_windows_shortcut("Ctrl+Shift+M") + self.assertEqual(modifiers, + hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"]) + self.assertEqual(key, hotkey.WIN_KEYS["m"]) + + def test_the_synonyms_land_on_one_number(self): + for name in ("meta", "super", "win"): + with self.subTest(name=name): + self.assertEqual(hotkey.parse_windows_shortcut(f"{name}+space"), + (hotkey.WIN_MODS["win"], 0x20)) + self.assertEqual(hotkey.parse_windows_shortcut("Control+Space"), + hotkey.parse_windows_shortcut("Ctrl+Space")) + + def test_a_key_on_its_own(self): + self.assertEqual(hotkey.parse_windows_shortcut("F9"), + (0, hotkey.WIN_KEYS["f9"])) + + def test_modifiers_with_no_key(self): + self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Alt"), (None, None)) + + def test_a_key_nobody_mapped(self): + self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+F13"), (None, None)) + + def test_something_that_is_not_even_a_string(self): + self.assertEqual(hotkey.parse_windows_shortcut(None), (None, None)) + + +class FakeWinHotkeys: + """user32 and kernel32, as much of both as the listener calls. + + The message queue is a real queue: GetMessageW blocks on it the way the + real one blocks on the thread's, so the listener runs its actual loop and + a test presses the key by posting the message a press would. + """ + + def __init__(self): + self.registered = {} # identifier -> (modifiers, key) + self.refused = set() # (modifiers, key) another program holds + self.unregistered = [] + self.queue = queue.Queue() + + # --- user32 + def RegisterHotKey(self, hwnd, identifier, modifiers, key): + if (modifiers & ~hotkey.WIN_MOD_NOREPEAT, key) in self.refused: + return 0 + self.registered[identifier] = (modifiers, key) + return 1 + + def UnregisterHotKey(self, hwnd, identifier): + self.unregistered.append(identifier) + self.registered.pop(identifier, None) + return 1 + + def PeekMessageW(self, reference, hwnd, low, high, remove): + return 0 + + def GetMessageW(self, reference, hwnd, low, high): + kind, wparam = self.queue.get() + if kind == hotkey.WM_QUIT: + return 0 + message = reference._obj + message.message = kind + message.wParam = wparam + return 1 + + def PostThreadMessageW(self, thread_id, message, wparam, lparam): + self.queue.put((message, wparam)) + return 1 + + # --- kernel32 + def GetCurrentThreadId(self): + return 1 + + # --- the keyboard + def press(self, identifier): + self.queue.put((hotkey.WM_HOTKEY, identifier)) + + +class WinListener(DikteTest): + """What the listener asks Windows for, without a Windows to ask.""" + + def setUp(self): + super().setUp() + self.api = FakeWinHotkeys() + self.patch_attr(hotkey, "_win_input", lambda: (self.api, self.api)) + self.addCleanup(hotkey._REGISTERED.clear) + self.listener = hotkey.WinHotkey() + self.addCleanup(self.listener.stop) + self.failures = [] + # Direct, because the emits come from the listener's own thread and + # there is no event loop here to carry a queued one across. + self.listener.failed.connect(self.failures.append, + Qt.ConnectionType.DirectConnection) + + @staticmethod + def settles(seen, count=1): + """The signals arrive from the listener's own thread, not this one.""" + deadline = time.monotonic() + 2 + while len(seen) < count and time.monotonic() < deadline: + time.sleep(0.01) + return seen + + def test_every_binding_is_registered_with_its_modifiers(self): + self.assertTrue(self.listener.start({"toggle": "Ctrl+Space", + "cancel": "Ctrl+Shift+Space"})) + norepeat = hotkey.WIN_MOD_NOREPEAT + self.assertEqual(self.api.registered, { + 1: (hotkey.WIN_MODS["ctrl"] | norepeat, 0x20), + 2: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"] | norepeat, 0x20), + }) + + def test_what_landed_is_what_the_status_line_shows(self): + self.listener.start({"toggle": "Ctrl+Space"}) + self.assertEqual(hotkey._REGISTERED, + {hotkey.DESKTOP_ID: "Ctrl+Space"}) + + def test_a_press_arrives_under_the_name_it_was_registered_as(self): + seen = [] + self.listener.triggered.connect(seen.append, + Qt.ConnectionType.DirectConnection) + self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"}) + self.api.press(2) + self.assertEqual(self.settles(seen), ["cancel"]) + + def test_a_held_combination_is_reported_and_the_rest_still_land(self): + self.api.refused = {(hotkey.WIN_MODS["ctrl"], 0x20)} + started = self.listener.start({"toggle": "Ctrl+Space", + "cancel": "Ctrl+Shift+Space"}) + self.assertTrue(started) + self.assertIn("Ctrl+Space", self.settles(self.failures)[0]) + self.assertEqual(list(self.api.registered), [2]) + + def test_an_unparsable_binding_is_reported(self): + self.assertFalse(self.listener.start({"toggle": "Ctrl+F13"})) + self.assertIn("Ctrl+F13", self.failures[0]) + + def test_nothing_but_empty_bindings_does_not_start(self): + self.assertFalse(self.listener.start({"toggle": "", "cancel": ""})) + self.assertFalse(self.listener.running) + + def test_stop_lets_go_of_everything(self): + self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"}) + self.listener.stop() + self.assertEqual(self.api.registered, {}) + self.assertEqual(hotkey._REGISTERED, {}) + self.assertFalse(self.listener.running) + + def test_a_second_start_is_a_clean_slate(self): + self.listener.start({"toggle": "Ctrl+Space"}) + self.assertTrue(self.listener.start({"toggle": "Ctrl+Shift+Space"})) + self.assertEqual(self.api.registered, + {1: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"] + | hotkey.WIN_MOD_NOREPEAT, 0x20)}) + + +class WindowsChooser(DikteTest): + def setUp(self): + super().setUp() + self.enterContext(mock.patch.object(hotkey.sys, "platform", "win32")) + self.addCleanup(hotkey._REGISTERED.clear) + + def test_the_listener_is_the_windows_hotkey_service(self): + self.assertIsInstance(hotkey.listener(), hotkey.WinHotkey) + + def test_a_combination_is_checked_against_the_windows_table(self): + self.assertTrue(hotkey.valid_shortcut("Ctrl+Space")) + self.assertFalse(hotkey.valid_shortcut("Ctrl+F13")) + + def test_no_registry_to_write_into_and_no_restart_to_wait_for(self): + self.assertFalse(hotkey.installs_shortcuts()) + self.assertFalse(hotkey.shortcut_needs_restart()) + self.assertEqual(hotkey.desktop_name(), "Windows") + + def test_installing_records_it_rather_than_writing_anything(self): + with mock.patch.object(hotkey.subprocess, "run") as run: + ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + run.assert_not_called() + self.assertTrue(ok) + self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space") + hotkey.remove_shortcut() + self.assertIsNone(hotkey.shortcut_status()) + + def test_no_list_of_conflicts_to_read(self): + """Not even KDE's file, which a dual-boot home directory could hold.""" + self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_paste.py b/tests/test_paste.py index 4bfe99f..7e80c0e 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -13,6 +13,7 @@ is checked on a Mac and the macOS half on Linux, and a change to the chooser cannot quietly break the platform nobody is sitting at. """ +import ctypes import os import pathlib import subprocess @@ -56,6 +57,9 @@ class Chooser(DikteTest): def test_a_mac(self): self.assertIs(self.under("darwin"), paste.MACOS) + def test_windows(self): + self.assertIs(self.under("win32"), paste.WINDOWS) + def test_a_mac_running_an_x_server_is_still_a_mac(self): """XQuartz sets DISPLAY, and none of X's programs are what pastes here.""" self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS) @@ -429,5 +433,128 @@ class MacClipboardSnapshot(DikteTest): self.assertFalse(os.path.exists(directory)) +class FakeWin32: + """user32 and kernel32, as much of both as paste.py calls. + + The clipboard is a string held here. A read materialises it as this + machine's own wide characters, which is what wstring_at reads wherever the + test runs; a write arrives as the UTF-16 the real clipboard is handed, so + what the code sent is exactly what is checked. + """ + + def __init__(self): + self.text = None + self.buffers = {} + self.next_handle = 1 + self.pressed = [] # (virtual key, flags), in the order sent + self.send_result = None # None: report every event as delivered + + def _keep(self, buffer): + handle = self.next_handle + self.next_handle += 1 + self.buffers[handle] = buffer + return handle + + # --- user32 + def OpenClipboard(self, owner): + return 1 + + def CloseClipboard(self): + return 1 + + def EmptyClipboard(self): + self.text = None + return 1 + + def GetClipboardData(self, fmt): + if self.text is None: + return 0 + return self._keep(ctypes.create_unicode_buffer(self.text)) + + def SetClipboardData(self, fmt, handle): + raw = self.buffers[handle].raw + self.text = raw.decode("utf-16-le").split("\x00", 1)[0] + return handle + + def SendInput(self, count, inputs, size): + self.pressed.extend((entry.union.ki.wVk, entry.union.ki.dwFlags) + for entry in inputs) + return count if self.send_result is None else self.send_result + + # --- kernel32 + def GlobalAlloc(self, flags, size): + return self._keep(ctypes.create_string_buffer(size)) + + def GlobalLock(self, handle): + buffer = self.buffers.get(handle) + return ctypes.addressof(buffer) if buffer else 0 + + def GlobalUnlock(self, handle): + return 1 + + def GlobalFree(self, handle): + self.buffers.pop(handle, None) + return 1 + + +class Windows(Standing, DikteTest): + """Windows shells out to nothing: both halves are calls into the system.""" + + platform = "win32" + here = paste.WINDOWS + + def setUp(self): + super().setUp() + self.api = FakeWin32() + self.patch_attr(paste, "_win_api", lambda: (self.api, self.api)) + self.patch_attr(paste.time, "sleep", lambda seconds: None) + + def test_what_is_copied_is_what_reads_back(self): + paste.copy("ığüşöç İ") + self.assertEqual(paste.read_clipboard(), "ığüşöç İ".encode("utf-8")) + + def test_an_empty_clipboard_reads_as_empty_text(self): + self.assertEqual(paste.read_clipboard(), b"") + + def test_what_was_saved_goes_back_after_the_paste(self): + paste.copy("mine") + saved = paste.read_clipboard() + paste.copy("the dictation") + paste.copy_bytes(saved) + self.assertEqual(self.api.text, "mine") + + def test_readiness_asks_for_no_program_and_no_permission(self): + with only_these_tools(): + self.assertTrue(paste.paste_ready()) + + def test_the_keys_go_down_in_order_and_up_in_reverse(self): + paste.press("ctrl+v") + keyup = 0x0002 + self.assertEqual(self.api.pressed, + [(0x11, 0), (0x56, 0), (0x56, keyup), (0x11, keyup)]) + + def test_three_keys(self): + paste.press("ctrl+shift+v") + self.assertEqual([code for code, _ in self.api.pressed], + [0x11, 0x10, 0x56, 0x56, 0x10, 0x11]) + + def test_the_other_spellings_land_on_the_same_keys(self): + paste.press("super+enter") + first, self.api.pressed = self.api.pressed, [] + paste.press("meta+return") + self.assertEqual(self.api.pressed, first) + + def test_a_key_nobody_mapped_is_refused_before_anything_is_sent(self): + with self.assertRaises(paste.PasteError): + paste.press("ctrl+f13") + self.assertEqual(self.api.pressed, []) + + def test_a_press_the_system_did_not_take_says_so(self): + self.api.send_result = 0 + with self.assertRaises(paste.PasteError) as caught: + paste.press("ctrl+v") + self.assertIn("SendInput", str(caught.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 518166f..790390c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -15,6 +15,7 @@ from PyQt6.QtWidgets import QApplication, QMessageBox import cleanup import config as cfg +import ggml import hotkey import overlay as overlay_module import paste @@ -449,6 +450,13 @@ if __name__ == "__main__": class LocalModels(DikteTest): """The download boxes, without a network and without either program.""" + def setUp(self): + super().setUp() + # A machine Dikte is actually installed on would otherwise answer the + # "nothing can transcribe" question from its real binary and model. + self.patch_attr(ggml, "BIN_DIR", self.path("bin")) + self.patch_attr(ggml, "MODELS_DIR", self.path("models")) + def window(self, conf): window = settings_ui.SettingsWindow(conf) self.addCleanup(window.deleteLater) From 6b86696648695ac7ee2415f3693eb4986ff88a04 Mon Sep 17 00:00:00 2001 From: huseyin-emre-tigci Date: Fri, 14 Aug 2026 18:09:24 +0300 Subject: [PATCH 02/16] Spell the Windows paths with forward slashes in the directories test 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 --- tests/test_config.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index fec1417..7d0a031 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -492,8 +492,10 @@ class Directories(unittest.TestCase): self.assertNotIn("/c", config_dir.as_posix()) def test_windows_keeps_settings_and_data_apart(self): - with mock.patch.dict(os.environ, {"APPDATA": r"C:\roam", - "LOCALAPPDATA": r"C:\local"}): + # Forward slashes, because a backslash only separates on Windows and + # this test also runs on the Linux that checks the Windows half. + with mock.patch.dict(os.environ, {"APPDATA": "C:/roam", + "LOCALAPPDATA": "C:/local"}): config_dir, data_dir = cfg._directories("win32") self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte") self.assertEqual(data_dir.as_posix(), "C:/local/Dikte") From a6fab18a225e393c4a6ee4b9dddbc8f190ff4d8c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 09:50:43 +0300 Subject: [PATCH 03/16] Keep the meeting a quiet microphone gave us, and read both captures at once 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. --- audio.py | 180 +++++++++++++++++++++++++++++--------------- dikte.py | 6 ++ i18n.py | 10 ++- tests/test_audio.py | 112 ++++++++++++++++++++++----- 4 files changed, 225 insertions(+), 83 deletions(-) diff --git a/audio.py b/audio.py index bf15ff6..511bdf1 100644 --- a/audio.py +++ b/audio.py @@ -2,15 +2,16 @@ Dictation records one source. A meeting records two of them at once, the microphone and what comes out of the speakers, and for that it goes through -ffmpeg: one process reading both devices and merging them into the two channels -of a single stream, which is the only way the two stay aligned with each other -over an hour. +ffmpeg. PulseAudio hands both devices to a single process, which merges them +into the two channels of one stream and keeps them aligned itself. AVFoundation +cannot be asked the same: two of its sessions inside one process starve each +other, so a Mac captures each device on its own and the two mono streams are +interleaved here as they arrive. Which programs do the capturing is a property of the machine, not of the code above: PulseAudio or PipeWire on Linux, AVFoundation through ffmpeg on macOS. They are gathered into one group each near the bottom of this file, and a -chooser picks between them. macOS uses one ffmpeg process per AVFoundation -device: two AVFoundation sessions in one process silently starve one another. +chooser picks between them. """ import array @@ -18,6 +19,7 @@ import collections import json import math import os +import queue import re import shutil import signal @@ -39,6 +41,21 @@ CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000) MIN_FRAMES = int(RATE * 0.25) +# A capture process hands over a block every CHUNK_LATENCY_MS. One that has said +# nothing for this long has stopped rather than fallen behind, and the meeting +# ends and says so instead of sitting on a read that will never return. +STALL_SECONDS = 5.0 +# Room for a whole stall of the other stream, so the side still delivering is +# never the one left waiting. +QUEUE_BLOCKS = int(STALL_SECONDS * RATE / CHUNK_FRAMES) + 8 + +# Exact zeroes are not quiet, they are nothing: a microphone that is really in +# the room has a noise floor. This much of a recording that long means it handed +# nothing over, which is worth saying once the meeting is over and nothing can +# be done about it any more. +QUIET_MIC_SECONDS = 10 +QUIET_MIC_SHARE = 0.5 + class Recorder(QObject): """Runs the available sound-server recorder and reads raw PCM from stdout.""" @@ -193,17 +210,12 @@ def recording_command(target=""): def meeting_commands(mic_target, system_target): """The capture processes that produce one stereo meeting stream. - PulseAudio can keep both inputs in one ffmpeg process. AVFoundation cannot: - on a real Mac its two sessions silently starve the microphone, so each Mac - device is captured and clock-corrected by its own process. MeetingRecorder - interleaves those two mono streams after that. + One of them on PulseAudio, which merges both inputs itself; one per device + on a Mac, because two AVFoundation sessions in a process starve each other. + Which of the two it is stays in the table with everything else the sound + system decides, and MeetingRecorder reads the count rather than the machine. """ - if sound() is COREAUDIO: - return [ - _avfoundation_meeting_capture(mic_target), - _avfoundation_meeting_capture(system_target), - ] - return [sound().meeting(mic_target, system_target)] + return sound().meeting(mic_target, system_target) class AudioDeviceError(RuntimeError): @@ -223,11 +235,11 @@ class MeetingRecorder(QObject): levels = pyqtSignal(float, float) # mine, theirs stopped = pyqtSignal(str, float) # wav path, duration (s) died = pyqtSignal() # ffmpeg quit on its own + warned = pyqtSignal(str) # recorded, but something was wrong failed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) - self._proc = None self._procs = [] self._thread = None self._wav = None @@ -277,10 +289,10 @@ class MeetingRecorder(QObject): self._procs.append(subprocess.Popen( command, stdout=subprocess.PIPE, stderr=log, bufsize=0 )) - self._proc = self._procs[0] except (OSError, wave.Error) as exc: + # One of two capture processes may already be running, and a Mac + # left holding an open AVFoundation session records nothing else. self._terminate_processes() - self._proc = None self._procs = [] self._close_file() self._drop_log() @@ -326,13 +338,21 @@ class MeetingRecorder(QObject): pass def _pump_split(self): - left = self._procs[0].stdout - right = self._procs[1].stdout + # A reader thread per process. Taking turns on the two pipes from one + # thread would let a starved microphone hold up the far side too: its + # blocks would sit unread until the pipe filled and its ffmpeg stopped + # writing, and an hour of meeting would freeze with nothing said. Each + # stream is read as fast as it arrives, and a side that goes quiet for + # STALL_SECONDS ends the recording rather than hanging it. block = CHUNK_FRAMES * SAMPLE_WIDTH + streams = [queue.Queue(maxsize=QUEUE_BLOCKS) for _ in self._procs] + for proc, blocks in zip(self._procs, streams): + threading.Thread(target=_read_blocks, daemon=True, + args=(proc.stdout, blocks, block)).start() try: while True: - mine = _read_exact(left, block) - theirs = _read_exact(right, block) + mine = _next_block(streams[0]) + theirs = _next_block(streams[1]) if not mine or not theirs: break frames = min(len(mine), len(theirs)) // SAMPLE_WIDTH @@ -407,7 +427,6 @@ class MeetingRecorder(QObject): self._thread = None codes = [proc.poll() for proc in self._procs] code = next((value for value in codes if value), 0) - self._proc = None self._procs = [] self._close_file() return code @@ -422,7 +441,7 @@ class MeetingRecorder(QObject): pass def stop(self): - if not self._proc: + if not self._procs: return # The count is read after the join: the pump thread is still appending # the last blocks up to the moment it ends. @@ -446,21 +465,19 @@ class MeetingRecorder(QObject): if tail or code else t("Recording too short, speak for at least 0.3 s") ) return - if (self._split_inputs and frames >= RATE * 10 - and self._mic_zero_frames / frames > 0.5): - empty = round(self._mic_zero_frames / frames * 100) - self._drop_log() - try: - os.unlink(self._path) - except OSError: - pass - self.failed.emit(t( - "The macOS microphone stopped delivering audio ({percent}% was " - "empty). The unusable recording was discarded; reconnect the " - "device and try again.", percent=empty, - )) - return self._drop_log() + # A microphone that handed nothing over costs the left channel, and the + # recording is kept anyway: the right one is everyone else, and an hour + # of them is worth more than an empty channel costs. Only the split + # capture can starve a device this way; one ffmpeg reading both cannot. + if (self._split_inputs and frames >= RATE * QUIET_MIC_SECONDS + and self._mic_zero_frames / frames > QUIET_MIC_SHARE): + self.warned.emit(t( + "The microphone handed over almost nothing ({percent}% of the " + "recording was empty), so your own side of the meeting will be " + "mostly missing. Check the device before the next one.", + percent=round(self._mic_zero_frames / frames * 100), + )) self.stopped.emit(self._path, frames / RATE) def _drop_log(self): @@ -496,20 +513,40 @@ def stereo_levels(chunk): def interleave_mono(left, right): - """Two equally long mono-s16 buffers into one stereo-s16 buffer.""" - left_samples = array.array("h") - right_samples = array.array("h") - left_samples.frombytes(left[:len(left) - len(left) % SAMPLE_WIDTH]) - right_samples.frombytes(right[:len(right) - len(right) % SAMPLE_WIDTH]) + """Two mono-s16 buffers into one stereo-s16 buffer, the shorter one setting + the length.""" + left_samples, right_samples = _samples(left), _samples(right) frames = min(len(left_samples), len(right_samples)) - stereo_samples = array.array("h") - stereo_samples.extend( - sample for pair in zip(left_samples[:frames], right_samples[:frames]) - for sample in pair - ) + stereo_samples = array.array("h", bytes(frames * 2 * SAMPLE_WIDTH)) + stereo_samples[0::2] = left_samples[:frames] + stereo_samples[1::2] = right_samples[:frames] return stereo_samples.tobytes() +def _read_blocks(stream, blocks, size): + """One stream's blocks onto its queue, ending with the empty one. + + A queue that stays full is the pump having given up on this recording, and + then there is nobody left to hand anything to. + """ + try: + while True: + block = _read_exact(stream, size) + blocks.put(block, timeout=STALL_SECONDS) + if not block: + return + except (OSError, ValueError, queue.Full): + pass + + +def _next_block(blocks): + """The next block of a stream, empty once it ends or falls silent.""" + try: + return blocks.get(timeout=STALL_SECONDS) + except queue.Empty: + return b"" + + def _read_exact(stream, size): """Read one meter-sized block, tolerating short unbuffered pipe reads.""" out = bytearray() @@ -522,9 +559,13 @@ def _read_exact(stream, size): def _zero_samples(chunk): + return _samples(chunk).count(0) + + +def _samples(chunk): samples = array.array("h") samples.frombytes(chunk[:len(chunk) - len(chunk) % SAMPLE_WIDTH]) - return sum(sample == 0 for sample in samples) + return samples def _peak(samples): @@ -535,8 +576,9 @@ def _peak(samples): # --- the sound system, one group per machine ------------------------------- -# Both meeting commands merge the same way: each input down to mono at our own -# rate, then the two of them into the left and right of one stream. +# How the one PulseAudio process merges: each input down to mono at our own +# rate, then the two of them into the left and right of one stream. A Mac does +# the first half per process and the second half itself, in interleave_mono(). MERGE_FILTER = ( f"[0:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];" f"[1:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];" @@ -600,13 +642,14 @@ def _pw_record_raw_option(): def _pulse_meeting(mic_target, system_target): - return [ + """One process for both devices: PulseAudio keeps them aligned itself.""" + return [[ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default", "-f", "pulse", "-thread_queue_size", "4096", "-i", system_target, "-filter_complex", MERGE_FILTER, "-map", "[out]", "-f", "s16le", "-ar", str(RATE), "-", - ] + ]] def _pactl_sources(): @@ -674,8 +717,20 @@ def _avfoundation_record(target): ] -def _avfoundation_meeting_capture(target): - target = _resolve_avfoundation_target(target) +def _avfoundation_meeting(mic_target, system_target): + """A process per device, both names read off the same device listing. + + Asking ffmpeg what is plugged in costs a process of its own, and a listing + taken twice could renumber in between: the two targets have to be resolved + against the same one to name the same machine the user picked from. + """ + inputs = _avfoundation_inputs() + return [_avfoundation_meeting_capture(mic_target, inputs), + _avfoundation_meeting_capture(system_target, inputs)] + + +def _avfoundation_meeting_capture(target, inputs=None): + target = _resolve_avfoundation_target(target, inputs) return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-thread_queue_size", "4096", @@ -725,7 +780,7 @@ def _avfoundation_named_inputs(): for _index, description in _avfoundation_inputs()] -def _resolve_avfoundation_target(target): +def _resolve_avfoundation_target(target, inputs=None): """Resolve a stored device name to its current, positional ffmpeg index.""" if not target or target == "default": return "default" @@ -734,7 +789,9 @@ def _resolve_avfoundation_target(target): "The saved macOS audio device uses an old numeric index. Open " "Settings and select the device again before recording." )) - matches = [index for index, description in _avfoundation_inputs() + if inputs is None: + inputs = _avfoundation_inputs() + matches = [index for index, description in inputs if description == target] if not matches: raise AudioDeviceError(t( @@ -758,9 +815,10 @@ def _avfoundation_default_output(): Sound = collections.namedtuple( "Sound", - # How to capture one source and two at once, the two device lists, which - # device a meeting records the far side from, and what to say when the - # programs for any of it are not installed. + # How to capture one source and how to capture two at once, that one as the + # list of processes it takes, the two device lists, which device a meeting + # records the far side from, and what to say when the programs for any of + # it are not installed. "record meeting inputs outputs default_output missing", ) @@ -775,7 +833,7 @@ PULSE = Sound( COREAUDIO = Sound( record=_avfoundation_record, - meeting=None, # two separate capture processes; see meeting_commands() + meeting=_avfoundation_meeting, inputs=_avfoundation_named_inputs, # Every macOS capture device is offered as the far side of a meeting, the # loopback driver among them: there is no way to tell them apart, and an diff --git a/dikte.py b/dikte.py index cda7f54..3d72a09 100755 --- a/dikte.py +++ b/dikte.py @@ -123,6 +123,7 @@ class Dikte: self.meeting_recorder.levels.connect(self._on_meeting_levels) self.meeting_recorder.stopped.connect(self._on_meeting_recorded) self.meeting_recorder.died.connect(self._on_meeting_died) + self.meeting_recorder.warned.connect(self._on_meeting_warning) self.meeting_recorder.failed.connect(self._on_meeting_error) self.meetings.progress.connect(self._on_meeting_progress) self.meetings.finished.connect(self._on_meeting_finished) @@ -721,6 +722,11 @@ class Dikte: self._settle(MEETING, {"ok": False, "error": message}) self._on_error(message) + def _on_meeting_warning(self, message): + """It was recorded and it is being written up, but read this first.""" + self.tray.showMessage("Dikte", message, + QSystemTrayIcon.MessageIcon.Warning, 12000) + def _on_meeting_died(self): if self.meeting_state != M_RECORDING: return diff --git a/i18n.py b/i18n.py index 52ab91c..c1495e3 100644 --- a/i18n.py +++ b/i18n.py @@ -566,10 +566,12 @@ TR = { "duplicate or choose a different device.": "Birden fazla macOS ses aygıtının adı {device}. Aynı adlı aygıtlardan " "birini çıkar ya da başka bir aygıt seç.", - "The macOS microphone stopped delivering audio ({percent}% was empty). The " - "unusable recording was discarded; reconnect the device and try again.": - "macOS mikrofonu ses iletmeyi durdurdu (kaydın %{percent} kadarı boştu). " - "Kullanılamaz kayıt silindi; aygıtı yeniden bağlayıp tekrar dene.", + "The microphone handed over almost nothing ({percent}% of the recording was " + "empty), so your own side of the meeting will be mostly missing. Check the " + "device before the next one.": + "Mikrofon neredeyse hiçbir şey iletmedi (kaydın %{percent} kadarı boştu), " + "toplantının senin tarafın büyük ölçüde eksik olacak. Bir sonrakinden " + "önce aygıtı kontrol et.", "Transcribing {side}: {index}/{count}…": "{side} yazıya çevriliyor: {index}/{count}…", "you": "sen", diff --git a/tests/test_audio.py b/tests/test_audio.py index 7a6a09e..5bda0ab 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -16,6 +16,7 @@ import json import os import subprocess import sys +import threading import unittest import wave from unittest import mock @@ -251,6 +252,34 @@ class FakeProcess: self._alive = False +class StalledProcess(FakeProcess): + """A capture that hands over a buffer and then stops answering at all. + + Not the same thing as one that ends: the device is still there and the pipe + is still open, and a read of it never comes back. + """ + + def __init__(self, data): + super().__init__(data) + self.stdout = _StalledStream(data) + + +class _StalledStream: + def __init__(self, data): + self._data = io.BytesIO(data) + self._released = threading.Event() + + def read(self, size): + chunk = self._data.read(size) + if chunk: + return chunk + self._released.wait() + return b"" + + def release(self): + self._released.set() + + class RecordingCommand(OnLinux, DikteTest): """Which program captures the microphone, and how it is asked to.""" @@ -486,8 +515,10 @@ class MeetingCommands(unittest.TestCase): def commands(self, platform, mic="", system="them"): with mock.patch.object(sys, "platform", platform), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=lambda target: target or "default"): + mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + mock.patch.object( + audio, "_resolve_avfoundation_target", + side_effect=lambda target, inputs=None: target or "default"): return audio.meeting_commands(mic, system) def test_linux_reads_both_through_pulse(self): @@ -529,23 +560,41 @@ class MeetingCommands(unittest.TestCase): for command in self.commands(platform): self.assertIn("-nostdin", command) + def test_both_mac_devices_are_read_off_one_listing(self): + """Asking twice costs an ffmpeg run, and the second answer could have + renumbered between the two.""" + with mock.patch.object(sys, "platform", "darwin"), \ + mock.patch.object(audio, "_avfoundation_inputs", + return_value=[("0", "mine"), + ("1", "them")]) as inputs: + audio.meeting_commands("mine", "them") + inputs.assert_called_once_with() + class MacMeetingRecorder(OnMacOS, DikteTest): + # Whichever way the machine has them ordered, a name is what is saved and + # the index it happens to hold now is what ffmpeg is given. + DEVICES = [("0", "External Headset"), ("1", "BlackHole 2ch"), + ("2", "MacBook Pro Microphone")] + + def devices(self): + return mock.patch.object(audio, "_avfoundation_inputs", + return_value=self.DEVICES) + def record(self, mine, theirs): path = str(self.path("meeting.wav")) recorder = audio.MeetingRecorder() - stopped, failed = [], [] + stopped, failed, warnings = [], [], [] recorder.stopped.connect(lambda *args: stopped.append(args)) recorder.failed.connect(failed.append) + recorder.warned.connect(warnings.append) processes = [FakeProcess(mine), FakeProcess(theirs)] - with only_these_tools("ffmpeg"), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=("2", "1")), \ + with only_these_tools("ffmpeg"), self.devices(), \ mock.patch.object(subprocess, "Popen", side_effect=processes) as popen: recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") recorder._thread.join(timeout=5) recorder.stop() - return path, recorder, stopped, failed, processes, popen + return path, warnings, stopped, failed, processes, popen def test_the_two_capture_processes_become_one_stereo_file(self): path, _, stopped, failed, _, _ = self.record( @@ -567,14 +616,42 @@ class MacMeetingRecorder(OnMacOS, DikteTest): self.assertIn(":2", commands[0]) self.assertIn(":1", commands[1]) - def test_an_unusable_mostly_empty_microphone_is_not_transcribed(self): - path, _, stopped, failed, _, _ = self.record( + def test_a_mostly_empty_microphone_is_said_out_loud_and_still_kept(self): + """Half the file is everyone else, and an hour of them is worth more + than the empty channel costs.""" + path, warnings, stopped, failed, _, _ = self.record( silence(11.0), tone(11.0) ) - self.assertEqual(stopped, []) - self.assertEqual(len(failed), 1) - self.assertIn("empty", failed[0]) - self.assertFalse(os.path.exists(path)) + self.assertEqual(failed, []) + self.assertEqual(len(stopped), 1) + self.assertTrue(os.path.exists(path)) + self.assertIn("empty", warnings[0]) + + def test_a_microphone_that_was_merely_quiet_is_not_complained_about(self): + _, warnings, stopped, _, _, _ = self.record(tone(11.0), tone(11.0)) + self.assertEqual(warnings, []) + self.assertEqual(len(stopped), 1) + + def test_a_capture_that_falls_silent_ends_the_meeting_rather_than_hanging(self): + """One thread taking turns on both pipes would sit on the dead read + until somebody noticed, an hour later.""" + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + stopped = [] + recorder.stopped.connect(lambda *args: stopped.append(args)) + mine, theirs = StalledProcess(tone(0.512)), FakeProcess(tone(30.0)) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(audio, "STALL_SECONDS", 0.2), \ + mock.patch.object(subprocess, "Popen", side_effect=(mine, theirs)): + try: + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + recorder._thread.join(timeout=2) + self.assertFalse(recorder.active) + recorder.stop() + finally: + mine.stdout.release() + self.assertAlmostEqual(stopped[0][1], 0.512, places=3) + self.assertTrue(os.path.exists(path)) def test_stopping_ends_both_capture_processes(self): _, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5)) @@ -584,22 +661,21 @@ class MacMeetingRecorder(OnMacOS, DikteTest): recorder = audio.MeetingRecorder() failed = [] recorder.failed.connect(failed.append) - with only_these_tools("ffmpeg"), \ - mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + with only_these_tools("ffmpeg"), self.devices(), \ mock.patch.object(subprocess, "Popen") as popen: recorder.start(str(self.path("meeting.wav")), "2", "1") popen.assert_not_called() self.assertIn("old numeric index", failed[0]) def test_a_second_capture_process_that_cannot_start_cleans_up_the_first(self): + """A Mac left holding an open AVFoundation session records nothing + else until it is let go.""" path = str(self.path("meeting.wav")) recorder = audio.MeetingRecorder() failed = [] recorder.failed.connect(failed.append) first = FakeProcess(tone(1.0)) - with only_these_tools("ffmpeg"), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=("2", "1")), \ + with only_these_tools("ffmpeg"), self.devices(), \ mock.patch.object(subprocess, "Popen", side_effect=(first, OSError("refused"))): recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") From 8c62795b8b8be72aa77cd9989acd9a8b6c0d1a4b Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:28:43 +0300 Subject: [PATCH 04/16] Give Windows devices an identifier, and ask ffmpeg for them once 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. --- audio.py | 103 +++++++++++++++++++++++++++++------- i18n.py | 10 ++++ settings_ui.py | 12 +++++ tests/test_audio.py | 124 ++++++++++++++++++++++++++++++++++++++++---- tests/test_ui.py | 31 +++++++++++ 5 files changed, 250 insertions(+), 30 deletions(-) diff --git a/audio.py b/audio.py index 292b533..46e88f3 100644 --- a/audio.py +++ b/audio.py @@ -277,6 +277,14 @@ class MeetingRecorder(QObject): def start(self, path, mic_target="", system_target="", max_seconds=14400): if self.active: return + # Before ffmpeg is looked for, because installing it would not help: a + # system with no way to capture what the speakers are playing has none + # whatever else is on the machine. + if not sound().meetings: + self.failed.emit(t("This system offers nothing that records what " + "the speakers are playing, so a meeting cannot " + "be recorded on it.")) + return if not shutil.which("ffmpeg"): self.failed.emit(t("ffmpeg not found. Install it to record a meeting.")) return @@ -837,12 +845,55 @@ def _avfoundation_default_output(): # device at all, so a meeting has nothing to record the far side from yet. +# A device entry and the line under it, in the two shapes ffmpeg has printed +# this listing in. Newer builds mark each device `(audio)` or `(video)`; older +# ones print no marker and group the devices under a heading instead. Both are +# anchored at each end, so that the error lines the command ends with, which +# quote the device name that was not found, are not read as devices. +_DSHOW_ENTRY = re.compile( + r'^(?:\[dshow @ [^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') +_DSHOW_ALTERNATIVE = re.compile( + r'^(?:\[dshow @ [^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$') +_DSHOW_HEADING = re.compile(r'DirectShow (audio|video) devices') + +# The last listing taken, so that a dictation does not pay for one of its own. +_DSHOW_SEEN = [] + + +def _parse_dshow_listing(text): + """[(id, name)] for the audio devices in one ffmpeg device listing. + + Two friendly names on one machine are routinely identical: a laptop with a + headset plugged in shows two microphones called the same thing, and + `audio=` would reach only the first of them either way. The + alternative name ffmpeg prints under each device is unique and is what the + recorder is given back, while the friendly name is what a user picks from. + """ + devices = [] + heading = "" + for line in text.splitlines(): + found = _DSHOW_HEADING.search(line) + if found: + heading = found.group(1) + continue + found = _DSHOW_ALTERNATIVE.match(line.strip()) + if found: + if devices: + devices[-1][0] = found.group(1) + continue + found = _DSHOW_ENTRY.match(line.strip()) + if found: + kind = (found.group(2) or heading).lower() + devices.append([found.group(1), found.group(1), kind]) + return [(identifier, name) for identifier, name, kind in devices + if "audio" in kind] + + def _dshow_devices(): - """[(name, name)] for every DirectShow audio capture device. + """[(id, name)] for every DirectShow audio capture device, freshly asked. The list comes out on stderr of a command that then fails, the same - documented trick AVFoundation uses above. Names are the only stable handle - dshow offers a user; they are what the recorder is given back. + documented trick AVFoundation uses above. """ if not shutil.which("ffmpeg"): return [] @@ -855,26 +906,30 @@ def _dshow_devices(): except (subprocess.SubprocessError, OSError): return [] - devices = [] - for line in result.stderr.decode("utf-8", "replace").splitlines(): - if "(audio)" not in line: - continue - match = re.search(r'"([^"]+)"\s*\([^)]*audio[^)]*\)', line) - if match: - devices.append((match.group(1), match.group(1))) + devices = _parse_dshow_listing(result.stderr.decode("utf-8", "replace")) + _DSHOW_SEEN[:] = devices return devices +def _dshow_first_device(): + """The device an unset target stands for, without a listing per dictation. + + dshow has no "default" for an empty target to mean, so it has to be turned + into a name, and asking ffmpeg for one costs a process every time the key + is pressed. The last listing is used when there is one: opening Settings or + running `dikte devices` takes a fresh one, which is what somebody who has + just plugged a microphone in does anyway. + """ + devices = _DSHOW_SEEN or _dshow_devices() + return devices[0][0] if devices else "" + + def _dshow_record(target): if not shutil.which("ffmpeg"): return [] - # dshow has no "default" device: an unset target means the first one listed. - device = target + device = target or _dshow_first_device() if not device: - inputs = _dshow_devices() - if not inputs: - return [] - device = inputs[0][0] + return [] return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", # dshow holds half a second of audio before handing anything over; @@ -901,9 +956,13 @@ Sound = collections.namedtuple( "Sound", # How to capture one source and how to capture two at once, that one as the # list of processes it takes, the two device lists, which device a meeting - # records the far side from, and what to say when the programs for any of - # it are not installed. - "record meeting inputs outputs default_output missing", + # records the far side from, whether this system can record one at all, and + # what to say when the programs for any of it are not installed. + # + # `meetings` is the sound system's own answer, not this machine's: an empty + # output list means the tool that lists them is missing, which is a thing a + # user can go and fix, while False here is a thing they cannot. + "record meeting inputs outputs default_output meetings missing", ) PULSE = Sound( @@ -912,6 +971,7 @@ PULSE = Sound( inputs=_pulse_inputs, outputs=_pulse_outputs, default_output=_pulse_default_output, + meetings=True, missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.", ) @@ -924,6 +984,8 @@ COREAUDIO = Sound( # empty list would leave nothing to pick. outputs=_avfoundation_named_inputs, default_output=_avfoundation_default_output, + # With a loopback driver installed, which is what the Settings note is for. + meetings=True, missing="ffmpeg not found. Install it with: brew install ffmpeg", ) @@ -934,6 +996,9 @@ DSHOW = Sound( inputs=_dshow_devices, outputs=_dshow_no_outputs, default_output=_dshow_no_default_output, + # Windows offers no capture device for what the speakers are playing, and + # there is no driver to install that would add one. + meetings=False, missing="ffmpeg or a microphone was not found. Install ffmpeg with: " "winget install Gyan.FFmpeg", ) diff --git a/i18n.py b/i18n.py index 15c2133..ccbbf48 100644 --- a/i18n.py +++ b/i18n.py @@ -623,6 +623,16 @@ TR = { "macOS, hoparlörden çıkan sesi kaydedilebilir bir kaynak olarak sunmaz. " "BlackHole ya da Loopback kur, toplantının sesini oradan geçir ve " "yukarıdan onu seç.", + "This system offers nothing that records what the speakers are playing, " + "so a meeting cannot be recorded on it. Dictation and transcribing a file " + "are unaffected.": + "Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; " + "burada toplantı kaydedilemez. Dikte ve dosya deşifresi bundan " + "etkilenmez.", + "This system offers nothing that records what the speakers are playing, " + "so a meeting cannot be recorded on it.": + "Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; " + "burada toplantı kaydedilemez.", "Wear headphones if you can. Through speakers your microphone hears the " "other side as well, and although a line that lands on both channels at " "once is dropped again, the repair is never as clean as not needing it.": diff --git a/settings_ui.py b/settings_ui.py index b671931..3cb6225 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1015,6 +1015,18 @@ class SettingsWindow(QDialog): )) mac_note.setWordWrap(True) sources_form.addRow(mac_note) + elif not audio.sound().meetings: + # Windows is the system this is written for: it offers nothing that + # captures what the speakers are playing, and there is no driver to + # install that would put an entry in the list above. Left unsaid, + # the box is simply empty and the Record button fails at the press. + nothing_note = QLabel(t( + "This system offers nothing that records what the speakers are " + "playing, so a meeting cannot be recorded on it. Dictation and " + "transcribing a file are unaffected." + )) + nothing_note.setWordWrap(True) + sources_form.addRow(nothing_note) note = QLabel(t( "Wear headphones if you can. Through speakers your microphone hears " diff --git a/tests/test_audio.py b/tests/test_audio.py index 6525b0d..027a060 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -822,6 +822,42 @@ class MacRecordingCommand(OnMacOS, DikteTest): self.assertFalse(recorder.active) +class NoFarSideToRecord(DikteTest): + """Two different answers, and the table is what tells them apart. + + A sound system that records the far side has a device this machine could + not pick out, and Settings is where to choose one. A sound system that does + not had nothing to offer there in the first place, and "pick one" would + send somebody to an empty box and an installation that cannot help. + """ + + def failure(self, meetings): + recorder = audio.MeetingRecorder() + failures = [] + recorder.failed.connect(failures.append) + with only_these_tools("ffmpeg"), \ + mock.patch.object(audio, "default_monitor", return_value=""), \ + mock.patch.object(audio, "sound", + return_value=audio.PULSE._replace( + meetings=meetings)): + recorder.start(str(self.path("meeting.wav"))) + self.assertFalse(recorder.active) + return failures[0] + + def test_a_system_that_records_the_far_side_sends_you_to_settings(self): + self.assertIn("Settings", self.failure(True)) + + def test_a_system_that_does_not_says_that_instead(self): + message = self.failure(False) + self.assertIn("nothing that records what the speakers", message) + self.assertNotIn("Settings", message) + + def test_the_three_sound_systems_each_answer_the_question(self): + self.assertTrue(audio.PULSE.meetings) + self.assertTrue(audio.COREAUDIO.meetings) + self.assertFalse(audio.DSHOW.meetings) + + class OnWindows: """A test that runs as if the machine ran Windows.""" @@ -837,51 +873,108 @@ class WindowsDevices(OnWindows, DikteTest): whatever alphabet the machine speaks, so the listing here does too. """ + MIC = "@device_cm_{33D9A762}\\wave_{B1C2}" LISTING = ( '[dshow @ 0000020c] "Integrated Camera" (video)\n' '[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n' '[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n' - '[dshow @ 0000020c] Alternative name "@device_cm_{33D9A762}...."\n' + f'[dshow @ 0000020c] Alternative name "{MIC}"\n' '[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n' + '[dshow @ 0000020c] Could not find audio only device with name ' + '"dummy" among source devices of type audio.\n' "dummy: Immediate exit requested\n" ).encode("utf-8") + def setUp(self): + super().setUp() + # The listing is remembered between calls, so that a dictation does not + # run ffmpeg of its own. It cannot be remembered between tests. + audio._DSHOW_SEEN.clear() + self.addCleanup(audio._DSHOW_SEEN.clear) + @contextlib.contextmanager def listing(self, stderr=None, tools=("ffmpeg",)): completed = FakeCompleted( returncode=1, stderr=self.LISTING if stderr is None else stderr) with only_these_tools(*tools), \ - mock.patch.object(subprocess, "run", return_value=completed): - yield + mock.patch.object(subprocess, "run", + return_value=completed) as run: + yield run def test_windows_records_through_dshow(self): self.assertIs(audio.sound(), audio.DSHOW) - def test_the_audio_lines_are_the_only_ones_read(self): + def test_the_audio_devices_are_the_only_ones_read(self): with self.listing(): self.assertEqual(audio.list_sources(), [ - ("Mikrofon Dizisi (Intel Smart Sound)", - "Mikrofon Dizisi (Intel Smart Sound)"), + (self.MIC, "Mikrofon Dizisi (Intel Smart Sound)"), ("Kulaklık (Soundcore Life Q30)", "Kulaklık (Soundcore Life Q30)"), ]) + def test_the_device_ffmpeg_could_not_open_is_not_one_of_them(self): + """The command ends by quoting the name it was sent to look for.""" + with self.listing(): + self.assertNotIn("dummy", [name for _, name in audio.list_sources()]) + + def test_a_listing_from_an_ffmpeg_that_marks_nothing(self): + """Older builds print a heading instead of an (audio) on every line.""" + listing = ( + '[dshow @ 0] DirectShow video devices\n' + '[dshow @ 0] "Integrated Camera"\n' + '[dshow @ 0] Alternative name "@device_pnp_\\..."\n' + '[dshow @ 0] DirectShow audio devices\n' + '[dshow @ 0] "Microphone (Realtek Audio)"\n' + '[dshow @ 0] Alternative name "@device_cm_{ABCD}"\n' + ).encode("utf-8") + with self.listing(stderr=listing): + self.assertEqual(audio.list_sources(), + [("@device_cm_{ABCD}", "Microphone (Realtek Audio)")]) + + def test_two_devices_called_the_same_thing_stay_apart(self): + """The normal state of a laptop with a headset plugged into it.""" + listing = ( + '[dshow @ 0] "Microphone" (audio)\n' + '[dshow @ 0] Alternative name "@device_cm_{ONE}"\n' + '[dshow @ 0] "Microphone" (audio)\n' + '[dshow @ 0] Alternative name "@device_cm_{TWO}"\n' + ).encode("utf-8") + with self.listing(stderr=listing): + sources = audio.list_sources() + self.assertEqual([identifier for identifier, _ in sources], + ["@device_cm_{ONE}", "@device_cm_{TWO}"]) + self.assertEqual({name for _, name in sources}, {"Microphone"}) + def test_no_ffmpeg_installed(self): with only_these_tools(): self.assertEqual(audio.list_sources(), []) self.assertEqual(audio.recording_command(), []) - def test_the_name_is_what_the_recorder_is_given_back(self): + def test_the_identifier_is_what_the_recorder_is_given_back(self): with self.listing(): - cmd = audio.recording_command("Kulaklık (Soundcore Life Q30)") + cmd = audio.recording_command(self.MIC) self.assertEqual(cmd[cmd.index("-f") + 1], "dshow") - self.assertIn("audio=Kulaklık (Soundcore Life Q30)", cmd) + self.assertIn(f"audio={self.MIC}", cmd) def test_no_microphone_named_means_the_first_one_listed(self): """dshow has no default device for an empty target to mean.""" with self.listing(): - self.assertIn("audio=Mikrofon Dizisi (Intel Smart Sound)", - audio.recording_command()) + self.assertIn(f"audio={self.MIC}", audio.recording_command()) + + def test_a_dictation_does_not_run_a_listing_of_its_own(self): + """Two hundred milliseconds of ffmpeg in front of every key press.""" + with self.listing() as run: + audio.list_sources() + audio.recording_command() + audio.recording_command() + self.assertEqual(run.call_count, 1) + + def test_opening_the_device_list_asks_again(self): + """Which is what somebody who has just plugged one in does.""" + with self.listing() as run: + audio.list_sources() + audio.list_sources() + self.assertEqual(run.call_count, 2) def test_a_machine_with_no_microphone_at_all(self): with self.listing(stderr=b'[dshow @ 0] "Integrated Camera" (video)\n'): @@ -899,6 +992,15 @@ class WindowsDevices(OnWindows, DikteTest): self.assertEqual(audio.default_monitor(), "") self.assertEqual(audio.meeting_commands("mic", "sys"), []) + def test_a_meeting_says_what_is_wrong_rather_than_where_to_look(self): + recorder = audio.MeetingRecorder() + failures = [] + recorder.failed.connect(failures.append) + with self.listing(): + recorder.start(str(self.path("meeting.wav"))) + self.assertIn("nothing that records what the speakers", failures[0]) + self.assertFalse(recorder.active) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 0cc5969..567c97f 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -13,6 +13,7 @@ from unittest import mock from PyQt6.QtWidgets import QApplication, QMessageBox +import audio import cleanup import config as cfg import ggml @@ -449,6 +450,36 @@ class Overlay(DikteTest): self.assertFalse(widget.muted) +class MeetingSources(DikteTest): + """What the Meeting tab says about the far side, per sound system. + + The box that picks it is empty on a system that cannot record it, and an + empty box with nothing next to it reads as a list that has not loaded yet. + """ + + def notes(self, meetings): + with mock.patch.object(audio, "sound", + return_value=audio.PULSE._replace( + meetings=meetings)), \ + only_these_tools(), \ + mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \ + mock.patch.object(settings_ui.SettingsWindow, + "_load_transcribe_models"): + window = settings_ui.SettingsWindow(cfg.Config()) + self.addCleanup(window.deleteLater) + self.addCleanup(window.close) + return " ".join(label.text() + for label in window.findChildren(settings_ui.QLabel)) + + def test_a_system_that_cannot_record_the_far_side_says_so(self): + self.assertIn("nothing that records what the speakers", + self.notes(meetings=False)) + + def test_a_system_that_can_says_nothing_of_the_sort(self): + self.assertNotIn("nothing that records what the speakers", + self.notes(meetings=True)) + + if __name__ == "__main__": unittest.main() From ca559e39613cc5f00e77444cb3a4fb7d210884d5 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:28:52 +0300 Subject: [PATCH 05/16] Fill the clipboard buffer before emptying the clipboard 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. --- paste.py | 23 +++++++++++++++-------- tests/test_paste.py | 23 ++++++++++++++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/paste.py b/paste.py index 772e8c4..d15c9dc 100644 --- a/paste.py +++ b/paste.py @@ -412,19 +412,26 @@ def _win_read_text(): def _win_write_text(text): user32, kernel32 = _win_api() payload = str(text).encode("utf-16-le") + b"\x00\x00" + # Filled before the clipboard is opened at all. EmptyClipboard is what + # throws away whatever was there, and a failure after it and before the + # SetClipboardData would leave the clipboard holding nothing: the one way + # this function could lose what it was called to put back. + handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload)) + pointer = kernel32.GlobalLock(handle) if handle else None + if not pointer: + if handle: + kernel32.GlobalFree(handle) + raise PasteError(t("Could not copy to clipboard: {error}", + error="out of memory")) + ctypes.memmove(pointer, payload, len(payload)) + kernel32.GlobalUnlock(handle) + if not _win_open_clipboard(user32): + kernel32.GlobalFree(handle) raise PasteError(t("Could not copy to clipboard: {error}", error="the clipboard is held by another program")) - handle = None try: user32.EmptyClipboard() - handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload)) - pointer = kernel32.GlobalLock(handle) if handle else None - if not pointer: - raise PasteError(t("Could not copy to clipboard: {error}", - error="out of memory")) - ctypes.memmove(pointer, payload, len(payload)) - kernel32.GlobalUnlock(handle) if not user32.SetClipboardData(_WIN_CF_UNICODETEXT, handle): raise PasteError(t("Could not copy to clipboard: {error}", error=f"error {_win_error()}")) diff --git a/tests/test_paste.py b/tests/test_paste.py index db098e7..5315c42 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -484,6 +484,8 @@ class FakeWin32: self.next_handle = 1 self.pressed = [] # (virtual key, flags), in the order sent self.send_result = None # None: report every event as delivered + self.held = False # another program has the clipboard open + self.out_of_memory = False def _keep(self, buffer): handle = self.next_handle @@ -493,7 +495,7 @@ class FakeWin32: # --- user32 def OpenClipboard(self, owner): - return 1 + return 0 if self.held else 1 def CloseClipboard(self): return 1 @@ -519,6 +521,8 @@ class FakeWin32: # --- kernel32 def GlobalAlloc(self, flags, size): + if self.out_of_memory: + return 0 return self._keep(ctypes.create_string_buffer(size)) def GlobalLock(self, handle): @@ -559,6 +563,23 @@ class Windows(Standing, DikteTest): paste.copy_bytes(saved) self.assertEqual(self.api.text, "mine") + def test_a_copy_that_fails_leaves_what_was_there(self): + """EmptyClipboard is the point of no return, so nothing runs after it.""" + paste.copy("mine") + for failure in ("out_of_memory", "held"): + with self.subTest(failure=failure): + setattr(self.api, failure, True) + with self.assertRaises(paste.PasteError): + paste.copy("the dictation") + self.assertEqual(self.api.text, "mine") + setattr(self.api, failure, False) + + def test_the_handle_is_not_leaked_when_the_copy_fails(self): + self.api.held = True + with self.assertRaises(paste.PasteError): + paste.copy("the dictation") + self.assertEqual(self.api.buffers, {}) + def test_readiness_asks_for_no_program_and_no_permission(self): with only_these_tools(): self.assertTrue(paste.paste_ready()) From bbb9bccda4d053f5605a6e5330468886cd1e4d9f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:28:52 +0300 Subject: [PATCH 06/16] Stop answering before the replacement starts, on a restart 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. --- dikte.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dikte.py b/dikte.py index 7ce82a2..d777206 100755 --- a/dikte.py +++ b/dikte.py @@ -88,6 +88,9 @@ class Dikte: self.meeting_base = "" self.meeting_message = "" self.settings_window = None + # The single-instance server, handed over once run_app has opened it, so + # that a restart can stop answering before the replacement starts. + self.server = None self._quitting = False # A request that asked to be told how its run ended waits in here until # the run gets there, keyed by which of the three it was waiting on. @@ -901,6 +904,13 @@ class Dikte: if self.settings_window is not None: self.settings_window.close() self.shutdown() + # Stop answering before the replacement is started, not just afterwards. + # execv leaves nothing behind to answer, but a Windows restart is two + # processes for a moment, and a new one that reached a server still + # listening would take itself for the second copy, hand its command over + # and exit, leaving nothing running at all. + if self.server is not None: + self.server.close() QLocalServer.removeServer(SERVER_NAME) if sys.platform == "win32": # execv on Windows mangles arguments with spaces and leaves the two @@ -1066,6 +1076,7 @@ def run_app(args): QLocalServer.removeServer(SERVER_NAME) if not server.listen(SERVER_NAME): print(f"dikte: could not open the IPC socket: {server.errorString()}") + dikte.server = server def on_connection(): conn = server.nextPendingConnection() From 743dcc9b8aa2727f0a5fb7d7de62aa7dd725ab78 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:29:07 +0300 Subject: [PATCH 07/16] Ask doctor and devices about the programs this system actually uses `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. --- README.windows.md | 5 +++-- cli.py | 22 ++++++++++++++++++---- tests/test_cli.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/README.windows.md b/README.windows.md index 2a9f0c3..8b4a5e6 100644 --- a/README.windows.md +++ b/README.windows.md @@ -62,8 +62,9 @@ python dikte.py ## Troubleshooting -- **Recording does not start:** does `ffmpeg -version` run? Does - `dikte devices` list your microphone? +- **Recording does not start:** does `dikte doctor` find ffmpeg, and does + `dikte devices` list your microphone? `devices` also takes a fresh listing, + which is what to run after plugging one in. - **Nothing is pasted:** a normal-privilege process cannot type into an elevated (administrator) window; run Dikte elevated too, or paste by hand. The text lands on the clipboard either way. diff --git a/cli.py b/cli.py index e6b4450..6a76cc9 100644 --- a/cli.py +++ b/cli.py @@ -655,7 +655,10 @@ def cmd_devices(opts): "default": name == default} for name, desc in audio.list_monitors()] if not mics and not monitors: - return fail(opts, "pactl found nothing; is PipeWire running?") + # Which program was asked, and so which one to go and look at, is not + # the same on all four systems: naming pactl on Windows sends somebody + # after a program that was never going to be there. + return fail(opts, audio.sound().missing) lines = ["Microphones:"] lines += [f" {'*' if item['chosen'] else ' '} {item['name']}\n" @@ -791,9 +794,20 @@ def cmd_status(opts): def cmd_doctor(opts): """What the settings window checks behind its buttons, in one pass.""" conf = cfg.Config() - wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6", - assistant.executable(assistant.provider(conf)) or "claude", - cleanup.executable(cleanup.provider(conf))] + # The two the clipboard and the key press go through come out of the table + # rather than being spelled here, because they are not the same pair on all + # four systems: X11 pastes with xclip where Wayland pastes with wl-copy, a + # Mac shells out for one half and Windows for neither. A row saying ydotool + # is missing on a machine that would never have run it is not a diagnosis, + # it is a red mark to explain away. + here = paste.desktop() + wanted = [here.clipboard, here.keyboard] + if sys.platform.startswith("linux"): + # Recording, the device list, and KDE's shortcut registry. + wanted += ["pw-record", "pactl", "kwriteconfig6"] + wanted += ["ffmpeg", + assistant.executable(assistant.provider(conf)) or "claude", + cleanup.executable(cleanup.provider(conf))] programs = {name: shutil.which(name) or "" for name in wanted if name} target = conf.transcribe_target() cleaner = cleanup.provider(conf) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5fe4c36..1948916 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,12 +12,14 @@ import json import unittest from unittest import mock +import audio import cli import config as cfg import ggml import hotkey import ipc -from tests.support import DikteTest, fake_urlopen +import paste +from tests.support import DikteTest, fake_urlopen, only_these_tools class Options: @@ -435,6 +437,30 @@ class Doctor(DikteTest): self.assertIn("OpenRouter key, cleaning up on some/model", self.run_doctor(as_json=False, cleanup_model="some/model")) + def test_it_asks_after_the_programs_this_desktop_actually_uses(self): + """A missing ydotool on a Mac is a red mark with nothing behind it.""" + with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS): + mac = self.run_doctor()["programs"] + with mock.patch.object(cli.paste, "desktop", return_value=paste.WAYLAND): + wayland = self.run_doctor()["programs"] + self.assertIn("pbcopy", mac) + self.assertNotIn("ydotool", mac) + self.assertIn("ydotool", wayland) + self.assertIn("ffmpeg", mac) # the one every system records through + + def test_a_system_that_shells_out_for_neither_half_is_asked_for_neither(self): + # shutil.which is faked as well as the platform: the real one reads + # sys.platform too, and reaches for a Windows API this machine has not + # got the moment it is told it is on Windows. + with mock.patch.object(cli.paste, "desktop", return_value=paste.WINDOWS), \ + only_these_tools("ffmpeg"), \ + mock.patch.object(cli.sys, "platform", "win32"): + programs = self.run_doctor()["programs"] + self.assertNotIn("", programs) + self.assertEqual([name for name in ("wl-copy", "ydotool", "pactl", + "pw-record", "kwriteconfig6") + if name in programs], []) + def test_cleanup_on_a_cli_is_a_question_about_the_program(self): reply = self.run_doctor(cleanup_provider="codex", cleanup_codex_model="gpt-5.4") @@ -446,6 +472,25 @@ class Doctor(DikteTest): cleanup_codex_model="gpt-5.4")) +class Devices(DikteTest): + def test_a_machine_with_nothing_names_its_own_missing_program(self): + """The Windows README sends people here, and pactl is not on it.""" + for here, expected in ((audio.DSHOW, "ffmpeg"), + (audio.PULSE, "pulseaudio-utils")): + with self.subTest(sound=expected): + with mock.patch.object(cli.audio, "sound", return_value=here), \ + mock.patch.object(cli.audio, "list_sources", + return_value=[]), \ + mock.patch.object(cli.audio, "list_monitors", + return_value=[]), \ + mock.patch.object(cli.audio, "default_monitor", + return_value=""), \ + captured() as (out, _err): + code = cli.cmd_devices(Options(json=True)) + self.assertEqual(code, 1) + self.assertIn(expected, json.loads(out.getvalue())["error"]) + + class Finding(DikteTest): def test_no_history_at_all(self): self.assertIsNone(cli._find_history("last")) From 476265465f51368fd53989dfe7d86facd0c98780 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:29:07 +0300 Subject: [PATCH 08/16] Write the Windows installer in the language the rest of it is written in 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. --- install.ps1 | 53 +++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/install.ps1 b/install.ps1 index cbd86de..0d331bb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,9 +1,9 @@ -# Dikte'yi bu Windows kullanicisi icin kurar: Baslat Menusu kisayolu, istege -# bagli otomatik baslangic ve her yerden calisan bir `dikte` komutu. +# Installs Dikte for this Windows user: a Start Menu entry, an optional +# autostart entry, and a `dikte` command that works from any terminal. # -# powershell -ExecutionPolicy Bypass -File install.ps1 # kur -# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + oturum acilisinda baslat -# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # kaldir +# powershell -ExecutionPolicy Bypass -File install.ps1 # install +# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + start at sign-in +# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # remove param( [switch]$Autostart, [switch]$Uninstall @@ -15,60 +15,65 @@ $startMenu = [Environment]::GetFolderPath("Programs") $startup = [Environment]::GetFolderPath("Startup") $shortcut = Join-Path $startMenu "Dikte.lnk" $autostartLink = Join-Path $startup "Dikte.lnk" -# WindowsApps kullanici PATH'inde hazir durur; oraya birakilan dikte.cmd her -# terminalden calisir. +# WindowsApps is already on the user PATH, so a dikte.cmd left there runs from +# any terminal without a PATH edit and without an administrator. $cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd" if ($Uninstall) { foreach ($path in @($shortcut, $autostartLink, $cmdShim)) { - if (Test-Path $path) { Remove-Item $path -Force; Write-Host "silindi: $path" } + if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" } } - Write-Host "Dikte kisayollari kaldirildi. Depo klasoru ve ayarlar duruyor." + Write-Host "Dikte's shortcuts are gone. The repository and your settings are not." exit 0 } -# --- gereksinimler ---------------------------------------------------------- +# --- what it needs ---------------------------------------------------------- $python = Get-Command python -ErrorAction SilentlyContinue if (-not $python) { - Write-Error "Python bulunamadi. Kurun: winget install Python.Python.3.12" + Write-Error "No python found. Install it with: winget install Python.Python.3.12" } $version = & python -c "import sys; print('%d.%d' % sys.version_info[:2])" if ([version]$version -lt [version]"3.11") { - Write-Error "Python 3.11+ gerekli, bulunan: $version" + Write-Error "Python 3.11 or newer is needed, and this one is $version." } & python -c "import PyQt6.QtWidgets" 2>$null if ($LASTEXITCODE -ne 0) { - Write-Host "PyQt6 kuruluyor..." + Write-Host "Installing PyQt6..." & python -m pip install PyQt6 - if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 kurulamadi." } + if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 would not install." } } if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { - Write-Warning "ffmpeg bulunamadi. Ses kaydi icin gerekli: winget install Gyan.FFmpeg" + Write-Warning "No ffmpeg found. Recording needs it: winget install Gyan.FFmpeg" } -# pythonw.exe konsol penceresi acmadan calistirir. +# pythonw.exe runs the same program without a console window behind it. $pythonw = Join-Path (Split-Path $python.Source) "pythonw.exe" if (-not (Test-Path $pythonw)) { $pythonw = $python.Source } -# --- Baslat Menusu kisayolu ------------------------------------------------- +# --- the Start Menu entry --------------------------------------------------- $shell = New-Object -ComObject WScript.Shell foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) { $link = $shell.CreateShortcut($path) $link.TargetPath = $pythonw $link.Arguments = "`"$repo\dikte.py`" --gui" $link.WorkingDirectory = $repo - $link.Description = "Dikte: sesli dikte" + $link.Description = "Dikte: dictation" $link.Save() - Write-Host "kisayol: $path" + Write-Host "shortcut: $path" } -# --- dikte komutu ----------------------------------------------------------- +# --- the dikte command ------------------------------------------------------ +# The interpreter by its full path rather than by name: the one checked above is +# the one the command line should run, whatever a later PATH change puts first. $shimDir = Split-Path $cmdShim if (Test-Path $shimDir) { - "@echo off`r`npython `"$repo\dikte.py`" %*" | Out-File $cmdShim -Encoding ascii - Write-Host "komut: dikte ($cmdShim)" + "@echo off`r`n`"$($python.Source)`" `"$repo\dikte.py`" %*" | + Out-File $cmdShim -Encoding ascii + Write-Host "command: dikte ($cmdShim)" +} else { + Write-Warning "No $shimDir on this machine, so there is no dikte command. Run it as: python `"$repo\dikte.py`"" } Write-Host "" -Write-Host "Kurulum tamam. Baslat Menusu'nden 'Dikte' ile ya da terminalden 'dikte' yazarak baslatin." -Write-Host "Ilk acilista Ayarlar penceresi acilir: oradan model indirin ve kisayolu secin (varsayilan Ctrl+Space)." +Write-Host "Installed. Start it from the Start Menu as 'Dikte', or type 'dikte' in a terminal." +Write-Host "The Settings window opens on the first run: download a model there and pick the shortcut (Ctrl+Space by default)." From ec4d7da09d0c5ee3fcbeecfddd6ea3105cd4fb04 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:29:07 +0300 Subject: [PATCH 09/16] Say there is a fourth system in the two documents that count them 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. --- CONTRIBUTING.md | 39 ++++++++++++++++++++++++--------------- README.tr.md | 12 +++++++++--- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c23508a..1eff08c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,19 +53,28 @@ forgets fails rather than hangs. ## Another platform -Three systems are supported: Wayland, X11 and macOS. Each one is a named entry -in a table, and one chooser picks between them, so a fourth adds an entry and a -line rather than a branch inside every function. The three tables are -`paste.Desktop` (clipboard and key press), `audio.Sound` (capture and the device -lists) and the `_macos()`/`_gnome()` pair in `hotkey.py`. Keep `sys.platform` -inside the chooser and read it there every time: a constant settled at import is -one no test can stand somewhere else. +Four systems are supported: Wayland, X11, macOS and Windows. Each one is a named +entry in a table, and one chooser picks between them, so a fifth adds an entry +and a line rather than a branch inside every function. The tables are +`paste.Desktop` (clipboard and key press), `audio.Sound` (capture, the device +lists, and whether the far side of a meeting can be recorded at all), +`paths.directories()` (where the settings and the data live) and the +`_macos()`/`_windows()`/`_gnome()` set in `hotkey.py`. Keep `sys.platform` inside +the chooser and read it there every time: a constant settled at import is one no +test can stand somewhere else. + +Where a platform cannot do something, say so in its table entry rather than in +the code that asks. `audio.Sound.meetings` is the shape of it: Windows offers no +capture device for what the speakers are playing, and a caller reading a False +there can tell that apart from an empty device list, which only means the tool +that lists them is not installed. The tests are split along the same line, and almost none of them are skipped. -892 of the 935 run on any machine, including every line of the Wayland, X11 and -macOS backends: the programs are faked at `shutil.which`, the frameworks at the -one function that loads them. A test class says which system it is standing on -rather than avoiding the question: +1009 of the 1052 run on any machine, including every line of the Wayland, X11, +macOS and Windows backends: the programs are faked at `shutil.which`, the +frameworks and system libraries at the one function that loads them +(`paste._win_api`, `hotkey._win_input`). A test class says which system it is +standing on rather than avoiding the question: ```python class MacOS(ClipboardContract, DikteTest): @@ -73,10 +82,10 @@ class MacOS(ClipboardContract, DikteTest): here = paste.MACOS ``` -so the Linux half is checked on a Mac and the macOS half on Linux, and a change -to a chooser cannot quietly break the platform nobody is sitting at. What the -systems owe in common is written once as a contract class and subclassed by each -of them. +so the Linux half is checked on a Mac, the macOS half on Linux and the Windows +half on both, and a change to a chooser cannot quietly break the platform nobody +is sitting at. What the systems owe in common is written once as a contract +class and subclassed by each of them. The 43 that do carry `@linux_only` are the ones that would need the real thing: the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test diff --git a/README.tr.md b/README.tr.md index db1f23c..099fe1c 100644 --- a/README.tr.md +++ b/README.tr.md @@ -4,9 +4,9 @@ çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. -KDE Plasma 6 / Wayland için yazıldı, GNOME X11 ve macOS'ta da çalışır. Sistem -paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya -üstü) ve PyQt6. +KDE Plasma 6 / Wayland için yazıldı, GNOME X11, macOS ve +[Windows](README.windows.md)'ta da çalışır. Sistem paketleri dışında bağımlılığı +yok: sadece Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. *[English README](README.md)* @@ -81,6 +81,12 @@ build -j`) ve yolunu Ayarlar → API'ye yaz, ya da buluta çevir. Toplantı içi BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için gerekmiyor. +Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi +kısayol servisi üzerinden tutuyor: `winget install Gyan.FFmpeg`, `pip install +PyQt6`, sonra `python dikte.py`; Başlat Menüsü girdisi ve `dikte` komutu için +isteğe bağlı `install.ps1`. Orada toplantı kaydı henüz yok, ayrıntılar +[Windows README](README.windows.md)'sinde. + `install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh` son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar; From 0419c88ac298c72aa54240f17b9aab404586ffdc Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 10:58:09 +0300 Subject: [PATCH 10/16] Run the tests on Windows too, which is where they have never run 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. --- .github/workflows/tests.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86eaa88..2366639 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,3 +74,41 @@ jobs: bash -n install-mac.sh bash -n update.sh bash -n uninstall.sh + + # The same job again for the same reason. The Windows backends are faked at + # the one function that loads user32 and kernel32, so every line of them is + # already read on the Linux above; what only this job can catch is the half + # that reads the real system. %APPDATA% and %LOCALAPPDATA% have to be the + # directories Windows actually hands out, a path spelled with a backslash has + # to be one the tests can still read, and the config-permission test has to + # skip rather than fail on a file system that decides by ACL. + windows: + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + # No apt step: PyQt6's wheel carries the Qt DLLs it needs on Windows. + - name: Install PyQt6 + run: python -m pip install --quiet PyQt6 + + - name: Run the tests + run: python -m unittest discover --verbose + + # What the Mac does for its installer, in the language this one is in. + # Parsing only: install.ps1 writes into the Start Menu and the user PATH. + - name: Check the installer parses + shell: pwsh + run: | + $problems = $null + [System.Management.Automation.Language.Parser]::ParseFile( + "$PWD/install.ps1", [ref]$null, [ref]$problems) > $null + if ($problems) { $problems; exit 1 } From 93f3db889d337afc45db31f2605aec053c0411c5 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 11:12:36 +0300 Subject: [PATCH 11/16] Say out loud that Windows on ARM gets an emulated whisper 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. --- README.windows.md | 4 +++- ggml.py | 4 ++++ tests/test_ggml.py | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.windows.md b/README.windows.md index 8b4a5e6..fd0789d 100644 --- a/README.windows.md +++ b/README.windows.md @@ -54,7 +54,9 @@ python dikte.py - The local install fetches whisper.cpp's **OpenBLAS build**, which transcribes about twice as fast as the stock one on a plain CPU. There is - no GPU build to fetch for machines without an NVIDIA card. + no GPU build to fetch for machines without an NVIDIA card, and none for + Windows on ARM either: whisper.cpp publishes x64 only, so a Snapdragon + machine runs it under emulation and the cloud is the faster option there. - Setting Settings → API and models → **Threads** near your physical core count helps noticeably; the server's own default is 4. - If speed matters more than accuracy, `ggml-small` and `ggml-base` are much diff --git a/ggml.py b/ggml.py index 39a6e21..87a2c4c 100644 --- a/ggml.py +++ b/ggml.py @@ -251,6 +251,10 @@ def _wanted_assets(program): # as fast as the stock one, and it carries everything it needs. # Full names, because "bin-x64.zip" alone would also match the # CUDA archives, whichever the release happened to list first. + # + # x64 whatever this machine is, because whisper.cpp publishes no + # arm64 build for Windows: a Snapdragon runs this one emulated, + # which is slow but is the only local option there is. return ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip") if _has_vulkan() and arch == "x64": return ("bin-win-vulkan-x64.zip", f"bin-win-cpu-{arch}.zip") diff --git a/tests/test_ggml.py b/tests/test_ggml.py index dab95a4..1f01372 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -725,6 +725,15 @@ class WindowsAssets(Local): self.assertEqual(ggml._wanted_assets(ggml.LLAMA), ("bin-win-cpu-arm64.zip",)) + def test_an_arm_machine_is_handed_the_x64_whisper_anyway(self): + """whisper.cpp publishes no arm64 build for Windows: the release has + Win32 and x64 and nothing else, so emulated is the only local option + a Snapdragon has. Pinned here so that a release which does start + publishing one is noticed rather than quietly ignored.""" + self.patch_attr(ggml, "_arch", lambda: "arm64") + self.assertEqual(ggml._wanted_assets(ggml.WHISPER), + ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip")) + class InstallOnWindows(Local): """The Windows releases are zips, and the binary carries .exe.""" From 5025f8293cb0721672a6f37d6633263df68f4659 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 11:47:40 +0300 Subject: [PATCH 12/16] Describe the restart race as what it is, not as what it is not 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. --- dikte.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dikte.py b/dikte.py index d777206..cc1eb47 100755 --- a/dikte.py +++ b/dikte.py @@ -906,9 +906,12 @@ class Dikte: self.shutdown() # Stop answering before the replacement is started, not just afterwards. # execv leaves nothing behind to answer, but a Windows restart is two - # processes for a moment, and a new one that reached a server still - # listening would take itself for the second copy, hand its command over - # and exit, leaving nothing running at all. + # processes for a moment, and removeServer does nothing about a name + # another process is holding. The new one then either opens a second + # server on a name the old one is still answering on, so that a command + # arriving in that moment reaches the process that is going away, or + # fails to open one at all and says so to a console nobody is watching. + # Closing first leaves neither. if self.server is not None: self.server.close() QLocalServer.removeServer(SERVER_NAME) From bac988e0e651f48b6f2d12e398a7553312014df0 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 14:13:12 +0300 Subject: [PATCH 13/16] Ask for the entry point by its parts, not by a slash 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. --- tests/test_ipc.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_ipc.py b/tests/test_ipc.py index a95cb8b..97d0373 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -7,6 +7,7 @@ answers by saying nothing at all. import json import os +import pathlib import sys import unittest from unittest import mock @@ -57,7 +58,10 @@ class FakeSocket: class Paths(unittest.TestCase): def test_script_path_points_at_dikte(self): - self.assertTrue(ipc.script_path().endswith("dikte/__main__.py")) + # By its parts rather than as a string: the separator is a backslash on + # Windows, and the path is what a shortcut there runs too. + path = pathlib.Path(ipc.script_path()) + self.assertEqual(path.parts[-2:], ("dikte", "__main__.py")) self.assertTrue(os.path.exists(ipc.script_path())) def test_the_shortcut_command_runs_it_with_this_interpreter(self): From 180ddac37e91fc72925a5699e3d5d1c45c96386c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 15:54:44 +0300 Subject: [PATCH 14/16] Let the Windows job read the merged suite 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. --- CONTRIBUTING.md | 9 ++++++--- tests/support.py | 12 ++++++++++++ tests/test_integrate.py | 6 ++++++ tests/test_ipc.py | 8 +++++--- tests/test_ui.py | 7 ++++--- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3f85b2..c364c89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ there can tell that apart from an empty device list, which only means the tool that lists them is not installed. The tests are split along the same line, and almost none of them are skipped. -1104 of the 1147 run on any machine, including every line of the Wayland, X11, +1084 of the 1147 run on any machine, including every line of the Wayland, X11, macOS and Windows backends: the programs are faked at `shutil.which`, the frameworks and system libraries at the one function that loads them (`paste._win_api`, `hotkey._win_input`). A test class says which system it is @@ -88,8 +88,11 @@ is sitting at. What the systems owe in common is written once as a contract class and subclassed by each of them. The 43 that do carry `@linux_only` are the ones that would need the real thing: -the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test -that way only when faking it would leave nothing to test. A test that quietly +the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. The 20 with +`@posix_only` are `integrate.py`, the menu entry and the login item a downloaded +build writes for itself: there are two downloads, an AppImage and a disk image, +so that module has no Windows half for a Windows host to check. Mark a test +either way only when faking it would leave nothing to test. A test that quietly stops running on the platform you are porting to protects nothing. ## What a pull request should carry diff --git a/tests/support.py b/tests/support.py index 18c0a06..f6ec508 100644 --- a/tests/support.py +++ b/tests/support.py @@ -40,6 +40,18 @@ linux_only = unittest.skipUnless( "covers the Linux desktop stack (PipeWire, wl-clipboard, ydotool, KDE)", ) +# The launchers a downloaded build writes for itself. There are two downloads, +# an AppImage and a disk image, so `integrate` has a Linux half and a macOS half +# and no third one, and the tests that pin them stand in a home laid out the way +# those two systems lay one out: paths that start at the root, a $HOME the +# library reads, a symlink for the command. None of that is a Windows machine, +# where the same code never runs. A Windows build would add an entry there and +# take the mark off these. +posix_only = unittest.skipIf( + sys.platform == "win32", + "covers what an AppImage and a .app write into the desktop they landed on", +) + def _no_network(*args, **kwargs): raise AssertionError( diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 6bc43b6..9721dd4 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -15,6 +15,7 @@ import unittest from unittest import mock from dikte import integrate +from tests.support import posix_only class Frozen: @@ -75,6 +76,7 @@ class WhatToStart(unittest.TestCase): def test_a_checkout_names_this_interpreter_and_the_entry_point(self): self.assertFalse(integrate.packaged()) + @posix_only def test_an_appimage_names_the_file_and_not_the_mount(self): """The mount is a fresh /tmp path every run; a shortcut written to it would work until the next login and never again.""" @@ -83,6 +85,7 @@ class WhatToStart(unittest.TestCase): self.assertEqual(str(integrate.target()), "/home/someone/Downloads/Dikte.AppImage") + @posix_only def test_a_mac_names_the_bundle_and_not_the_executable_inside_it(self): with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte", platform="darwin"): @@ -95,6 +98,7 @@ class WhatToStart(unittest.TestCase): class BundledTools(unittest.TestCase): """The ffmpeg the disk image carries, and how anything finds it.""" + @posix_only def test_a_mac_looks_beside_the_bundle_not_beside_the_executable(self): with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte", platform="darwin"): @@ -213,6 +217,7 @@ class Certificates(unittest.TestCase): self.assertIsNone(integrate.use_system_certificates()) +@posix_only class Linux(Home): def install(self, appimage, force=False): with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage), @@ -348,6 +353,7 @@ class Linux(Home): self.assertEqual(integrate.ensure(), []) +@posix_only class MacOS(Home): def agent(self): return self.home / "Library/LaunchAgents/io.github.yusufipk.dikte.plist" diff --git a/tests/test_ipc.py b/tests/test_ipc.py index e58c06f..502574c 100644 --- a/tests/test_ipc.py +++ b/tests/test_ipc.py @@ -8,6 +8,7 @@ answers by saying nothing at all. import json import os import pathlib +import shlex import sys import unittest from unittest import mock @@ -65,9 +66,10 @@ class Paths(unittest.TestCase): self.assertTrue(os.path.exists(ipc.script_path())) def test_the_shortcut_command_runs_it_with_this_interpreter(self): - command = ipc.command_for("toggle") - self.assertTrue(command.startswith(sys.executable)) - self.assertTrue(command.endswith(" toggle")) + # Read back through the same quoting it went out with: a Windows path + # is spelled with backslashes and comes out of the join quoted. + self.assertEqual(shlex.split(ipc.command_for("toggle")), + [sys.executable, ipc.script_path(), "toggle"]) def test_a_packaged_build_names_itself_and_no_interpreter(self): """There is no __main__.py on disk in one, and sys.executable is the diff --git a/tests/test_ui.py b/tests/test_ui.py index b3a748d..6cd402c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -21,6 +21,7 @@ from dikte import cleanup from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import ipc from dikte import overlay as overlay_module from dikte import paste from dikte import settings_ui @@ -283,7 +284,7 @@ class Settings(DikteTest): text = self.shortcut_tab_text(window) self.assertIn("i3 keeps no shortcut registry", text) self.assertNotIn("KWin", text) - self.assertIn("__main__.py toggle", text) + self.assertIn(ipc.command_for("toggle"), text) # Not a choice to offer where it is the only mechanism there is. self.assertTrue(window.evdev_enabled.isHidden()) self.assertFalse([button for button in @@ -460,7 +461,7 @@ class MacSettings(Settings): text = self.shortcut_tab_text(window) self.assertIn("Dikte asks macOS for these combinations", text) self.assertNotIn("KWin", text) - self.assertNotIn("__main__.py toggle", text) + self.assertNotIn(ipc.command_for("toggle"), text) def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self): window = self.window(cfg.Config()) @@ -484,7 +485,7 @@ class KdeSettings(Settings): self.assertIn("KWin only reads shortcut settings at startup", text) self.assertIn("Install as a KDE shortcut", text) self.assertNotIn("keeps no shortcut registry", text) - self.assertNotIn("__main__.py toggle", text) + self.assertNotIn(ipc.command_for("toggle"), text) # Here it is a choice: the wait for the next login, or the key press # reaching the focused application as well. self.assertFalse(window.evdev_enabled.isHidden()) From aef33585c133e80c5bad4c3bf1ff3223c5714412 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 15:55:52 +0300 Subject: [PATCH 15/16] Say where the Windows install comes from now that there are downloads --- README.windows.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.windows.md b/README.windows.md index cc13b57..05adcf6 100644 --- a/README.windows.md +++ b/README.windows.md @@ -12,6 +12,9 @@ up and pasted where your cursor is. ## Installing +From a checkout: the releases page carries an AppImage and a disk image, and no +Windows build yet. + ```powershell powershell -ExecutionPolicy Bypass -File install.ps1 ``` From 11a4058e162f24b9f0f74e89ee065fcbfa12a39d Mon Sep 17 00:00:00 2001 From: huseyin-emre-tigci Date: Mon, 17 Aug 2026 01:02:42 +0300 Subject: [PATCH 16/16] Let the device listing match ffmpeg 8's bracket prefix The dshow parser pinned the bracketed prefix to the `[dshow @ ...]` the older builds print, and ffmpeg 8 writes `[in#0 @ ...]` there instead: on a current install every line fell through the anchors, the device list came back empty, and a dictation with no microphone picked could not start at all. The prefix is now any bracketed tag, which the anchors and the quoted name still keep apart from the error lines. Pinned with a listing taken verbatim from ffmpeg 8.1.2 on Windows 11, third kind marker `(none)` included. Co-Authored-By: Claude Fable 5 --- dikte/audio.py | 8 +++++--- tests/test_audio.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/dikte/audio.py b/dikte/audio.py index edcd5dc..ef24461 100644 --- a/dikte/audio.py +++ b/dikte/audio.py @@ -874,11 +874,13 @@ def _avfoundation_default_output(): # this listing in. Newer builds mark each device `(audio)` or `(video)`; older # ones print no marker and group the devices under a heading instead. Both are # anchored at each end, so that the error lines the command ends with, which -# quote the device name that was not found, are not read as devices. +# quote the device name that was not found, are not read as devices. The +# bracketed prefix is not pinned to a spelling: ffmpeg 8 writes `[in#0 @ ...]` +# where the versions before it wrote `[dshow @ ...]`. _DSHOW_ENTRY = re.compile( - r'^(?:\[dshow @ [^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') + r'^(?:\[[^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') _DSHOW_ALTERNATIVE = re.compile( - r'^(?:\[dshow @ [^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$') + r'^(?:\[[^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$') _DSHOW_HEADING = re.compile(r'DirectShow (audio|video) devices') # The last listing taken, so that a dictation does not pay for one of its own. diff --git a/tests/test_audio.py b/tests/test_audio.py index 7435443..a7ea353 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -982,6 +982,22 @@ class WindowsDevices(OnWindows, DikteTest): with self.listing(): self.assertNotIn("dummy", [name for _, name in audio.list_sources()]) + def test_a_listing_from_ffmpeg_8_which_renamed_the_prefix(self): + """ffmpeg 8 writes `[in#0 @ ...]` where older builds wrote `[dshow @ ...]`.""" + listing = ( + '[in#0 @ 00000238c3300ac0] "Integrated Camera" (video)\n' + '[in#0 @ 00000238c3300ac0] Alternative name "@device_pnp_\\..."\n' + '[in#0 @ 00000238c3300ac0] "OBS Virtual Camera" (none)\n' + '[in#0 @ 00000238c3300ac0] Alternative name "@device_sw_{860B}"\n' + '[in#0 @ 00000238c3300ac0] "Mikrofon Dizisi (Intel® Smart Sound)" (audio)\n' + '[in#0 @ 00000238c3300ac0] Alternative name "@device_cm_{33D9}"\n' + "Error opening input file dummy.\n" + ).encode("utf-8") + with self.listing(stderr=listing): + self.assertEqual(audio.list_sources(), + [("@device_cm_{33D9}", + "Mikrofon Dizisi (Intel® Smart Sound)")]) + def test_a_listing_from_an_ffmpeg_that_marks_nothing(self): """Older builds print a heading instead of an (audio) on every line.""" listing = (