Merge master into the macOS front branch

This commit is contained in:
Gökhan
2026-08-25 00:16:20 +03:00
48 changed files with 3523 additions and 258 deletions
+1 -1
View File
@@ -10,4 +10,4 @@ business loading Qt to answer one question.
# both the .dmg's Info.plist and the AppImage's file name are built from it. A
# build off master rather than off a tag appends the commit to it, so that a
# bug report from someone running "latest" names a commit.
__version__ = "1.0.0"
__version__ = "1.0.2"
+30 -5
View File
@@ -18,6 +18,7 @@ import mimetypes
import os
import secrets
import socket
import sys
import threading
import urllib.error
import urllib.request
@@ -57,10 +58,20 @@ def timestamp_model(provider, selected=""):
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
# What a gateway in front of the model answers of its own accord: the request
# never reached the model, or the model was still working when the connection
# was given up on. Trying again is the only thing that fixes any of them, and
# with a long file it is worth the second try rather than losing the run.
RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
class ApiError(Exception):
def __init__(self, message, status=None):
def __init__(self, message, status=None, retryable=None):
super().__init__(message)
self.status = status
# Anything not on that list is the request itself being wrong, and it
# will be just as wrong the second time.
self.retryable = status in RETRY_STATUS if retryable is None else retryable
class Aborted(Exception):
@@ -148,6 +159,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()
@@ -211,7 +228,7 @@ def explain(exc, service):
if exc.status == 429:
return ApiError(t("{service} is rate limiting you (HTTP 429). Try again in "
"a moment.", service=service), exc.status)
return ApiError(f"{service}: {exc}", exc.status)
return ApiError(f"{service}: {exc}", exc.status, retryable=exc.retryable)
def _request(url, data, headers, timeout=120, aborter=None):
@@ -227,8 +244,11 @@ def _request(url, data, headers, timeout=120, aborter=None):
# not the network failing. URLError is an OSError, so both land here.
if aborter is not None and aborter.aborted:
raise Aborted from None
# A connection that dropped or timed out is the same bad minute as a
# 502, so it is worth the same second try.
raise ApiError(t("Could not connect: {reason}",
reason=getattr(exc, "reason", exc))) from exc
reason=getattr(exc, "reason", exc)),
retryable=True) from exc
except json.JSONDecodeError as exc:
raise ApiError(t("Could not parse the response: {error}", error=exc)) from exc
@@ -258,7 +278,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()
@@ -306,7 +331,7 @@ def local_failure(service, server, exc):
"""
detail = server.error()
return ApiError(f"{service}: {exc}" + (f" ({detail})" if detail else ""),
exc.status)
exc.status, retryable=exc.retryable)
def _transcribe_request(target, audio_path, language, prompt, response_format,
+130 -3
View File
@@ -15,6 +15,7 @@ import json
import os
import signal
import socket
import subprocess
import sys
import threading
import time
@@ -33,8 +34,9 @@ if sys.platform == "darwin":
os.environ.get("PATH", "")) if part
)
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtCore import (QObject, QTimer, QElapsedTimer, QSocketNotifier, # noqa: E402
QUrl, pyqtSignal)
from PyQt6.QtGui import QAction, QDesktopServices, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
@@ -44,12 +46,14 @@ from . import cli # noqa: E402
from . import config as cfg # noqa: E402
from . import ggml # noqa: E402
from . import hotkey # noqa: E402
from . import hub # noqa: E402
from . import i18n # noqa: E402
from . import integrate # noqa: E402
from . import ipc # noqa: E402
from . import mac_window # noqa: E402
from . import meeting # noqa: E402
from . import trayicon # noqa: E402
from . import update # noqa: E402
from .i18n import t # noqa: E402
from .meeting import MeetingPipeline # noqa: E402
from .overlay import Overlay # noqa: E402
@@ -78,6 +82,37 @@ ECHO_MS = 2000
# short enough not to sit in the corner for the rest of the hour.
PEEK_MS = 12000
# When the releases page is looked at, and how often it is thought about after
# that. The delay is there so that a check never shares the first seconds of a
# start with the model being loaded and the desktop drawing the tray; the
# interval is not the interval between checks, which update.py holds at a day,
# but how often that clock is read, so that a machine left running for a week
# still asks once a day rather than once a boot.
UPDATE_DELAY_MS = 20000
UPDATE_POLL_MS = 3 * 3600 * 1000
class UpdateCheck(QObject):
"""One look at the releases page, off the interface thread.
An object of its own because the application is not one: a plain thread
cannot touch a widget, and a signal is the only way back onto the thread
that may.
"""
# The newer release, or None when there is nothing to say, and the reason
# nothing could be found out instead.
done = pyqtSignal(object, str)
def start(self):
def work():
try:
self.done.emit(update.check(), "")
except hub.HubError as exc:
self.done.emit(None, str(exc))
threading.Thread(target=work, daemon=True).start()
class Dikte:
def __init__(self, app):
@@ -99,6 +134,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.
@@ -163,6 +201,17 @@ class Dikte:
self.meeting_ticker.setInterval(500)
self.meeting_ticker.timeout.connect(self._meeting_tick)
# What the last check found, read from disk rather than asked for, so
# that a tray built in the next line already knows to say so.
self.update_release = update.pending()
self.updates = UpdateCheck()
self.updates.done.connect(self._on_update_checked)
self.update_ticker = QTimer()
self.update_ticker.setInterval(UPDATE_POLL_MS)
self.update_ticker.timeout.connect(self._look_for_update)
self.update_ticker.start()
QTimer.singleShot(UPDATE_DELAY_MS, self._look_for_update)
self.tray = QSystemTrayIcon()
self._apply_settings()
self.tray.show()
@@ -215,6 +264,11 @@ class Dikte:
self.menu.addAction(self.meeting_cancel_action)
self.menu.addSeparator()
# Named in _refresh_update, and hidden until a check has found one.
self.update_action = QAction("", self.menu)
self.update_action.triggered.connect(self.open_release_page)
self.menu.addAction(self.update_action)
self.settings_action = QAction(t("Settings…"), self.menu)
self.settings_action.triggered.connect(self.open_settings)
self.menu.addAction(self.settings_action)
@@ -231,6 +285,7 @@ class Dikte:
self.tray.setContextMenu(self.menu)
self.tray.setToolTip(t("Dikte: ready"))
self.tray.activated.connect(self._tray_clicked)
self._refresh_update()
self._set_icon("audio-input-microphone")
def _tray_clicked(self, reason):
@@ -999,12 +1054,58 @@ class Dikte:
if len(message) > len(first_line):
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
# ---- updates ----------------------------------------------------------
def _look_for_update(self):
"""The timer. update.py decides whether this is a request or a memory."""
if not self.conf["update_check"]:
return
self.updates.start()
def _on_update_checked(self, release, error):
if error:
# Nobody asked for this, so nobody is waiting to be told it failed.
# A machine that is offline, or a GitHub that is rate-limiting the
# address, is not a thing to interrupt a dictation about.
print(f"dikte: update check: {error}", file=sys.stderr)
return
if release is None:
return
self._found_update(release)
# Once per version. A check that runs every day must not be a
# notification every day for an update somebody has decided to skip.
if update.announced() != release.version:
update.mark_announced(release.version)
self.tray.showMessage(
"Dikte",
t("Dikte {version} is out. The tray menu has the release page.",
version=release.version),
QSystemTrayIcon.MessageIcon.Information, 8000,
)
def _found_update(self, release):
self.update_release = release
self._refresh_update()
def _refresh_update(self):
release = self.update_release
self.update_action.setVisible(release is not None)
if release is not None:
self.update_action.setText(
t("Dikte {version} is out…", version=release.version))
def open_release_page(self):
release = self.update_release
QDesktopServices.openUrl(
QUrl(release.url if release is not None else update.RELEASES_PAGE))
# ---- settings ---------------------------------------------------------
def open_settings(self):
if self.settings_window is None:
self.settings_window = SettingsWindow(self.conf, self.meetings)
self.settings_window.applied.connect(self._apply_settings)
self.settings_window.update_found.connect(self._found_update)
self.settings_window.finished.connect(self._settings_closed)
self.settings_window.show()
self.settings_window.raise_()
@@ -1068,8 +1169,29 @@ 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 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)
args = ipc.launcher() + ["--gui"]
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(
args,
creationflags=(subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP),
close_fds=True,
)
QApplication.instance().quit()
return
os.execv(args[0], args)
def shutdown(self):
@@ -1140,7 +1262,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)
@@ -1230,6 +1356,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()
+1
View File
@@ -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}",
+174 -8
View File
@@ -33,6 +33,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
@@ -57,6 +61,19 @@ QUIET_MIC_SECONDS = 10
QUIET_MIC_SHARE = 0.5
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."""
@@ -111,7 +128,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))
@@ -172,7 +190,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:
@@ -284,6 +302,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
@@ -312,7 +338,8 @@ class MeetingRecorder(QObject):
self._procs = []
for command, log in zip(commands, self._logs):
self._procs.append(subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=log, bufsize=0
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
creationflags=NO_WINDOW,
))
except (OSError, wave.Error) as exc:
# One of two capture processes may already be running, and a Mac
@@ -411,7 +438,7 @@ class MeetingRecorder(QObject):
running = [proc for proc in self._procs if proc.poll() is None]
for proc in running:
try:
proc.send_signal(signal.SIGINT)
_interrupt(proc)
except OSError:
pass
for proc in running:
@@ -838,13 +865,131 @@ 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.
# 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. 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'^(?:\[[^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$')
_DSHOW_ALTERNATIVE = re.compile(
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.
_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=<name>` 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():
"""[(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.
"""
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 = _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 []
device = target or _dshow_first_device()
if not device:
return []
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 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(
@@ -853,6 +998,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.",
)
@@ -865,13 +1011,33 @@ 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",
)
DSHOW = Sound(
record=_dshow_record,
meeting=_dshow_meeting,
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",
)
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():
+1
View File
@@ -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.",
+74 -7
View File
@@ -17,8 +17,10 @@ import json
import os
import shutil
import signal
import subprocess
import sys
import time
import webbrowser
from PyQt6.QtCore import QCoreApplication, QTimer
@@ -29,10 +31,12 @@ from . import cleanup
from . import config as cfg
from . import filetranscribe
from . import hotkey
from . import hub
from . import ipc
from . import integrate
from . import meeting
from . import paste
from . import update
from . import __version__
NOT_RUNNING = 3
@@ -136,6 +140,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(args[0], args)
@@ -299,6 +313,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)
@@ -643,7 +660,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"
@@ -760,11 +780,14 @@ def cmd_integrate(opts):
Run for you on every start, so this is for the two cases that start does
not cover: undoing it, and repairing it from a terminal after the AppImage
was moved while Dikte was not running.
was moved while Dikte was not running. On Windows the setup program wrote
the rest, and what is left for this is the switch it could only offer while
it was on the screen: typing it starts Dikte at sign-in, --remove stops it.
"""
if not integrate.packaged():
installer = "install.ps1" if sys.platform == "win32" else "./install.sh"
return fail(opts, "this is a checkout, not a downloaded build; "
"./install.sh writes those files here", 2)
f"{installer} writes those files here", 2)
try:
# force, because typing this is asking for it outright, where the same
# call on every start stands aside for an installation already there.
@@ -777,6 +800,33 @@ def cmd_integrate(opts):
f"{verb}:\n{listing}" if paths else "Nothing to change.")
def cmd_update(opts):
"""Whether a newer Dikte has been released, and where it is.
It looks and nothing more: what to do about the answer is a download page,
because the AppImage, the disk image, the Windows setup and a checkout are
four different installations and only their owner knows which one this is.
"""
try:
release = update.latest(refresh=True)
except hub.HubError as exc:
return fail(opts, exc)
# Written down even when there is nothing new, so that the application does
# not go and ask the same question an hour later.
update.remember(release)
waiting = update.newer(release.version)
payload = {"ok": True, "current": __version__, "latest": release.version,
"update": waiting, "url": release.url}
if not waiting:
return out(opts, payload,
f"Dikte {__version__} is the newest release.")
if opts.open:
webbrowser.open(release.url)
return out(opts, payload,
f"Dikte {release.version} is out; this is {__version__}.\n"
f"{release.url}")
def cmd_status(opts):
reply = ipc.send("status")
if reply is None:
@@ -802,9 +852,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)
@@ -1060,12 +1121,18 @@ def build_parser():
remove.set_defaults(func=cmd_shortcut)
integrated = leaf(subs, "integrate",
"menu entry, login item and command, for a downloaded build")
"menu entry, start at sign-in and command, "
"for a downloaded build")
integrated.add_argument("--remove", action="store_true",
help="take them away again")
integrated.set_defaults(func=cmd_integrate)
# --- the application --------------------------------------------------
updates = leaf(subs, "update", "whether a newer Dikte has been released")
updates.add_argument("--open", action="store_true",
help="open the release page in a browser")
updates.set_defaults(func=cmd_update)
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
for name, help_text in (("settings", "open the settings window"),
("restart", "reload the running instance"),
+3
View File
@@ -457,6 +457,9 @@ DEFAULTS = {
"overlay_corner": "bottom-left",
"keep_audio": False,
"history_limit": 200,
# A look at the releases page once a day, and nothing more than a look:
# what is found opens a browser, never an installer.
"update_check": True,
"file_timestamps": False,
"file_cleanup": True,
"file_cleanup_prompt": "", # empty -> language-specific default
+102 -39
View File
@@ -1,15 +1,19 @@
"""Transcribe an existing audio/video file with the same models.
ffmpeg converts whatever comes in to 16 kHz mono WAV, and for a hosted API to
mp3 on top of that. The upload limit is the only reason a file is ever cut up,
and uncompressed audio reaches it after ten minutes where mp3 takes an hour.
mp3 on top of that. Two things decide where a file is cut up: the upload limit,
which uncompressed audio reaches after ten minutes where mp3 takes an hour, and
the clock. An hour of audio in one request is minutes of work at the other end,
and the gateway in front of the model hangs up long before the answer comes
back, which arrives here as a 502 with the whole chunk lost. So a chunk is also
capped at MAX_CHUNK_SECONDS however small it is on disk.
That is worth the encoder, because a cut is not free. Whisper hears in thirty
second windows and decides for itself where one cue ends and the next begins; a
chunk that starts in the middle of a sentence can come back as one cue per
window, twenty seconds of text at a time, for the whole rest of the chunk. So
the file is cut as rarely as the limit allows, what is cut overlaps, and
stitch() drops the half that was heard twice.
A cut is not free, which is what the encoder buys and why nothing is cut more
finely than that. Whisper hears in thirty second windows and decides for itself
where one cue ends and the next begins; a chunk that starts in the middle of a
sentence can come back as one cue per window, twenty seconds of text at a time,
for the whole rest of the chunk. So what is cut overlaps, and stitch() drops
the half that was heard twice.
"""
import contextlib
@@ -19,6 +23,7 @@ import shutil
import subprocess
import tempfile
import threading
import time
import wave
from PyQt6.QtCore import QObject, pyqtSignal
@@ -29,10 +34,14 @@ from . import ggml
from .i18n import t
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room
MAX_CHUNK_SECONDS = 900 # as much audio as a hosted request can outlive
MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less
OVERLAP_SECONDS = 30 # a whisper window: how far back a chunk starts
WAV_CHUNK_SECONDS = 600 # 19 MB, for the caller that uploads the WAV itself
CLEANUP_CHUNK_CHARS = 12000 # keep each cleanup call comfortably small
HOSTED_TIMEOUT = 600 # a quarter hour of audio, with room for the upload
RETRIES = 3 # how many times one chunk is asked for in all
RETRY_WAIT = 5 # seconds before the second try, doubled after that
RATE = 16000
MIN_SUBTITLE_SECONDS = 1.5 # how long a cue with no end time of its own stays up
@@ -86,9 +95,40 @@ class FileTranscriber(QObject):
def _check(self):
self._abort.check()
def _wait(self, seconds):
"""Sleep on it, with the Stop button still able to get through."""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
self._check()
time.sleep(0.25)
self._check()
def _attempt(self, call, stage):
"""`call`, asked again when what failed was the network rather than us.
One chunk is a quarter hour of audio that took a minute to encode and a
minute to upload, so a gateway having a bad moment is worth waiting out
rather than throwing the run away over. `stage` is what the status line
said before the failure, put back once the wait is over.
"""
for attempt in range(1, RETRIES + 1):
self._check()
try:
return call()
except api.ApiError as exc:
if attempt == RETRIES or not exc.retryable:
raise
self.progress.emit(t(
"{error} Trying again ({attempt}/{total})…",
error=exc, attempt=attempt + 1, total=RETRIES))
self._wait(RETRY_WAIT * 2 ** (attempt - 1))
self.progress.emit(stage)
def _work(self, path, timestamps, do_cleanup):
conf = self.conf
workdir = None
pieces = []
segments = []
try:
if not shutil.which("ffmpeg"):
raise api.ApiError(t("ffmpeg not found. Install it to transcribe files."))
@@ -104,39 +144,36 @@ class FileTranscriber(QObject):
if len(chunks) > 1:
self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks)))
pieces = []
segments = []
for index, (chunk_path, offset) in enumerate(chunks, start=1):
self._check()
self.progress.emit(
t("Transcribing chunk {index}/{count}",
index=index, count=len(chunks))
if len(chunks) > 1 else t("Transcribing…")
)
stage = (t("Transcribing chunk {index}/{count}",
index=index, count=len(chunks))
if len(chunks) > 1 else t("Transcribing…"))
self.progress.emit(stage)
if timestamps:
segments = stitch(segments, [
(start + offset, end + offset, line)
for start, end, line in api.transcribe_segments(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
aborter=self._abort,
)
])
else:
pieces.append(api.transcribe(
heard = self._attempt(lambda: api.transcribe_segments(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
timeout=HOSTED_TIMEOUT,
aborter=self._abort,
))
), stage)
segments = stitch(segments, [
(start + offset, end + offset, line)
for start, end, line in heard
])
else:
pieces.append(self._attempt(lambda: api.transcribe(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
timeout=HOSTED_TIMEOUT,
aborter=self._abort,
), stage))
if timestamps:
pieces = [f"[{format_timestamp(start)}] {line}"
for start, _, line in segments]
text = "\n".join(pieces) if timestamps else " ".join(pieces)
text = _joined(pieces, segments, timestamps)
if do_cleanup and text:
self._check()
@@ -148,7 +185,16 @@ class FileTranscriber(QObject):
except Cancelled:
self.progress.emit(t("Stopped."))
except (api.ApiError, OSError, subprocess.SubprocessError, wave.Error) as exc:
self.failed.emit(str(exc))
# An hour of a long file already heard is not worth throwing away
# because the chunk after it failed, or because cleanup did. Hand
# over what there is, and say in the same breath where it stops.
partial = _joined(pieces, segments, timestamps)
if partial:
self.finished.emit(partial, segments)
self.failed.emit(t("{error} The transcript up to there is below.",
error=exc))
else:
self.failed.emit(str(exc))
finally:
self._local = None
if workdir:
@@ -181,12 +227,21 @@ class FileTranscriber(QObject):
self._local = ggml.llm if cleanup.provider(conf) == "local" else None
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
out = []
stage = t("Cleaning up…")
for block in split_text(text, timestamps):
self._check()
out.append(cleanup.run(block, conf, prompt, aborter=self._abort))
out.append(self._attempt(
lambda: cleanup.run(block, conf, prompt, aborter=self._abort), stage))
return ("\n" if timestamps else "\n\n").join(out)
def _joined(pieces, segments, timestamps):
"""The transcript as one string, out of whichever of the two is holding it."""
if timestamps:
pieces = [f"[{format_timestamp(start)}] {line}" for start, _, line in segments]
return "\n".join(pieces) if timestamps else " ".join(pieces)
def format_timestamp(seconds):
seconds = int(seconds)
hours, rest = divmod(seconds, 3600)
@@ -283,6 +338,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.
@@ -308,13 +364,20 @@ def wav_seconds(wav_path):
def chunk_seconds(path, duration):
"""How many seconds of this audio fit in one request, or 0 when all of it does.
Measured rather than worked out: what an encoder makes of an hour of speech
depends on the speech, and the file on disk is the only honest answer.
Whichever of the two limits bites first. How much fits under the upload
limit is measured rather than worked out: what an encoder makes of an hour
of speech depends on the speech, and the file on disk is the only honest
answer. The other limit is MAX_CHUNK_SECONDS, and it is the one that catches
a long file at this bitrate: an hour and a half of mp3 is two chunks by size
and one of them is an hour of audio in a single request, which no hosted
gateway stays on the line for.
"""
size = os.path.getsize(path)
if size <= UPLOAD_LIMIT or duration <= 0:
if duration <= 0:
return 0.0
return max(60.0, duration * UPLOAD_LIMIT / size * 0.95)
size = os.path.getsize(path)
fits = duration * UPLOAD_LIMIT / size * 0.95 if size > UPLOAD_LIMIT else duration
seconds = max(60.0, min(fits, MAX_CHUNK_SECONDS))
return 0.0 if seconds >= duration else seconds
def split_wav(wav_path, workdir, seconds=WAV_CHUNK_SECONDS, overlap=OVERLAP_SECONDS):
+73 -12
View File
@@ -43,6 +43,7 @@ import threading
import time
import urllib.error
import urllib.request
import zipfile
from . import hub
from . import paths
@@ -154,11 +155,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
@@ -168,11 +172,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:
@@ -218,9 +228,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):
@@ -233,6 +245,20 @@ 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.
#
# 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")
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",)
@@ -278,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():
@@ -286,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
@@ -344,7 +380,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))
@@ -501,6 +537,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.
@@ -602,6 +658,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}",
@@ -699,8 +757,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:
+189 -13
View File
@@ -440,15 +440,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 -------------------------------------------
# The four ways a combination can reach Dikte. Everything below asks backend()
# The five ways a combination can reach Dikte. Everything below asks backend()
# rather than looking at the session itself, so the name shown, the status read
# back, what Install writes and what the installer promises cannot disagree
# about which one this session got.
KDE = "kde"
GNOME = "gnome"
MACOS = "macos"
WINDOWS = "windows"
LISTENER = "listener"
@@ -456,6 +616,10 @@ def _macos():
return sys.platform == "darwin"
def _windows():
return sys.platform == "win32"
def backend():
"""Which shortcut mechanism this session has.
@@ -467,6 +631,8 @@ def backend():
"""
if _macos():
return MACOS
if _windows():
return WINDOWS
names = os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":")
names = [name.strip() for name in names if name.strip()]
if any("gnome" in name for name in names) and shutil.which("gsettings"):
@@ -596,7 +762,11 @@ 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 default_combo(which):
@@ -613,16 +783,20 @@ def default_combo(which):
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 and the plain listener do not: there is
nothing to install, nothing to remove, and Settings should not offer either.
survives Dikte being closed. macOS, Windows and the plain listener do not:
there is nothing to install, nothing to remove, and Settings should not
offer either.
"""
return backend() in (KDE, GNOME)
@@ -631,7 +805,7 @@ 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 the other two never had one to write.
up as it is written, and the others never had one to write.
"""
return backend() == KDE
@@ -644,7 +818,7 @@ def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
if which == KDE:
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
_REGISTERED[desktop_id] = shortcut
if which == MACOS:
if which in (MACOS, WINDOWS):
return True, t(
"Shortcut saved: {shortcut}\nDikte holds this one itself while it "
"is running, so it works as soon as the settings are saved.",
@@ -686,6 +860,8 @@ def desktop_name():
which = backend()
if which == MACOS:
return "macOS"
if which == WINDOWS:
return "Windows"
if which == GNOME:
return "GNOME"
if which == KDE:
@@ -776,11 +952,11 @@ 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 backend() != KDE:
# Nowhere else has a list to read. macOS answers the question by
# refusing the registration, which CarbonHotkey reports when it asks
# for the key; the other two would only be reading a file their session
# never looks at, and a leftover one from a Plasma install the user has
# since left would refuse perfectly good combinations.
# Nowhere else has a list to read. macOS and Windows answer the question
# by refusing the registration, which their listeners report when they
# ask for the key; the other two would only be reading a file their
# session never looks at, and a leftover one from a Plasma install the
# user has since left would refuse perfectly good combinations.
return []
try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
+16
View File
@@ -136,6 +136,22 @@ def release(repo, tag="latest", refresh=False):
return data.get("tag_name") or tag, assets
def newest_release(repo, refresh=False):
"""(tag, page, published) for the newest release of a repository.
release() above is for taking a file out of one and insists on there being
files to take; this is for the number, which a release with nothing
attached answers just as well. GitHub keeps prereleases out of "latest" on
its own, which is what leaves the nightly build off this answer.
"""
data = _fetch(f"gh-newest-{repo}",
f"{GITHUB_API}/repos/{repo}/releases/latest", refresh=refresh)
if not isinstance(data, dict) or not data.get("tag_name"):
raise HubError(t("{repo} has published no release.", repo=repo))
return (data["tag_name"], data.get("html_url") or "",
data.get("published_at") or "")
def files(repo, revision="main", refresh=False):
"""[Item] for every file in a Hugging Face repository.
+48
View File
@@ -109,6 +109,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}",
@@ -188,6 +192,25 @@ TR = {
"Silence threshold": "Sessizlik eşiği",
"Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
# --- updates --------------------------------------------------------
"Updates": "Güncelleme",
"Look for a newer version once a day": "Günde bir kez yeni sürüm var mı diye bak",
"Dikte only looks. What it finds opens the release page in your browser; "
"it downloads and installs nothing by itself.":
"Dikte yalnızca bakar. Bulduğu şey tarayıcında sürüm sayfasını açar; "
"kendi başına hiçbir şey indirmez ve kurmaz.",
"Check now": "Şimdi bak",
"Looking…": "Bakılıyor…",
"Open the release page": "Sürüm sayfasını",
"This is Dikte {version}.": "Buradaki sürüm Dikte {version}.",
"Dikte {version} is the newest release.": "En yeni sürüm zaten bu: Dikte {version}.",
"Dikte {version} is out; this is {current}.":
"Dikte {version} çıkmış; buradaki sürüm {current}.",
"Dikte {version} is out…": "Dikte {version} çıkmış…",
"Dikte {version} is out. The tray menu has the release page.":
"Dikte {version} çıkmış. Sürüm sayfası tepsi menüsünde.",
"{repo} has published no release.": "{repo} için yayımlanmış sürüm yok.",
# --- settings: api --------------------------------------------------
"Keys": "Anahtarlar",
"Speech to text": "Sesi yazıya çevirme",
@@ -293,6 +316,10 @@ TR = {
"Converting audio…": "Ses dönüştürülüyor…",
"Splitting into {count} chunks…": "{count} parçaya bölünüyor…",
"Transcribing chunk {index}/{count}": "{index}/{count} parça yazıya çevriliyor…",
"{error} Trying again ({attempt}/{total})…":
"{error} Yeniden deneniyor ({attempt}/{total})…",
"{error} The transcript up to there is below.":
"{error} Oraya kadar çevrilen metin aşağıda.",
"Done: {chars} characters.": "Bitti: {chars} karakter.",
"Stopped.": "Durduruldu.",
"Failed: {error}": "Başarısız: {error}",
@@ -353,6 +380,11 @@ TR = {
"meantime.":
"Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey "
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"Dikte asks Windows for these combinations itself, while it is running. "
"Nothing is installed, and no other application receives them in the "
"meantime.":
"Dikte bu kombinasyonları çalışırken Windows'tan kendisi ister. Hiçbir şey "
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"{desktop} keeps no shortcut registry, so Dikte listens for these "
"combinations itself while it is running. Your user has to be able to read "
"/dev/input for that, and the focused application receives the keys as "
@@ -392,6 +424,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"
@@ -643,6 +681,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.":
+124 -9
View File
@@ -6,6 +6,12 @@ downloaded an AppImage or dragged Dikte.app out of a disk image ran no
installer at all, so the application writes those files itself, on its first
run and again whenever the file it was started from has moved.
Windows is the one platform where the download is an installer, and it wrote
the Start Menu entry, the `dikte` command and the uninstaller as it ran. What
is left here is the one thing it can only ask about once: whether Dikte starts
when you sign in. `dikte integrate` turns that on later and `--remove` turns it
off, and a plain start only repairs an entry that is already there.
Nothing here runs from a checkout. install.sh has already written the same
files there, pointing at the interpreter that checkout was installed against,
and overwriting them with a guess would be a downgrade.
@@ -34,6 +40,15 @@ import sys
AGENT_ID = "io.github.yusufipk.dikte"
ICON_NAME = "dikte"
DESKTOP_FILE = "dikte.desktop"
MACOS_COMMAND_MARKER = "# Written by Dikte itself. Delete it to be rid of it.\n"
# The windowed executable the Windows setup installs, beside the console one
# the `dikte` command runs.
WINDOWS_APP = "Dikte.exe"
# Where Windows keeps what to start when somebody signs in, and the name the
# setup program files Dikte's entry under. Both halves have to agree: the
# uninstaller deletes this value, and so does `dikte integrate --remove`.
RUN_KEY = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
RUN_VALUE = "Dikte"
def packaged():
@@ -55,6 +70,13 @@ def target():
for parent in executable.parents:
if parent.suffix == ".app":
return parent
if sys.platform == "win32":
# The windowed executable, whichever of the two is running: the console
# one is what the `dikte` command names, and a sign-in that started
# that one would open a console window nobody asked for.
windowed = executable.with_name(WINDOWS_APP)
if windowed.is_file():
return windowed
return executable
@@ -151,11 +173,12 @@ def use_system_certificates():
def bundled_bin():
"""Where a build keeps the helper programs it carries, if it carries any.
The disk image ships an ffmpeg because macOS records through one and has
nothing like it preinstalled, so a Mac that downloaded Dikte and nothing
else would otherwise not be able to record at all. The AppImage carries
none: Linux records through parec or pw-record, which come with the sound
server, and the distributions all package ffmpeg for the rest.
The disk image and the Windows setup both ship an ffmpeg, because both
systems record through one and neither has anything like it preinstalled,
so a machine that downloaded Dikte and nothing else would otherwise not be
able to record at all. The AppImage carries none: Linux records through
parec or pw-record, which come with the sound server, and the distributions
all package ffmpeg for the rest.
"""
binary = pathlib.Path(sys.executable).parent
if sys.platform == "darwin" and binary.name == "MacOS":
@@ -209,6 +232,8 @@ def install(force=False):
"""
if sys.platform == "darwin":
return _macos_install(target(), force)
if sys.platform == "win32":
return _windows_install(target(), force)
return _linux_install(target(), force)
@@ -216,6 +241,8 @@ def remove():
"""Take them away again. The paths that were there to delete."""
if sys.platform == "darwin":
return _macos_remove()
if sys.platform == "win32":
return _windows_remove()
return _linux_remove()
@@ -398,6 +425,20 @@ def _agent_path():
return pathlib.Path.home() / "Library" / "LaunchAgents" / f"{AGENT_ID}.plist"
def _macos_command_path():
return pathlib.Path.home() / ".local" / "bin" / "dikte"
def _macos_command_is_ours(command):
"""Whether this is the wrapper a downloaded Mac build wrote itself."""
try:
return command.is_file() and MACOS_COMMAND_MARKER in command.read_text(
encoding="utf-8"
)
except (OSError, UnicodeDecodeError):
return False
def _agent_plist(app):
"""Through `open` rather than the executable inside the bundle, so that the
process is one LaunchServices started: that is what gives it the bundle's
@@ -449,13 +490,13 @@ def _macos_install(app, force=False):
# The command, as a wrapper rather than a symlink: the executable has to be
# run from inside the bundle for macOS to file its permissions under Dikte,
# and a symlink somewhere else is a different process to macOS.
command = pathlib.Path.home() / ".local" / "bin" / "dikte"
command = _macos_command_path()
binary = app / "Contents" / "MacOS" / "Dikte"
marker = "# Written by Dikte itself. Delete it to be rid of it.\n"
script = f'#!/bin/sh\n{marker}exec {shlex.quote(str(binary))} "$@"\n'
script = (f'#!/bin/sh\n{MACOS_COMMAND_MARKER}'
f'exec {shlex.quote(str(binary))} "$@"\n')
# install-mac.sh writes its own wrapper here, naming the checkout's Python.
# Ours only replaces a wrapper it wrote before, or nothing at all.
ours = command.exists() and marker in command.read_text(encoding="utf-8")
ours = _macos_command_is_ours(command)
if (not command.exists() or ours or force) and _write(command, script):
command.chmod(0o755)
written.append(command)
@@ -470,6 +511,10 @@ def _macos_remove():
capture_output=True, check=False)
agent.unlink()
gone.append(agent)
command = _macos_command_path()
if _macos_command_is_ours(command):
command.unlink()
gone.append(command)
return gone
@@ -481,3 +526,73 @@ def _launchctl_reload(agent):
capture_output=True, check=False)
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
capture_output=True, check=False)
# --- Windows --------------------------------------------------------------
#
# The setup program did the installing here, which leaves one question a
# wizard can only ask while it is on the screen: whether Dikte starts when you
# sign in. That answer is a registry value, so it is one both sides can write:
# the setup program sets it from the tick box, the uninstaller deletes it
# however it got there, and the two functions below are the same switch from a
# terminal, long after the wizard is gone.
def _run_entry():
"""What the autostart entry names, or "" when there is none."""
import winreg
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
value, kind = winreg.QueryValueEx(key, RUN_VALUE)
except OSError:
return ""
return value if kind == winreg.REG_SZ and isinstance(value, str) else ""
def _write_run_entry(command):
import winreg
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
winreg.SetValueEx(key, RUN_VALUE, 0, winreg.REG_SZ, command)
def _delete_run_entry():
"""Whether there was one to delete."""
import winreg
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0,
winreg.KEY_SET_VALUE) as key:
winreg.DeleteValue(key, RUN_VALUE)
except OSError:
return False
return True
def _run_entry_name():
"""What to call the value in a listing, since it is not a file."""
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
def _windows_install(app, force=False):
"""Point the autostart entry at this build. What changed.
Only `force`, which is what typing `dikte integrate` means, creates one.
The call on every start repairs an entry that is already there and names an
executable somewhere else, which is what an installation moved to another
drive or reinstalled into another directory leaves behind; somebody who
unticked the box in the wizard, or turned it off since, is not asked again
by every start.
"""
command = f'"{app}"'
current = _run_entry()
if not current and not force:
return []
if current == command:
return []
_write_run_entry(command)
return [_run_entry_name()]
def _windows_remove():
"""Stop starting at sign-in. The Start Menu entry, the command and the
files are the uninstaller's, and Add/Remove Programs is where they go."""
return [_run_entry_name()] if _delete_run_entry() else []
+15 -1
View File
@@ -15,7 +15,11 @@ import sys
from PyQt6.QtNetwork import QLocalSocket
SERVER_NAME = "dikte-" + str(os.getuid())
from . import integrate
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.
@@ -41,9 +45,19 @@ def launcher():
under a fresh /tmp path every run, so what a shortcut written today has to
say is the .AppImage file the user keeps, not the binary inside this run's
mount. APPIMAGE is what the runtime puts that path in.
The Windows build is two executables over one program, and the one to start
again is always the windowed one: `dikte toggle` typed at a terminal runs
the console one, and the application it leaves running should no more be
tied to that terminal than the one the Start Menu starts.
"""
if not getattr(sys, "frozen", False):
return [sys.executable, script_path()]
if sys.platform == "win32":
windowed = os.path.join(os.path.dirname(sys.executable),
integrate.WINDOWS_APP)
if os.path.isfile(windowed):
return [windowed]
return [os.environ.get("APPIMAGE") or sys.executable]
+3 -2
View File
@@ -70,9 +70,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
+188 -1
View File
@@ -354,6 +354,166 @@ def _macos_press(shortcut, delay, focus=None):
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"
# 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"))
try:
user32.EmptyClipboard()
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(
@@ -386,6 +546,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
@@ -407,6 +578,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"):
@@ -451,6 +624,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:
@@ -478,6 +654,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}.",
@@ -497,7 +676,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)
+13 -5
View File
@@ -16,7 +16,8 @@ import pathlib
import sys
def _xdg(var, default):
def _env(var, default):
"""The directory a variable names, or the one it stands in for."""
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
@@ -24,13 +25,20 @@ def directories(platform=None):
"""(settings, data), in the two places this system keeps them.
macOS keeps both in the one directory a Mac user's backup already knows
about. Everywhere else they are separate and follow the XDG variables.
about. Windows keeps them apart on purpose: settings roam with the account,
and several gigabytes of models are exactly what a roaming profile must not
carry. Everywhere else they are separate and follow the XDG variables.
"""
if (platform or sys.platform) == "darwin":
here = platform or sys.platform
if here == "darwin":
support = pathlib.Path.home() / "Library/Application Support/Dikte"
return support, support
return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte",
_xdg("XDG_DATA_HOME", "~/.local/share") / "dikte")
if here == "win32":
roaming = _env("APPDATA", "~/AppData/Roaming")
local = _env("LOCALAPPDATA", "~/AppData/Local")
return roaming / "Dikte", local / "Dikte"
return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte",
_env("XDG_DATA_HOME", "~/.local/share") / "dikte")
CONFIG_DIR, DATA_DIR = directories()
+89
View File
@@ -13,6 +13,7 @@ from PyQt6.QtWidgets import (
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
from . import __version__
from . import api
from . import assistant
from . import audio
@@ -21,9 +22,11 @@ from . import config as cfg
from . import filetranscribe
from . import ggml
from . import hotkey
from . import hub
from . import ipc
from . import meeting
from . import paste
from . import update
from .filetranscribe import FileTranscriber
from .i18n import t
@@ -512,11 +515,16 @@ class LocalModelBox(QGroupBox):
class SettingsWindow(QDialog):
applied = pyqtSignal()
# A newer release this window's own check found, so that the tray icon
# hears about it from here rather than waiting for its own next check.
update_found = pyqtSignal(object)
_models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str)
# Which key was tested, whether it worked, and what to write under it.
_test_done = pyqtSignal(str, bool, str)
# The release that was found, or None, and what went wrong instead.
_update_checked = pyqtSignal(object, str)
def __init__(self, conf, meetings=None, parent=None):
super().__init__(parent)
@@ -533,6 +541,9 @@ class SettingsWindow(QDialog):
self._key_fields = {}
self._testers = {}
self._shown_provider = ""
# Where "Open the release page" goes: the release itself once a check
# has named one, and the page that redirects to the newest until then.
self._release_url = update.RELEASES_PAGE
self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings"))
@@ -567,6 +578,7 @@ class SettingsWindow(QDialog):
self._models_loaded.connect(self._on_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
self._test_done.connect(self._on_test_done)
self._update_checked.connect(self._on_update_checked)
self.transcriber.progress.connect(self._on_file_progress)
self.transcriber.finished.connect(self._on_file_finished)
self.transcriber.failed.connect(self._on_file_failed)
@@ -698,6 +710,23 @@ class SettingsWindow(QDialog):
t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR))
)
form.addRow("", self.keep_audio)
self.update_check = QCheckBox(t("Look for a newer version once a day"))
self.update_check.setToolTip(
t("Dikte only looks. What it finds opens the release page in your "
"browser; it downloads and installs nothing by itself.")
)
form.addRow(t("Updates"), self.update_check)
self.update_status = WrappedLabel("")
self.update_page = QPushButton(t("Open the release page"))
self.update_page.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl(self._release_url))
)
self.update_now = QPushButton(t("Check now"))
self.update_now.clicked.connect(self._check_for_update)
form.addRow("", self._row(self.update_status, self.update_page,
self.update_now))
return page
def _api_tab(self):
@@ -1100,6 +1129,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 "
@@ -1369,6 +1410,12 @@ class SettingsWindow(QDialog):
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
elif hotkey.backend() == hotkey.WINDOWS:
explanation = t(
"Dikte asks Windows for these combinations itself, while it is "
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
else:
# The desktops nobody writes a backend for. Saying "installed" here
# would be the old bug in words: there is no registry, the listener
@@ -1550,6 +1597,8 @@ class SettingsWindow(QDialog):
self.silence_db.setValue(int(conf["silence_db"]))
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
self.keep_audio.setChecked(conf["keep_audio"])
self.update_check.setChecked(conf["update_check"])
self._show_update(update.pending())
for name, who in cfg.TRANSCRIBERS.items():
self._key_fields[name].setText(conf[who.key])
@@ -1643,6 +1692,7 @@ class SettingsWindow(QDialog):
conf["silence_db"] = float(self.silence_db.value())
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
conf["keep_audio"] = self.keep_audio.isChecked()
conf["update_check"] = self.update_check.isChecked()
provider = self.transcribe_provider.currentData() or "local"
if provider in self._models:
@@ -1873,6 +1923,45 @@ class SettingsWindow(QDialog):
button.setEnabled(True)
answer.setText(("" if ok else "") + message)
# ---- updates ---------------------------------------------------------
def _check_for_update(self):
"""The button, which asks GitHub whatever the daily clock says."""
self.update_now.setEnabled(False)
self.update_status.setText(t("Looking…"))
def work():
try:
self._update_checked.emit(update.check(force=True), "")
except hub.HubError as exc:
self._update_checked.emit(None, str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_update_checked(self, release, error):
self.update_now.setEnabled(True)
if error:
self.update_status.setText(error)
return
self._show_update(release, asked=True)
if release is not None:
self.update_found.emit(release)
def _show_update(self, release, asked=False):
"""What the line under the checkbox says, and whether the page button
is on it. `release` is None when this build is the newest one, and
`asked` is what tells "nothing new" from "nobody has looked yet"."""
self.update_page.setVisible(release is not None)
if release is None:
self.update_status.setText(
t("Dikte {version} is the newest release.", version=__version__)
if asked else t("This is Dikte {version}.", version=__version__))
return
self._release_url = release.url
self.update_status.setText(
t("Dikte {version} is out; this is {current}.",
version=release.version, current=__version__))
# ---- audio file ------------------------------------------------------
def _choose_file(self):
+47 -6
View File
@@ -23,9 +23,10 @@ outwards, which stands out on a dark bar and stays readable on a light one.
"""
import pathlib
import struct
import sys
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtCore import QBuffer, QPointF, QRectF, Qt
from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
QPen, QPixmap)
@@ -210,6 +211,9 @@ APP_ICON_SIZES = (16, 32, 128, 256, 512)
# What an XDG icon theme is asked for: a menu wants 48, a task bar 22 or 24, a
# file dialog 16, and something scaling for a HiDPI panel wants the big ones.
HICOLOR_SIZES = (16, 22, 24, 32, 48, 64, 128, 256)
# What goes into the .ico: the Windows shell picks the nearest of these itself,
# and 256 is the one the large view in Explorer and the setup program read.
ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
def app_pixmap(size):
@@ -292,26 +296,63 @@ def write_hicolor(directory, name="dikte"):
return written
def write_ico(path):
"""Write the Windows icon, every size in the one file. The path it wrote.
An .ico is a directory of images and a run of image data after it, and
since Vista each image may be a PNG rather than the bitmap-and-mask pair
the format started with. PNGs are what Qt can already produce, so the
twenty bytes of header per size are the whole of the work, and it saves
both a build dependency and an icon file in the repository.
"""
path = pathlib.Path(path)
images = []
for size in ICO_SIZES:
buffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.WriteOnly)
app_pixmap(size).save(buffer, "PNG")
images.append((size, bytes(buffer.data())))
buffer.close()
# 0, then 1 for an icon rather than a cursor, then the count.
header = struct.pack("<HHH", 0, 1, len(images))
offset = len(header) + 16 * len(images)
entries, data = b"", b""
for size, png in images:
# A side of 256 is written as 0: the field is one byte, and the format
# spends it on the sizes below that rather than on the one above.
entries += struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0,
1, 32, len(png), offset)
offset += len(png)
data += png
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(header + entries + data)
return path
def _main(argv):
"""`trayicon.py <path>.iconset` for install-mac.sh, `--hicolor <dir>` for
install.sh.
install.sh, `--ico <path>` for the Windows build.
A QGuiApplication has to exist before a QPixmap can, and offscreen because
this runs from a shell script with no window to open.
"""
hicolor = len(argv) == 3 and argv[1] == "--hicolor"
if not hicolor and len(argv) != 2:
flag = argv[1] if len(argv) == 3 else ""
if flag not in ("--hicolor", "--ico") and len(argv) != 2:
print("usage: trayicon.py <directory>.iconset\n"
" trayicon.py --hicolor <icon directory>", file=sys.stderr)
" trayicon.py --hicolor <icon directory>\n"
" trayicon.py --ico <path>.ico", file=sys.stderr)
return 2
from PyQt6.QtGui import QGuiApplication
QGuiApplication.setAttribute(
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
try:
if hicolor:
if flag == "--hicolor":
for path in write_hicolor(argv[2]):
print(path)
elif flag == "--ico":
print(write_ico(argv[2]))
else:
print(write_iconset(argv[1]))
finally:
+158
View File
@@ -0,0 +1,158 @@
"""Whether a newer Dikte has been published, and where to get it.
GitHub is asked for the newest release, its number is held against the one this
build carries, and that is where it stops. Nothing is downloaded and nothing is
replaced. The four downloads are installed in four different ways, and three of
those belong to the platform rather than to Dikte: a Mac bundle is dragged into
Applications and cannot rewrite itself while it is running, the Windows setup
is an installer with an uninstall entry of its own, an AppImage is a single
file kept wherever its owner keeps it, and a checkout is updated with git. A
program that guessed at all four would be wrong on at least one of them, and
being wrong there means an installation somebody has to repair by hand. So the
answer ends in a browser, on the release page, where the same download that was
installed the first time is waiting.
The clock is kept in a file of its own rather than in the settings. A check
runs while the settings window may be open, and a background write into
config.json is exactly what would overwrite a setting somebody is in the middle
of changing.
Nothing here imports Qt or the rest of the application: `dikte update` at a
terminal and the timer behind the tray icon ask the same three questions of the
same module.
"""
import collections
import itertools
import json
import time
from . import __version__
from . import hub
from . import paths
REPO = "yusufipk/dikte"
# Where somebody is sent. GitHub redirects this to whatever the newest release
# is, so it stays right without anybody writing a number into it.
RELEASES_PAGE = f"https://github.com/{REPO}/releases/latest"
# Once a day. A release happens every few weeks at best, and a question nobody
# is waiting on is not one to ask GitHub on every start.
INTERVAL = 24 * 3600
# When the last check was, what it found, and which version has already been
# announced. In the data directory rather than the config one: it is not a
# setting, nobody edits it, and losing it costs one extra request.
STATE_FILE = paths.DATA_DIR / "update.json"
Release = collections.namedtuple("Release", "version url published")
def _numbers(version):
"""(1, 0, 2) for "v1.0.2", "1.0.2" and "1.0.2-dev.abc1234" alike.
Empty for anything that does not start with a number, which is what a tag
naming something other than a version comes back as.
"""
number = str(version or "").strip().lstrip("vV").split("-")[0].split("+")[0]
parts = []
for piece in number.split("."):
digits = "".join(itertools.takewhile(str.isdigit, piece))
if not digits:
break
parts.append(int(digits))
return tuple((parts + [0, 0, 0])[:3]) if parts else ()
def newer(there, here=""):
"""Whether the release numbered `there` is one this build has not got.
Only the numbers are compared, and what follows them is dropped. A build
off master carries the released number with its commit after it
(1.0.1-dev.abc1234), and that build is ahead of 1.0.1 rather than behind
it; read as a version suffix it would be behind, and every nightly would be
told to update to the release it was already past.
"""
theirs = _numbers(there)
return bool(theirs) and theirs > _numbers(here or __version__)
def state():
"""What the last check wrote down; empty when there has never been one."""
try:
stored = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return stored if isinstance(stored, dict) else {}
def _store(**changes):
stored = state()
stored.update(changes)
try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(stored), encoding="utf-8")
except OSError:
pass # a check that cannot be written down still happened
return stored
def due(now=0):
"""Whether a day has gone by since the last time anybody asked."""
return (now or time.time()) - float(state().get("checked") or 0) >= INTERVAL
def latest(refresh=False):
"""The newest published release, asked for outright. Raises HubError."""
tag, url, published = hub.newest_release(REPO, refresh=refresh)
return Release(tag.lstrip("vV"), url or RELEASES_PAGE, published)
def remember(release):
"""Write down that a check has just happened, and what it found."""
_store(checked=time.time(), version=release.version, url=release.url,
published=release.published)
def pending():
"""The newer release the last check found, without asking anybody.
What the tray icon is built from: the answer has to be there the moment it
appears, and a request on the way to the screen is a request nobody has
time for.
"""
stored = state()
version = stored.get("version") or ""
if not newer(version):
return None
return Release(version, stored.get("url") or RELEASES_PAGE,
stored.get("published") or "")
def check(force=False):
"""A newer release, or None when there is nothing to say.
The scheduled half: it asks only when a day has gone by, and answers from
what the last check found in between. `force` is the button in Settings and
the command line, which ask whatever the clock says.
The clock here is the only throttle. Once it has decided to ask, it asks
for real rather than reading hub.py's few hours of cache, which is there to
keep a settings window from fetching the same model list twice in an
evening and would only ever answer this with something it already knew.
"""
if not force and not due():
return pending()
release = latest(refresh=True)
remember(release)
return release if newer(release.version) else None
def announced():
"""The version somebody has already been shown a notification about."""
return state().get("announced") or ""
def mark_announced(version):
"""Said once. A daily check must not be a daily interruption."""
_store(announced=version)