diff --git a/README.md b/README.md index c195318..fe36166 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ Meetings are not supported there yet; the details are in the two global shortcuts, whose keys are its two arguments, or the ones already in your settings when it is given none. `./scripts/update.sh` pulls and puts all of that back; `./scripts/uninstall.sh` takes it away again and leaves your settings -and dictations alone unless you pass `--purge`. +and dictations alone unless you pass `--purge`. Dikte looks at the releases page +once a day and puts a line in the tray menu when a newer version is out, which +opens the page rather than installing anything; the General tab turns that off +or runs it on the spot. Speech to text and cleanup each pick a provider in the settings window, and both run here by default, on models of your own. The cloud is the other option: @@ -130,6 +133,7 @@ set next to it. | Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | | Settings | Tray menu → *Settings*, or `dikte settings` | +| Look for a newer version | General tab → *Check now*, or `dikte update` | | Reload after an update | Tray menu → *Restart*, or `dikte restart` | | Quit | Tray menu → *Quit*, or `dikte quit` | @@ -240,6 +244,7 @@ api.py transcription and cleanup requests (stdlib only) cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving hub.py what GitHub and Hugging Face have on offer today +update.py whether a newer release is out, and the page it is on worker.py transcribe → clean up → clipboard → paste vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps diff --git a/README.tr.md b/README.tr.md index 65a50d1..5e88c69 100644 --- a/README.tr.md +++ b/README.tr.md @@ -104,7 +104,9 @@ toplantı kaydı henüz yok, ayrıntılar [Windows README](README.windows.md)'si başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge` -demedikçe ayarlarına ve diktelerine dokunmaz. +demedikçe ayarlarına ve diktelerine dokunmaz. Dikte sürüm sayfasına günde bir +kez bakar ve yeni sürüm çıkmışsa tepsi menüsüne bir satır koyar; o satır bir şey +kurmaz, sayfayı açar. Genel sekmesi bu denetimi kapatır ya da anında çalıştırır. Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu @@ -127,6 +129,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin. | Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | | Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` | +| Yeni sürüm var mı bak | Genel sekmesi → *Şimdi bak*, ya da `dikte update` | | Güncelleme sonrası yeniden yükle | Tepsi menüsü → *Yeniden başlat*, ya da `dikte restart` | | Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` | @@ -234,6 +237,7 @@ api.py transkript ve temizleme istekleri (yalnız stdlib) cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma hub.py GitHub ve Hugging Face'te bugün ne olduğu +update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada worker.py transkript → temizleme → pano → yapıştırma vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları diff --git a/dikte/__init__.py b/dikte/__init__.py index b487fc9..c95ff63 100644 --- a/dikte/__init__.py +++ b/dikte/__init__.py @@ -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" diff --git a/dikte/api.py b/dikte/api.py index 0c76b80..7a0e78f 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -58,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): @@ -218,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): @@ -234,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 @@ -318,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, diff --git a/dikte/app.py b/dikte/app.py index ceaa9f5..891c359 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -34,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 @@ -45,11 +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): @@ -116,6 +151,12 @@ class Dikte: # Which recording is the current one, so a timer set for the run that # started it cannot stop the one that came after. self._run_id = 0 + # The application that was in front when the recording started, which + # is where the transcript is meant to go, and the timer watching for + # the moment it has to be put back there. macOS only; see + # _give_the_front_back. + self.front_before = None + self._front_watch = None self.overlay = Overlay(self.conf["overlay_corner"]) # The agent's indicator sits on top of the dictation one when both are @@ -171,6 +212,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() @@ -223,6 +275,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) @@ -239,6 +296,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): @@ -578,9 +636,19 @@ class Dikte: timer.restart() return False + def _the_front(self): + """The application a recording is about to start from, or None. + + Asked before the indicator goes up rather than alongside the + microphone: putting a window on screen can take the front as well, and + once it has, the only answer left to the question is Dikte. + """ + return mac_window.frontmost_pid() if sys.platform == "darwin" else None + def start(self): if self.state != IDLE or self.recording: return + self.front_before = self._the_front() self.overlay.show_recording() self._begin_recording(DICTATION) # A recorder that could not start has already said so, synchronously, @@ -594,6 +662,7 @@ class Dikte: def start_ask(self): if self.ask_state != IDLE or self.recording: return + self.front_before = self._the_front() self.ask_overlay.show_recording(asking=True) self._begin_recording(ASK) if not self.recorder.active: @@ -608,6 +677,80 @@ class Dikte: self.elapsed.restart() self.ticker.start() self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"]) + self._give_the_front_back(self.front_before) + + def _give_the_front_back(self, was_in_front): + """Hand the front back to whoever had it when the recording started. + + On macOS a recording goes through ffmpeg's avfoundation input, and + opening a capture session there brings the process that did it to the + front. ffmpeg is a child of Dikte with no bundle of its own, so the + system credits the move to Dikte: the window the user was typing in + loses the front, its caret stops, its title bar greys out, and the + Cmd+V at the end of the dictation has nowhere to land. Measured with a + TextEdit document in front: + + press the shortcut front = TextEdit + recorder.start returns front = TextEdit + 89 ms later front = Dikte + + Nothing about the capture session can be asked not to do this. It is + not a window of ours and no flag reaches it. Starting ffmpeg in its own + session, and clearing __CFBundleIdentifier from its environment, were + both tried and both measured to make no difference. So it is undone + instead. The move lands a moment after the process starts rather than + during the call, hence the short watch rather than one attempt: it + gives up as soon as it has put the front back, and in any case after a + second and a half, which is longer than the microphone has ever taken + to open. + + Silent off macOS, and silent when the recording started from Dikte + itself: there is nothing to give back. + """ + # One watch at a time. A second recording started before the first + # watch had finished would otherwise leave two of them running, and the + # older one would put the front back where the older recording + # started, which by then is the wrong window. + if self._front_watch is not None: + self._front_watch.stop() + self._front_watch = None + if not was_in_front or was_in_front == os.getpid(): + return + deadline = time.monotonic() + 1.5 + watch = QTimer(self.app) + # Ten milliseconds because the front is already gone by the time this + # notices, and every tick it waits is a tick of the user's window drawn + # inactive: at forty the title bar visibly blinks, at ten it does not. + # Two messages to AppKit per tick, for at most a second and a half. + watch.setInterval(10) + # activateWithOptions: answers whether macOS accepted the request, not + # whether the other application is already back in front. Keep the + # watch alive until that asynchronous handoff is observable; on Intel + # Macs it can take hundreds of milliseconds after the call returned. + restore_requested = False + + def look(): + nonlocal restore_requested + if time.monotonic() > deadline: + self._stop_watching_the_front() + return + if mac_window.is_frontmost(): + if not restore_requested: + restore_requested = mac_window.activate(was_in_front) + elif restore_requested: + # The request has landed. Stop only now, rather than as soon + # as AppKit accepted it, so a delayed or failed handoff stays + # under observation until the deadline guard above. + self._stop_watching_the_front() + + watch.timeout.connect(look) + self._front_watch = watch + watch.start() + + def _stop_watching_the_front(self): + if self._front_watch is not None: + self._front_watch.stop() + self._front_watch = None def stop(self): if self.state != RECORDING: @@ -725,6 +868,12 @@ class Dikte: return base = meeting.new_base() _, wav_path = cfg.meeting_paths(base) + # A meeting opens the same capture as a dictation does, and takes the + # front the same way: whoever is being recorded is in a call, and + # having their window go inactive mid-sentence is worse here than + # anywhere else. Kept as a local rather than on self: a dictation may + # already be waiting on its own note for where to paste. + was_in_front = self._the_front() self.meeting_recorder.start( str(wav_path), self.conf["meeting_mic_target"] or self.conf["mic_target"], @@ -733,6 +882,7 @@ class Dikte: ) if not self.meeting_recorder.active: return # start() has already said what went wrong + self._give_the_front_back(was_in_front) self.meeting_base = base self.meeting_elapsed.restart() self.meeting_ticker.start() @@ -859,11 +1009,13 @@ class Dikte: def _on_recorded(self, wav_path, duration, rms_values): owner, self.recorder_owner = self.recorder_owner, None wants_paste = self.paste_override.pop(owner, None) + focus, self.front_before = self.front_before, None if owner == ASK: self.ask_pipeline.run(wav_path, duration, rms_values, ask=True, - paste=wants_paste) + paste=wants_paste, focus=focus) else: - self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste) + self.pipeline.run(wav_path, duration, rms_values, + paste=wants_paste, focus=focus) def _on_finished(self, _raw, text, warning): if warning: @@ -954,12 +1106,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_() diff --git a/dikte/cli.py b/dikte/cli.py index d85b5c5..e9b0c45 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -20,6 +20,7 @@ import signal import subprocess import sys import time +import webbrowser from PyQt6.QtCore import QCoreApplication, QTimer @@ -30,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 @@ -786,6 +789,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: @@ -1114,6 +1144,11 @@ def build_parser(): 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"), diff --git a/dikte/config.py b/dikte/config.py index 24b7a20..3b904c1 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -459,6 +459,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 diff --git a/dikte/filetranscribe.py b/dikte/filetranscribe.py index 3838e3e..c69bc2f 100644 --- a/dikte/filetranscribe.py +++ b/dikte/filetranscribe.py @@ -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 @@ -30,10 +35,14 @@ from . import paths 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 @@ -87,9 +96,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.")) @@ -105,39 +145,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() @@ -149,7 +186,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: @@ -182,12 +228,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) @@ -313,13 +368,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): diff --git a/dikte/hub.py b/dikte/hub.py index 2ff115c..f5da71a 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -134,6 +134,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. diff --git a/dikte/i18n.py b/dikte/i18n.py index b9a9667..4e45e38 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -192,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ı aç", + "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", @@ -297,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}", diff --git a/dikte/mac_window.py b/dikte/mac_window.py new file mode 100644 index 0000000..538c6cd --- /dev/null +++ b/dikte/mac_window.py @@ -0,0 +1,203 @@ +"""The parts of AppKit a dictation needs on macOS and Qt does not reach. + +Two jobs, both about staying out of the user's way. + +The first is keeping the indicator on screen. Qt draws it as a tool window, +which on macOS is an NSPanel, and an NSPanel is hidden by the system the moment +its application stops being the active one. For a dictation indicator that is +exactly backwards: you press the shortcut inside some other program, so Dikte is +never the active application, and the one window that has something to say +disappears as soon as you look away from it. Three settings AppKit has and Qt +does not expose: + + hidesOnDeactivate = NO stay put when another application comes forward + collectionBehavior show on whichever desktop is in front, including + over a full screen window, and stay out of Cmd+Tab + nonactivating panel come to the front without bringing Dikte with it + +The second is putting the front back. Opening the microphone activates Dikte +whatever the indicator does, so app.py watches for that and calls activate() +here. See _give_the_front_back() there for the measurement. + +Done through the Objective-C runtime rather than a binding, because Dikte has no +third party Python packages and this is a handful of messages to three objects. +The runtime is loaded in _appkit() rather than at import, the way paste.py loads +its frameworks in _macos_api(): that is the one function a test fakes, and it is +what lets the tests below run on a machine that has no AppKit at all. +""" + +import ctypes +import ctypes.util +import os + +from PyQt6.QtGui import QGuiApplication + +# NSWindowCollectionBehavior, as of the macOS these names come from: +CAN_JOIN_ALL_SPACES = 1 << 0 +IGNORES_CYCLE = 1 << 6 # not a window Cmd+Tab should ever land on +FULL_SCREEN_AUXILIARY = 1 << 8 +BEHAVIOUR = CAN_JOIN_ALL_SPACES | IGNORES_CYCLE | FULL_SCREEN_AUXILIARY + +# NSWindowStyleMaskNonactivatingPanel. Without it, ordering the indicator to +# the front brings Dikte to the front with it, and the application the user was +# typing in loses focus the moment they start dictating: the Cmd+V at the end +# then lands on the indicator instead of their document. Qt has no flag for +# this; WA_ShowWithoutActivating governs the show, not what the panel does to +# the application afterwards. +NONACTIVATING_PANEL = 1 << 7 + +_appkit_runtime = None + + +class _AppKit: + """objc_msgSend under the signatures this file sends it through. + + It has no fixed signature of its own, and calling it through the wrong + argument or return types is how a Mac crashes rather than raises, so each + one is spelled out once here and used by name below. + """ + + def __init__(self, objc): + self.objc = objc + objc.sel_registerName.restype = ctypes.c_void_p + objc.sel_registerName.argtypes = [ctypes.c_char_p] + objc.objc_getClass.restype = ctypes.c_void_p + objc.objc_getClass.argtypes = [ctypes.c_char_p] + self.ask = self._as(ctypes.c_void_p) + self.ask_bool = self._as(ctypes.c_bool) + self.ask_pid = self._as(ctypes.c_int) # pid_t is an int32 + self.ask_unsigned = self._as(ctypes.c_ulong) # NSUInteger + self.tell_bool = self._as(None, ctypes.c_bool) + self.tell_unsigned = self._as(None, ctypes.c_ulong) + self.ask_of_class = self._as(ctypes.c_bool, ctypes.c_void_p) + self.ask_of_pid = self._as(ctypes.c_void_p, ctypes.c_int) + self.ask_with_options = self._as(ctypes.c_bool, ctypes.c_ulong) + + def _as(self, returns, *arguments): + return ctypes.cast(self.objc.objc_msgSend, ctypes.CFUNCTYPE( + returns, ctypes.c_void_p, ctypes.c_void_p, *arguments)) + + def selector(self, name): + return self.objc.sel_registerName(name) + + def shared(self, class_name, selector): + """A class's singleton, e.g. +[NSWorkspace sharedWorkspace].""" + return ctypes.c_void_p(self.ask( + ctypes.c_void_p(self.objc.objc_getClass(class_name)), + self.selector(selector))) + + +def _appkit(): + """The Objective-C runtime, loaded the first time something needs it. + + Loaded here rather than at import so that this module can be imported on a + machine that has no AppKit: the tests stand on macOS from a Linux machine + and back, and this is the one function they replace to do it. + """ + global _appkit_runtime + if _appkit_runtime is None: + _appkit_runtime = _AppKit( + ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc"))) + return _appkit_runtime + + +def frontmost_pid(): + """Which application is in front, by process id, or None when unasked. + + A process id rather than the object itself: the object would have to be + retained to survive the trip, and a number needs nothing looking after it. + """ + try: + api = _appkit() + workspace = api.shared(b"NSWorkspace", b"sharedWorkspace") + running = ctypes.c_void_p(api.ask( + workspace, api.selector(b"frontmostApplication"))) + if not running: + return None + return int(api.ask_pid(running, api.selector(b"processIdentifier"))) + except Exception: + return None + + +def activate(pid): + """Put the application with that process id back in front. + + False when it has gone away in the meantime, or when the message could not + be sent at all: a dictation is not worth failing over the window behind it. + """ + if not pid: + return False + try: + api = _appkit() + running = ctypes.c_void_p(api.ask_of_pid( + ctypes.c_void_p(api.objc.objc_getClass(b"NSRunningApplication")), + api.selector(b"runningApplicationWithProcessIdentifier:"), + int(pid))) + if not running: + return False + # activateWithOptions: rather than the deprecated activate, and with no + # options: bringing every one of its windows forward is not asked for, + # only the application it was before Dikte took the front from it. + return bool(api.ask_with_options( + running, api.selector(b"activateWithOptions:"), 0)) + except Exception: + return False + + +def is_frontmost(): + """Whether Dikte itself is the application in front. + + By process id rather than -[NSRunningApplication isActive] on our own + process, which stays true once the application has ever been activated: + measured True with another application plainly in front. + """ + pid = frontmost_pid() + return pid is not None and pid == os.getpid() + + +def _is_panel(api, window): + """Whether this window is an NSPanel, which is the only kind the + nonactivating bit is legal on: setting it on a plain NSWindow raises an + Objective-C exception, and an exception through ctypes takes the process + down with it.""" + panel = api.objc.objc_getClass(b"NSPanel") + if not panel: + return False + return bool(api.ask_of_class(window, api.selector(b"isKindOfClass:"), + ctypes.c_void_p(panel))) + + +def keep_on_screen(widget): + """Ask the window behind `widget` to stay while other programs are used. + + Silent when anything is not as expected: an indicator that cannot be made + to linger is still an indicator, and a dictation should not fail over the + window it is drawn in. + """ + # Only the Cocoa backend hands out a real NSView. Under the offscreen + # platform the tests run on, winId() is a number that means something else + # entirely, and sending an Objective-C message to it is how a test run + # turns into a crash. + if QGuiApplication.platformName() != "cocoa": + return False + try: + api = _appkit() + view = ctypes.c_void_p(int(widget.winId())) + window = api.ask(view, api.selector(b"window")) + if not window: + return False + window = ctypes.c_void_p(window) + api.tell_bool(window, api.selector(b"setHidesOnDeactivate:"), False) + api.tell_unsigned(window, api.selector(b"setCollectionBehavior:"), + BEHAVIOUR) + # Only a panel may carry the nonactivating bit, and only a panel is + # asked to: on anything else the message raises, and an Objective-C + # exception through ctypes takes the process with it. + if _is_panel(api, window): + mask = api.ask_unsigned(window, api.selector(b"styleMask")) + if not mask & NONACTIVATING_PANEL: + api.tell_unsigned(window, api.selector(b"setStyleMask:"), + mask | NONACTIVATING_PANEL) + return True + except (AttributeError, OSError, RuntimeError, ValueError): + return False diff --git a/dikte/overlay.py b/dikte/overlay.py index 4f4d3f1..37a2761 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -7,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics from PyQt6.QtWidgets import QWidget, QApplication +from . import mac_window + BARS = 22 HEIGHT = 56 MIN_WIDTH = 210 @@ -202,6 +204,11 @@ class Overlay(QWidget): self._reposition() if not self.isVisible(): self.show() + if sys.platform == "darwin": + # After show(), because the window it works on does not exist until + # then, and every time, because a window Qt rebuilt has the setting + # again at its default. + mac_window.keep_on_screen(self) if self._concealed: self.raise_() self._concealed = False diff --git a/dikte/paste.py b/dikte/paste.py index 62fad76..93e7040 100644 --- a/dikte/paste.py +++ b/dikte/paste.py @@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""): def ready(): return shutil.which(program) is not None - def press(shortcut, delay): + def press(shortcut, delay, _focus=None): + # Nothing here takes the front from the window being dictated into, so + # there is nothing to hand back: the process id is a macOS concern. if not ready(): raise PasteError(t("{tool} not found, cannot paste automatically.", tool=program)) @@ -296,11 +298,27 @@ def _ask_for_permission(): pass -def _macos_press(shortcut, delay): +def _macos_press(shortcut, delay, focus=None): """Post the key down and up straight into the window system. Nothing is typed anywhere until macOS has been told to trust Dikte, and it only asks once, when the paste it was granted for is first tried. + + `focus` is the application that was in front when the recording began. The + keys land wherever the window system is pointing, so a Dikte that has ended + up in front would swallow its own transcript; when that has happened the + front is handed back before pressing. Nothing is taken from anyone else: an + application the user went to while the transcription ran is where they want + the text now. + + This runs on the transcription's own thread rather than the main one, and + the two calls it makes are the kind AppKit documents as answering + atomically wherever they are asked from: NSRunningApplication is thread + safe by its own header, and the workspace lookup behind it returns a + reference rather than anything that has to be held. Stressed with four + threads and 32000 lookups against a running main loop without a fault; if + one ever does happen, mac_window answers None and the press goes ahead + where it would have gone anyway. """ keycode, flags = _macos_keys(shortcut) services, core = _macos_api() @@ -310,6 +328,12 @@ def _macos_press(shortcut, delay): "macOS has not been told to let Dikte press keys. Turn Dikte on " "under System Settings → Privacy & Security → Accessibility." )) + if focus: + # Imported here rather than at the top: it reaches for QtGui, and a + # terminal that only wants the clipboard should not pay for that. + from . import mac_window + if mac_window.is_frontmost(): + mac_window.activate(focus) time.sleep(delay) # let the selection settle and focus come back down = services.CGEventCreateKeyboardEvent(None, keycode, True) @@ -464,11 +488,14 @@ class _WinInput(ctypes.Structure): _fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)] -def _win_press(shortcut, delay): +def _win_press(shortcut, delay, _focus=None): """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. + + Nothing here takes the front from the window being dictated into, so the + remembered process id has nothing to hand back to: it is a macOS concern. """ codes = _win_keys(shortcut) user32, _ = _win_api() @@ -675,7 +702,11 @@ def paste_ready(): return desktop().ready() -def press(shortcut="", delay=0.12): - """Press a paste combination, e.g. 'ctrl+v', or this desktop's own.""" +def press(shortcut="", delay=0.12, focus=None): + """Press a paste combination, e.g. 'ctrl+v', or this desktop's own. + + `focus` is the process the keys are meant for, remembered when the + recording started: see the macOS press for what is done with it. + """ here = desktop() - here.press(shortcut or here.shortcuts[0], delay) + here.press(shortcut or here.shortcuts[0], delay, focus) diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index fe6f461..42ce4a8 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -15,6 +15,7 @@ from PyQt6.QtWidgets import ( QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, ) +from . import __version__ from . import api from . import assistant from . import audio @@ -23,9 +24,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 @@ -526,6 +529,9 @@ 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) def _sources_once(self): """One device listing per window build, shared by every combo. @@ -540,6 +546,8 @@ class SettingsWindow(QDialog): _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) @@ -556,6 +564,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")) @@ -590,6 +601,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) @@ -721,6 +733,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): @@ -1591,6 +1620,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]) @@ -1696,6 +1727,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: @@ -1946,6 +1978,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): diff --git a/dikte/update.py b/dikte/update.py new file mode 100644 index 0000000..4375996 --- /dev/null +++ b/dikte/update.py @@ -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) diff --git a/dikte/worker.py b/dikte/worker.py index 649a538..24b1e1f 100644 --- a/dikte/worker.py +++ b/dikte/worker.py @@ -50,16 +50,20 @@ class Pipeline(QObject): def busy(self): return self._thread is not None and self._thread.is_alive() - def run(self, wav_path, duration, rms_values=(), ask=False, paste=None): + def run(self, wav_path, duration, rms_values=(), ask=False, paste=None, + focus=None): """`paste` overrides the setting for this one run, which is what a dictation asked for from a terminal wants: the text comes back down the - socket, and pasting it into whatever had focus is nobody's intention.""" + socket, and pasting it into whatever had focus is nobody's intention. + + `focus` is the application that was in front when the recording began, + as a process id, and is where the paste is meant to land.""" if self.busy: return self._stop.clear() self._thread = threading.Thread( target=self._work, - args=(wav_path, duration, list(rms_values), ask, paste), + args=(wav_path, duration, list(rms_values), ask, paste, focus), daemon=True, ) self._thread.start() @@ -73,7 +77,8 @@ class Pipeline(QObject): """ self._stop.set() - def _work(self, wav_path, duration, rms_values, ask, paste_override=None): + def _work(self, wav_path, duration, rms_values, ask, paste_override=None, + focus=None): conf = self.conf started = time.monotonic() raw = "" @@ -171,7 +176,7 @@ class Pipeline(QObject): if wants_paste: self.stage.emit(t("Pasting…")) try: - paste.press(conf["paste_shortcut"]) + paste.press(conf["paste_shortcut"], focus=focus) except paste.PasteError as exc: # The transcript is on the clipboard and in the history; # a key press that would not land is a warning, not a diff --git a/tests/support.py b/tests/support.py index f6ec508..8a005f1 100644 --- a/tests/support.py +++ b/tests/support.py @@ -25,6 +25,7 @@ from unittest import mock from dikte import assistant from dikte import config as cfg from dikte import i18n +from dikte import update # What the application is, rather than what it does: PipeWire, wl-clipboard, # ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces @@ -86,8 +87,10 @@ class DikteTest(unittest.TestCase): MEETINGS_FILE=data_dir / "meetings.jsonl", ) # Resolved from cfg.DATA_DIR when assistant was imported, so it needs - # moving on its own. + # moving on its own. The same goes for where the update check writes + # down when it last ran. self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json") + self.patch_attr(update, "STATE_FILE", data_dir / "update.json") i18n.set_language("en") self.addCleanup(i18n.set_language, "en") diff --git a/tests/test_api.py b/tests/test_api.py index 58ab960..6bb28f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -79,6 +79,33 @@ class Explain(DikteTest): def test_the_status_is_carried_through(self): self.assertEqual(self.error(429).status, 429) + def test_so_is_whether_it_is_worth_asking_again(self): + self.assertTrue(self.error(502).retryable) + self.assertFalse(self.error(401).retryable) + + +class Retryable(unittest.TestCase): + """Which failures a second try can fix, and which will fail the same way.""" + + def test_a_gateway_that_gave_up_waiting(self): + for status in (408, 429, 500, 502, 503, 504): + with self.subTest(status=status): + self.assertTrue(api.ApiError("x", status).retryable) + + def test_a_request_that_was_wrong(self): + for status in (400, 401, 402, 403, 404, 413, 422): + with self.subTest(status=status): + self.assertFalse(api.ApiError("x", status).retryable) + + def test_an_error_of_our_own_is_not_the_network(self): + self.assertFalse(api.ApiError("Transcript came back empty.").retryable) + + def test_a_connection_that_dropped_is_worth_a_second_try(self): + with fake_urlopen(url_error("connection reset")): + with self.assertRaises(api.ApiError) as caught: + api._request("https://example.test", b"{}", {}) + self.assertTrue(caught.exception.retryable) + class ExtractError(unittest.TestCase): def test_the_usual_shape(self): diff --git a/tests/test_cli.py b/tests/test_cli.py index 3c2de6d..585bad5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,8 @@ import contextlib import io import json import unittest +import webbrowser +from typing import ClassVar from unittest import mock from dikte import audio @@ -17,9 +19,11 @@ from dikte import cli from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc from dikte import paste -from tests.support import DikteTest, fake_urlopen, only_these_tools +from dikte import update +from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error class Options: @@ -420,6 +424,68 @@ class Providers(DikteTest): self.assertIn("Groq", out) +class Updates(DikteTest): + """`dikte update` looks, says what it found, and installs nothing.""" + + RELEASE: ClassVar[dict] = { + "tag_name": "v9.9.9", + "html_url": "https://github.com/yusufipk/dikte/releases/tag/v9.9.9", + } + + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + # Nothing here may reach a browser, whatever the answer turns out to be. + self.opened = [] + self.patch_attr(webbrowser, "open", self.opened.append) + + def run_update(self, reply, **values): + with fake_urlopen(reply), captured() as (out, err): + code = cli.cmd_update(Options(open=False, **values)) + return code, out.getvalue(), err.getvalue() + + def test_a_newer_release_is_named_with_its_page(self): + code, out, _ = self.run_update(self.RELEASE) + self.assertEqual(code, 0) + self.assertIn("9.9.9", out) + self.assertIn(self.RELEASE["html_url"], out) + + def test_this_build_being_the_newest_is_not_a_failure(self): + code, out, _ = self.run_update({"tag_name": f"v{cli.__version__}"}) + self.assertEqual(code, 0) + self.assertIn("newest", out) + + def test_the_json_answer_says_both_numbers(self): + code, out, _ = self.run_update(self.RELEASE, json=True) + answer = json.loads(out) + self.assertTrue(answer["update"]) + self.assertEqual(answer["latest"], "9.9.9") + self.assertEqual(answer["current"], cli.__version__) + + def test_github_being_unreachable_is_a_failure_with_a_reason(self): + with fake_urlopen(url_error("no route to host")), captured() as (_, err): + code = cli.cmd_update(Options(open=False)) + self.assertEqual(code, 1) + self.assertIn("api.github.com", err.getvalue()) + + def test_the_browser_is_opened_only_when_asked_and_only_when_there_is_one(self): + self.run_update(self.RELEASE) + self.assertEqual(self.opened, []) + with fake_urlopen({"tag_name": f"v{cli.__version__}"}), captured(): + cli.cmd_update(Options(open=True)) + self.assertEqual(self.opened, []) + with fake_urlopen(self.RELEASE), captured(): + cli.cmd_update(Options(open=True)) + self.assertEqual(self.opened, [self.RELEASE["html_url"]]) + + def test_the_answer_is_written_down_for_the_application(self): + """A check at a terminal is a check; the tray must not go and ask the + same question an hour later.""" + self.run_update(self.RELEASE) + self.assertEqual(update.state()["version"], "9.9.9") + self.assertFalse(update.due()) + + class Doctor(DikteTest): """One pass over everything the settings window checks behind its buttons.""" diff --git a/tests/test_filetranscribe.py b/tests/test_filetranscribe.py index cab88dd..9931dfa 100644 --- a/tests/test_filetranscribe.py +++ b/tests/test_filetranscribe.py @@ -214,10 +214,22 @@ class ChunkSeconds(DikteTest): self.assertEqual(ft.chunk_seconds(self.file(1024), 600), 0.0) def test_a_file_over_the_limit_is_cut_by_what_it_measured(self): - # Twice the limit over an hour, so a little under half an hour fits. - seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600) - self.assertGreater(seconds, 1500) - self.assertLess(seconds, 1800) + # Twice the limit over twenty minutes, so a little under ten fits. + seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 1200) + self.assertGreater(seconds, 500) + self.assertLess(seconds, 600) + + def test_a_chunk_is_never_more_audio_than_a_request_can_outlive(self): + """An hour in one request is a 502 from the gateway, whatever it weighs.""" + self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600), + ft.MAX_CHUNK_SECONDS) + + def test_a_small_file_that_is_still_hours_long_is_cut_on_the_clock(self): + self.assertEqual(ft.chunk_seconds(self.file(1024), 7200), + ft.MAX_CHUNK_SECONDS) + + def test_a_file_short_enough_on_both_counts_is_not_cut(self): + self.assertEqual(ft.chunk_seconds(self.file(1024), ft.MAX_CHUNK_SECONDS), 0.0) def test_a_file_with_no_length_is_left_whole(self): self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0) @@ -400,6 +412,58 @@ class Transcriber(DikteTest): worker.stop() self.assertTrue(worker._abort.aborted) + def test_a_chunk_is_given_longer_to_answer_than_a_dictation(self): + """A quarter hour of audio is not a sentence: the default would cut it off.""" + worker = ft.FileTranscriber(self.conf) + with mock.patch.object(ft, "_to_wav", side_effect=lambda *a: self.source), \ + mock.patch.object(ft, "_to_mp3", + side_effect=lambda path, *a, **k: path), \ + mock.patch.object(ft.shutil, "which", return_value="/usr/bin/ffmpeg"), \ + mock.patch.object(api, "transcribe", return_value="text") as call: + worker._work(self.source, False, False) + self.assertEqual(call.call_args.kwargs["timeout"], ft.HOSTED_TIMEOUT) + + def test_a_gateway_having_a_bad_moment_is_asked_again(self): + with mock.patch.object(ft.FileTranscriber, "_wait"): + done, failures, progress, _ = self.run_chain( + fail=[api.ApiError("HTTP 502: timeout", 502), "raw text"]) + self.assertEqual(failures, []) + self.assertEqual(done[0][0], "raw text") + self.assertTrue(any("Trying again" in message for message in progress)) + + def test_a_rejected_key_is_not_asked_again(self): + """Trying again with the same key is only a slower way to fail.""" + call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401)) + with mock.patch.object(ft.FileTranscriber, "_wait"): + _, failures, _, _ = self.run_chain(fail=call) + self.assertEqual(call.call_count, 1) + self.assertIn("rejected", failures[0]) + + def test_a_chunk_is_given_up_on_after_the_last_try(self): + call = mock.Mock(side_effect=api.ApiError("HTTP 502: timeout", 502)) + with mock.patch.object(ft.FileTranscriber, "_wait"): + _, failures, _, _ = self.run_chain(fail=call) + self.assertEqual(call.call_count, ft.RETRIES) + self.assertIn("502", failures[0]) + + def test_what_was_heard_before_the_failure_is_still_handed_over(self): + """An hour already transcribed is not thrown away over the chunk after it.""" + boom = api.ApiError("HTTP 502: timeout", 502) + with mock.patch.object(ft.FileTranscriber, "_wait"), \ + mock.patch.object(ft.FileTranscriber, "_chunks", + side_effect=lambda wav, *a: [(wav, 0.0), (wav, 10.0)]): + done, failures, _, _ = self.run_chain( + fail=["first half"] + [boom] * ft.RETRIES) + self.assertEqual(done[0][0], "first half") + self.assertIn("502", failures[0]) + + def test_nothing_heard_at_all_is_a_plain_failure(self): + call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401)) + with mock.patch.object(ft.FileTranscriber, "_wait"): + done, failures, _, _ = self.run_chain(fail=call) + self.assertEqual(done, []) + self.assertEqual(failures[0], "rejected the API key") + def test_a_second_start_while_one_is_running_is_ignored(self): worker = ft.FileTranscriber(self.conf) worker._thread = mock.Mock(is_alive=lambda: True) diff --git a/tests/test_paste.py b/tests/test_paste.py index 360492d..0f37995 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -444,6 +444,45 @@ class MacOS(ClipboardContract, DikteTest): self.assertFalse(paste.paste_ready()) +class MacPasteGoesWhereTheDictationStarted(MacOS): + """The keys land in the frontmost window, so the front is what decides + where a transcript ends up.""" + + def setUp(self): + super().setUp() + from dikte import mac_window + self.mac_window = mac_window + self.activated = [] + self.patch_attr(mac_window, "activate", self.activated.append) + + def frontmost(self, dikte_is): + self.patch_attr(self.mac_window, "is_frontmost", lambda: dikte_is) + + def test_a_dikte_that_took_the_front_hands_it_back_before_pressing(self): + self.frontmost(True) + paste.press("cmd+v", focus=4242) + self.assertEqual(self.activated, [4242]) + self.assertEqual([event for _, event in self.api.posted], [1001, 1002]) + + def test_another_application_in_front_is_where_the_user_went_and_is_left(self): + self.frontmost(False) + paste.press("cmd+v", focus=4242) + self.assertEqual(self.activated, []) + + def test_a_run_that_remembered_nobody_asks_nothing(self): + self.frontmost(True) + paste.press("cmd+v") + self.assertEqual(self.activated, []) + + def test_the_front_is_handed_back_only_once_macos_trusts_dikte(self): + """Pulling the user out of their window and then failing to type would + be the worst of both.""" + self.frontmost(True) + self.api.trusted = False + with self.assertRaises(paste.PasteError): + paste.press("cmd+v", focus=4242) + self.assertEqual(self.activated, []) + class MacClipboardSnapshot(DikteTest): def test_every_native_type_is_restored_and_the_files_are_removed(self): directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-") diff --git a/tests/test_ui.py b/tests/test_ui.py index 7b9a297..445fa5d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -25,6 +25,7 @@ from dikte import ipc from dikte import overlay as overlay_module from dikte import paste from dikte import settings_ui +from dikte import update from tests.support import DikteTest, only_these_tools # One application for the whole run; Qt allows no second one. @@ -103,6 +104,7 @@ CHANGED = { "pause_shortcut": "Meta+P", "evdev_hotkey": True, "history_limit": 50, + "update_check": False, } @@ -252,6 +254,31 @@ class Settings(DikteTest): self.assertEqual(shown, [provider]) self.assertFalse(box.isHidden()) + def test_the_update_line_names_the_version_that_is_running(self): + window = self.window(cfg.Config()) + self.assertIn(settings_ui.__version__, window.update_status.text()) + # Nothing to open until a check has found something to open. + self.assertTrue(window.update_page.isHidden()) + + def test_a_newer_release_puts_the_page_button_on_screen(self): + window = self.window(cfg.Config()) + told = [] + window.update_found.connect(told.append) + release = update.Release("9.9.9", "https://example.invalid/9.9.9", "") + window._on_update_checked(release, "") + self.assertIn("9.9.9", window.update_status.text()) + self.assertFalse(window.update_page.isHidden()) + self.assertEqual(window._release_url, release.url) + # And the tray hears about it from here rather than waiting a day. + self.assertEqual(told, [release]) + + def test_a_check_that_failed_says_why_and_hands_the_button_back(self): + window = self.window(cfg.Config()) + window.update_now.setEnabled(False) + window._on_update_checked(None, "api.github.com answered HTTP 403.") + self.assertIn("403", window.update_status.text()) + self.assertTrue(window.update_now.isEnabled()) + def test_the_settings_the_window_does_not_show_are_left_alone(self): """A tab nobody wrote must not reset what the command line set.""" self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0, @@ -491,6 +518,321 @@ class KdeSettings(Settings): self.assertFalse(window.evdev_enabled.isHidden()) + + +class FakeAppKit: + """The Objective-C runtime, answering rather than being asked. + + Stands in for what mac_window._appkit() loads, so what the indicator's + window would have been sent can be read off `told` on any machine. The + selectors come back as the names they were registered under, which is what + lets the tests below name the message they mean. + """ + + def __init__(self, window=4242, panel=True, mask=0xe): + self.objc = self + self.window, self.panel, self.mask = window, panel, mask + self.told = {} + + def objc_getClass(self, name): + return 1 if name == b"NSPanel" else 0 + + def selector(self, name): + return name.decode() + + def shared(self, _class_name, _selector): + return 1 + + def ask(self, _to, selector): + return self.window if selector == "window" else 0 + + def ask_unsigned(self, _to, _selector): + return self.mask + + def ask_of_class(self, _to, _selector, _klass): + return self.panel + + def tell_bool(self, _to, selector, value): + self.told[selector] = value + + tell_unsigned = tell_bool + + +class MacIndicatorWindow(DikteTest): + """Which of the three AppKit settings the indicator's window is sent. + + The nonactivating bit is the one worth a test of its own: it is legal on an + NSPanel and nowhere else, and sending it to a plain NSWindow raises an + Objective-C exception, which through ctypes is not something Python can + catch. It takes the whole process down. `_is_panel` is the only thing + standing between the two, so what it answers has to decide what is sent. + """ + + def setUp(self): + super().setUp() + from dikte import mac_window + self.mac_window = mac_window + self.patch_attr(mac_window.QGuiApplication, "platformName", + staticmethod(lambda: "cocoa")) + + def told(self, **kwargs): + """What keep_on_screen sends an indicator, against this AppKit.""" + appkit = FakeAppKit(**kwargs) + self.patch_attr(self.mac_window, "_appkit", lambda: appkit) + widget = overlay_module.Overlay() + self.addCleanup(widget.deleteLater) + self.addCleanup(widget.close) + self.answered = self.mac_window.keep_on_screen(widget) + return appkit.told + + def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self): + """The message that would kill the process. The other two still go.""" + told = self.told(panel=False) + self.assertTrue(self.answered) + self.assertNotIn("setStyleMask:", told) + self.assertIs(told["setHidesOnDeactivate:"], False) + self.assertEqual(told["setCollectionBehavior:"], + self.mac_window.BEHAVIOUR) + + def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self): + told = self.told(panel=True, mask=0xe) + self.assertEqual(told["setStyleMask:"], + 0xe | self.mac_window.NONACTIVATING_PANEL) + + def test_a_panel_that_already_has_the_bit_is_left_alone(self): + """Every dictation runs this again, and a mask Cocoa did not need is a + window it rebuilds underneath the indicator.""" + told = self.told(panel=True, + mask=0xe | self.mac_window.NONACTIVATING_PANEL) + self.assertNotIn("setStyleMask:", told) + + def test_a_window_the_view_does_not_have_yet_is_not_messaged(self): + self.assertEqual(self.told(window=0), {}) + self.assertFalse(self.answered) + + def test_nothing_is_sent_anywhere_but_cocoa(self): + """Every other platform hands out a winId that means something else + entirely, and messaging it crashes the run.""" + self.patch_attr(self.mac_window.QGuiApplication, "platformName", + staticmethod(lambda: "offscreen")) + self.assertEqual(self.told(), {}) + self.assertFalse(self.answered) + + +class GivingTheFrontBack(DikteTest): + """Opening the microphone brings Dikte to the front, and the window the + user was dictating into goes inactive with the caret in it. Nothing can be + asked of the capture session, so the front is put back afterwards. + + The watch is driven by hand here: what matters is what it decides, not how + long Qt takes to tick. + """ + + def setUp(self): + super().setUp() + from dikte import app as dikte_module + from dikte import mac_window + self.dikte = dikte_module + self.activated = [] + self.patch_attr(mac_window, "activate", self.activate) + self.ticks = [] + outer = self + + class FakeTimer: + """Records what it was asked to do and hands over the tick.""" + + def __init__(self, _parent): + self.interval = None + self.running = False + outer.ticks.append(self) + + def setInterval(self, milliseconds): + self.interval = milliseconds + + def start(self): + self.running = True + + def stop(self): + self.running = False + + @property + def timeout(self): + return self + + def connect(self, slot): + self.tick = slot + + self.patch_attr(dikte_module, "QTimer", FakeTimer) + + class BareDikte: + """As much of the application as this one method touches.""" + + app = None + _front_watch = None + _the_front = dikte_module.Dikte._the_front + _give_the_front_back = dikte_module.Dikte._give_the_front_back + _stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front + + self.bare = BareDikte + + def activate(self, pid): + self.activated.append(pid) + return True + + def watching(self, was_in_front, dikte_in_front, on=None): + from dikte import mac_window + self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front) + dikte = on if on is not None else self.bare() + dikte._give_the_front_back(was_in_front) + return self.ticks[-1] if self.ticks else None + + def test_the_front_goes_back_to_whoever_had_it(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + self.assertEqual(self.activated, [4242]) + self.assertTrue(watch.running) # accepted is not the same as landed + self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False) + watch.tick() + self.assertFalse(watch.running) + + def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + watch.tick() + self.assertEqual(self.activated, [4242]) + self.assertTrue(watch.running) + + def test_an_accepted_restore_that_never_lands_still_times_out(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + with mock.patch.object(self.dikte.time, "monotonic", + return_value=self.dikte.time.monotonic() + 60): + watch.tick() + self.assertFalse(watch.running) + self.assertEqual(self.activated, [4242]) + + def test_a_restore_the_system_refused_is_retried(self): + from dikte import mac_window + self.patch_attr(mac_window, "activate", + lambda pid: self.activated.append(pid) or False) + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + watch.tick() + self.assertEqual(self.activated, [4242, 4242]) + self.assertTrue(watch.running) + + def test_a_front_that_was_never_taken_is_left_where_it_is(self): + """The microphone does not always take it, and pulling an application + forward that is already there is one flicker for nothing.""" + watch = self.watching(4242, dikte_in_front=False) + watch.tick() + self.assertEqual(self.activated, []) + self.assertTrue(watch.running) # still waiting for the moment + + def test_it_gives_up_rather_than_watching_for_ever(self): + watch = self.watching(4242, dikte_in_front=False) + with mock.patch.object(self.dikte.time, "monotonic", + return_value=self.dikte.time.monotonic() + 60): + watch.tick() + self.assertFalse(watch.running) + self.assertEqual(self.activated, []) + + def test_a_dictation_started_in_dikte_itself_watches_nothing(self): + """Settings is a window of ours, and the front is already where it + belongs.""" + self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True)) + + def test_a_second_recording_calls_off_the_watch_the_first_one_left(self): + """The older watch remembers where the older recording started, and by + now that is the wrong window to be pulling forward.""" + dikte = self.bare() + first = self.watching(4242, dikte_in_front=False, on=dikte) + second = self.watching(1111, dikte_in_front=False, on=dikte) + self.assertFalse(first.running) + self.assertTrue(second.running) + second.tick() # and the survivor is the new one + self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True) + second.tick() + self.assertEqual(self.activated, [1111]) + + def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self): + """Starting the next one from Dikte's own window is not a reason to + leave the last one's watch running.""" + dikte = self.bare() + first = self.watching(4242, dikte_in_front=False, on=dikte) + self.watching(os.getpid(), dikte_in_front=False, on=dikte) + self.assertFalse(first.running) + self.assertIsNone(dikte._front_watch) + + def test_nobody_in_front_is_nobody_to_go_back_to(self): + self.assertIsNone(self.watching(None, dikte_in_front=True)) + + def mac_window_module(self): + from dikte import mac_window + return mac_window + + +class EveryRecordingProtectsTheFront(DikteTest): + """Three ways in, dictation, agent and meeting, and all three open the same + avfoundation capture, so all three take the front the same way. What is + checked here is the order: the front has to be noted before the microphone + is opened, and the watch armed after, or there is nothing to go back to. + """ + + def setUp(self): + super().setUp() + from dikte import app as dikte_module + self.dikte = dikte_module + self.order = [] + + def app(self, **attributes): + """A Dikte that records the order it does things in, and nothing else. + + The methods under test are called unbound against it, so the stand-ins + go on the object rather than on the class. + """ + dikte = mock.Mock(**attributes) + dikte._run_id = 0 + dikte.conf = {"mic_target": "", "max_seconds": 60, + "meeting_mic_target": "", "meeting_system_target": "", + "meeting_max_seconds": 60} + dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242 + dikte._give_the_front_back.side_effect = ( + lambda pid: self.order.append(f"watching {pid}")) + dikte._begin_recording = ( + lambda owner: self.dikte.Dikte._begin_recording(dikte, owner)) + dikte.recorder.start.side_effect = ( + lambda *_a: self.order.append("microphone")) + dikte.meeting_recorder.start.side_effect = ( + lambda *_a: self.order.append("microphone")) + return dikte + + def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self): + dikte = self.app(state=self.dikte.IDLE, recording=False) + dikte.overlay.show_recording.side_effect = ( + lambda *_a: self.order.append("indicator")) + self.dikte.Dikte.start(dikte) + self.assertEqual(self.order, + ["noted", "indicator", "microphone", "watching 4242"]) + + def test_the_agent_does_the_same(self): + dikte = self.app(ask_state=self.dikte.IDLE, recording=False) + self.dikte.Dikte.start_ask(dikte) + self.assertEqual(self.order, ["noted", "microphone", "watching 4242"]) + + def test_a_meeting_does_the_same(self): + """The one most worth protecting: the user is in a call.""" + dikte = self.app(meeting_state=self.dikte.M_IDLE) + dikte.meeting_recorder.active = True + self.dikte.Dikte.start_meeting(dikte) + self.assertEqual(self.order, ["noted", "microphone", "watching 4242"]) + + def test_a_meeting_whose_microphone_never_opened_watches_nothing(self): + dikte = self.app(meeting_state=self.dikte.M_IDLE) + dikte.meeting_recorder.active = False + self.dikte.Dikte.start_meeting(dikte) + self.assertEqual(self.order, ["noted", "microphone"]) + class Overlay(DikteTest): def overlay(self, **kwargs): widget = overlay_module.Overlay(**kwargs) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..06aca80 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,175 @@ +"""Whether a newer release is one worth telling somebody about. + +Two things carry the weight here. A version is compared by its numbers alone, +because a build off master carries the released number with its commit after +it and is ahead of that release rather than behind it. And the clock lives in a +file, so a day of asking nobody has to survive a restart. +""" + +import json +import time + +from dikte import hub +from dikte import update +from tests.support import DikteTest, fake_urlopen, url_error + +RELEASE = { + "tag_name": "v1.4.0", + "html_url": "https://github.com/yusufipk/dikte/releases/tag/v1.4.0", + "published_at": "2026-08-01T10:00:00Z", +} + + +class Numbers(DikteTest): + def test_a_tag_and_a_bare_number_read_the_same(self): + self.assertEqual(update._numbers("v1.4.0"), (1, 4, 0)) + self.assertEqual(update._numbers("1.4.0"), (1, 4, 0)) + + def test_a_short_number_is_filled_out(self): + self.assertEqual(update._numbers("2"), (2, 0, 0)) + self.assertEqual(update._numbers("2.1"), (2, 1, 0)) + + def test_what_follows_the_number_is_dropped(self): + self.assertEqual(update._numbers("1.0.1-dev.abc1234"), (1, 0, 1)) + self.assertEqual(update._numbers("1.0.1+build7"), (1, 0, 1)) + + def test_something_that_is_not_a_version_is_no_version(self): + self.assertEqual(update._numbers("latest"), ()) + self.assertEqual(update._numbers(""), ()) + self.assertEqual(update._numbers(None), ()) + + +class Newer(DikteTest): + def test_a_higher_number_is_newer(self): + self.assertTrue(update.newer("1.4.0", "1.3.9")) + self.assertTrue(update.newer("v2.0.0", "1.9.9")) + + def test_the_same_number_is_not(self): + self.assertFalse(update.newer("1.4.0", "1.4.0")) + self.assertFalse(update.newer("1.3.0", "1.4.0")) + + def test_a_build_off_master_is_ahead_of_the_release_it_names(self): + """1.0.1-dev.abc1234 was built after 1.0.1 went out, not before it. + Read as a version suffix it would be older, and every nightly would be + told to go back to the release it had already passed.""" + self.assertFalse(update.newer("1.0.1", "1.0.1-dev.abc1234")) + self.assertTrue(update.newer("1.0.2", "1.0.1-dev.abc1234")) + + def test_a_tag_that_is_not_a_version_is_never_newer(self): + self.assertFalse(update.newer("nightly", "1.0.0")) + + +class Asking(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + self.patch_attr(update, "__version__", "1.0.0") + + def test_the_newest_release_comes_back_with_its_page(self): + with fake_urlopen(RELEASE) as calls: + release = update.latest() + self.assertEqual(release.version, "1.4.0") + self.assertEqual(release.url, RELEASE["html_url"]) + self.assertEqual( + calls[0].full_url, + "https://api.github.com/repos/yusufipk/dikte/releases/latest") + + def test_a_release_with_no_page_falls_back_to_the_redirect(self): + with fake_urlopen({"tag_name": "v1.4.0"}): + release = update.latest() + self.assertEqual(release.url, update.RELEASES_PAGE) + + def test_a_repository_with_no_release_is_an_error(self): + with fake_urlopen({"message": "Not Found"}): + with self.assertRaises(hub.HubError): + update.latest() + + def test_a_check_answers_with_the_newer_release(self): + with fake_urlopen(RELEASE): + release = update.check() + self.assertEqual(release.version, "1.4.0") + + def test_a_check_that_finds_nothing_new_answers_with_nothing(self): + self.patch_attr(update, "__version__", "1.4.0") + with fake_urlopen(RELEASE): + self.assertIsNone(update.check()) + + def test_a_second_check_the_same_day_asks_nobody(self): + with fake_urlopen(RELEASE) as calls: + update.check() + release = update.check() + self.assertEqual(len(calls), 1) + # And still says what the first one found, since it is still true. + self.assertEqual(release.version, "1.4.0") + + def test_a_day_later_it_asks_again(self): + with fake_urlopen(RELEASE) as calls: + update.check() + update._store(checked=time.time() - update.INTERVAL - 60) + update.check() + self.assertEqual(len(calls), 2) + + def test_the_button_asks_whatever_the_clock_says(self): + with fake_urlopen(RELEASE) as calls: + update.check() + update.check(force=True) + self.assertEqual(len(calls), 2) + + def test_a_check_that_cannot_reach_github_says_so(self): + with fake_urlopen(url_error("no route to host")): + with self.assertRaises(hub.HubError): + update.check() + + +class Remembering(DikteTest): + def setUp(self): + super().setUp() + self.patch_attr(hub, "CACHE_DIR", self.path("cache")) + self.patch_attr(update, "__version__", "1.0.0") + + def test_what_the_last_check_found_survives_a_restart(self): + with fake_urlopen(RELEASE): + update.check() + release = update.pending() + self.assertEqual(release.version, "1.4.0") + self.assertEqual(release.url, RELEASE["html_url"]) + + def test_nothing_was_ever_checked(self): + self.assertIsNone(update.pending()) + self.assertEqual(update.state(), {}) + self.assertTrue(update.due()) + + def test_a_release_that_is_no_longer_newer_is_not_pending(self): + """The state file outlives the build that wrote it: an update that was + found and then installed must not still be waiting afterwards.""" + update._store(version="1.4.0") + self.patch_attr(update, "__version__", "1.4.0") + self.assertIsNone(update.pending()) + + def test_a_version_is_announced_once(self): + self.assertEqual(update.announced(), "") + update.mark_announced("1.4.0") + self.assertEqual(update.announced(), "1.4.0") + + def test_a_state_file_that_is_rubbish_is_no_state_at_all(self): + update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + update.STATE_FILE.write_text("half a {", encoding="utf-8") + self.assertEqual(update.state(), {}) + self.assertIsNone(update.pending()) + + def test_a_state_file_that_cannot_be_written_is_not_a_failure(self): + self.patch_attr(update, "STATE_FILE", + self.path("nope") / "deeper" / "update.json") + self.path("nope").write_text("a file where a directory would go") + with fake_urlopen(RELEASE): + release = update.check() + self.assertEqual(release.version, "1.4.0") + + def test_the_clock_is_kept_out_of_the_settings(self): + """A background check writes while the settings window may be open, and + a write into config.json there would undo whatever it holds.""" + with fake_urlopen(RELEASE): + update.check() + stored = json.loads(update.STATE_FILE.read_text(encoding="utf-8")) + self.assertEqual(stored["version"], "1.4.0") + self.assertGreater(stored["checked"], 0) diff --git a/tests/test_worker.py b/tests/test_worker.py index da4b476..c1918dd 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -33,7 +33,8 @@ class Chain(DikteTest): transcribe_error=None, cleaned="Book it for Thursday.", cleanup_error=None, answer=("Booked.", ""), rms=None, - clipboard=b"what was there before", paste_error=None): + clipboard=b"what was there before", paste_error=None, + focus=None): pipeline = worker.Pipeline(self.conf) done, failures, stages, cancels = [], [], [], [] pipeline.finished.connect(lambda *args: done.append(args)) @@ -64,7 +65,8 @@ class Chain(DikteTest): "copy": copy, "copy_bytes": copy_bytes, "press": press, "read_clipboard": read_clipboard} pipeline._work(self.wav, duration, - self.rms if rms is None else rms, ask, paste_override) + self.rms if rms is None else rms, ask, paste_override, + focus) return {"done": done, "failures": failures, "stages": stages, "cancelled": cancels, **calls} @@ -76,7 +78,15 @@ class Chain(DikteTest): self.assertEqual(run["done"][0], ("uh, book it for Thursday", "Book it for Thursday.", "")) run["copy"].assert_called_once_with("Book it for Thursday.") - run["press"].assert_called_once_with(self.conf["paste_shortcut"]) + run["press"].assert_called_once_with(self.conf["paste_shortcut"], + focus=None) + + def test_the_paste_is_told_where_the_dictation_started(self): + """Whoever was in front when the recording began is where the keys are + meant to go, and the press is the only part that can act on it.""" + run = self.run_chain(focus=4242) + run["press"].assert_called_once_with(self.conf["paste_shortcut"], + focus=4242) def test_the_stages_are_named_as_they_happen(self): run = self.run_chain()