mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge master into the reliability branch
The paste block was rewritten by both sides: master taught press() to put the remembered application back in front (focus), this branch moved the history write ahead of the paste and stopped restoring the old clipboard over a transcript the key press refused. Kept this branch's order and error handling, and handed press() the focus it now takes.
This commit is contained in:
@@ -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
|
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
|
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
|
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
|
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:
|
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` |
|
| 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` |
|
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
|
||||||
| Settings | Tray menu → *Settings*, or `dikte settings` |
|
| 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` |
|
| Reload after an update | Tray menu → *Restart*, or `dikte restart` |
|
||||||
| Quit | Tray menu → *Quit*, or `dikte quit` |
|
| 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
|
cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex
|
||||||
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
|
||||||
hub.py what GitHub and Hugging Face have on offer today
|
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
|
worker.py transcribe → clean up → clipboard → paste
|
||||||
vad.py deciding whether a recording holds speech at all
|
vad.py deciding whether a recording holds speech at all
|
||||||
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
||||||
|
|||||||
+5
-1
@@ -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
|
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
|
verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve
|
||||||
bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge`
|
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ı
|
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
|
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` |
|
| 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` |
|
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
|
||||||
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
|
| 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` |
|
| 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` |
|
| Çı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
|
cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex
|
||||||
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
|
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
|
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
|
worker.py transkript → temizleme → pano → yapıştırma
|
||||||
vad.py kayıtta gerçekten konuşma var mı kararı
|
vad.py kayıtta gerçekten konuşma var mı kararı
|
||||||
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
||||||
|
|||||||
+1
-1
@@ -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
|
# 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
|
# 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.
|
# bug report from someone running "latest" names a commit.
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.2"
|
||||||
|
|||||||
+17
-4
@@ -58,10 +58,20 @@ def timestamp_model(provider, selected=""):
|
|||||||
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
|
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):
|
class ApiError(Exception):
|
||||||
def __init__(self, message, status=None):
|
def __init__(self, message, status=None, retryable=None):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status = status
|
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):
|
class Aborted(Exception):
|
||||||
@@ -218,7 +228,7 @@ def explain(exc, service):
|
|||||||
if exc.status == 429:
|
if exc.status == 429:
|
||||||
return ApiError(t("{service} is rate limiting you (HTTP 429). Try again in "
|
return ApiError(t("{service} is rate limiting you (HTTP 429). Try again in "
|
||||||
"a moment.", service=service), exc.status)
|
"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):
|
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.
|
# not the network failing. URLError is an OSError, so both land here.
|
||||||
if aborter is not None and aborter.aborted:
|
if aborter is not None and aborter.aborted:
|
||||||
raise Aborted from None
|
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}",
|
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:
|
except json.JSONDecodeError as exc:
|
||||||
raise ApiError(t("Could not parse the response: {error}", error=exc)) from 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()
|
detail = server.error()
|
||||||
return ApiError(f"{service}: {exc}" + (f" ({detail})" if detail else ""),
|
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,
|
def _transcribe_request(target, audio_path, language, prompt, response_format,
|
||||||
|
|||||||
+202
-4
@@ -34,8 +34,9 @@ if sys.platform == "darwin":
|
|||||||
os.environ.get("PATH", "")) if part
|
os.environ.get("PATH", "")) if part
|
||||||
)
|
)
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
|
from PyQt6.QtCore import (QObject, QTimer, QElapsedTimer, QSocketNotifier, # noqa: E402
|
||||||
from PyQt6.QtGui import QAction, QIcon # noqa: E402
|
QUrl, pyqtSignal)
|
||||||
|
from PyQt6.QtGui import QAction, QDesktopServices, QIcon # noqa: E402
|
||||||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||||||
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # 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 config as cfg # noqa: E402
|
||||||
from . import ggml # noqa: E402
|
from . import ggml # noqa: E402
|
||||||
from . import hotkey # noqa: E402
|
from . import hotkey # noqa: E402
|
||||||
|
from . import hub # noqa: E402
|
||||||
from . import i18n # noqa: E402
|
from . import i18n # noqa: E402
|
||||||
from . import integrate # noqa: E402
|
from . import integrate # noqa: E402
|
||||||
from . import ipc # noqa: E402
|
from . import ipc # noqa: E402
|
||||||
|
from . import mac_window # noqa: E402
|
||||||
from . import meeting # noqa: E402
|
from . import meeting # noqa: E402
|
||||||
from . import trayicon # noqa: E402
|
from . import trayicon # noqa: E402
|
||||||
|
from . import update # noqa: E402
|
||||||
from .i18n import t # noqa: E402
|
from .i18n import t # noqa: E402
|
||||||
from .meeting import MeetingPipeline # noqa: E402
|
from .meeting import MeetingPipeline # noqa: E402
|
||||||
from .overlay import Overlay # 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.
|
# short enough not to sit in the corner for the rest of the hour.
|
||||||
PEEK_MS = 12000
|
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:
|
class Dikte:
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
@@ -116,6 +151,12 @@ class Dikte:
|
|||||||
# Which recording is the current one, so a timer set for the run that
|
# Which recording is the current one, so a timer set for the run that
|
||||||
# started it cannot stop the one that came after.
|
# started it cannot stop the one that came after.
|
||||||
self._run_id = 0
|
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"])
|
self.overlay = Overlay(self.conf["overlay_corner"])
|
||||||
# The agent's indicator sits on top of the dictation one when both are
|
# 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.setInterval(500)
|
||||||
self.meeting_ticker.timeout.connect(self._meeting_tick)
|
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.tray = QSystemTrayIcon()
|
||||||
self._apply_settings()
|
self._apply_settings()
|
||||||
self.tray.show()
|
self.tray.show()
|
||||||
@@ -223,6 +275,11 @@ class Dikte:
|
|||||||
self.menu.addAction(self.meeting_cancel_action)
|
self.menu.addAction(self.meeting_cancel_action)
|
||||||
self.menu.addSeparator()
|
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 = QAction(t("Settings…"), self.menu)
|
||||||
self.settings_action.triggered.connect(self.open_settings)
|
self.settings_action.triggered.connect(self.open_settings)
|
||||||
self.menu.addAction(self.settings_action)
|
self.menu.addAction(self.settings_action)
|
||||||
@@ -239,6 +296,7 @@ class Dikte:
|
|||||||
self.tray.setContextMenu(self.menu)
|
self.tray.setContextMenu(self.menu)
|
||||||
self.tray.setToolTip(t("Dikte: ready"))
|
self.tray.setToolTip(t("Dikte: ready"))
|
||||||
self.tray.activated.connect(self._tray_clicked)
|
self.tray.activated.connect(self._tray_clicked)
|
||||||
|
self._refresh_update()
|
||||||
self._set_icon("audio-input-microphone")
|
self._set_icon("audio-input-microphone")
|
||||||
|
|
||||||
def _tray_clicked(self, reason):
|
def _tray_clicked(self, reason):
|
||||||
@@ -578,9 +636,19 @@ class Dikte:
|
|||||||
timer.restart()
|
timer.restart()
|
||||||
return False
|
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):
|
def start(self):
|
||||||
if self.state != IDLE or self.recording:
|
if self.state != IDLE or self.recording:
|
||||||
return
|
return
|
||||||
|
self.front_before = self._the_front()
|
||||||
self.overlay.show_recording()
|
self.overlay.show_recording()
|
||||||
self._begin_recording(DICTATION)
|
self._begin_recording(DICTATION)
|
||||||
# A recorder that could not start has already said so, synchronously,
|
# A recorder that could not start has already said so, synchronously,
|
||||||
@@ -594,6 +662,7 @@ class Dikte:
|
|||||||
def start_ask(self):
|
def start_ask(self):
|
||||||
if self.ask_state != IDLE or self.recording:
|
if self.ask_state != IDLE or self.recording:
|
||||||
return
|
return
|
||||||
|
self.front_before = self._the_front()
|
||||||
self.ask_overlay.show_recording(asking=True)
|
self.ask_overlay.show_recording(asking=True)
|
||||||
self._begin_recording(ASK)
|
self._begin_recording(ASK)
|
||||||
if not self.recorder.active:
|
if not self.recorder.active:
|
||||||
@@ -608,6 +677,80 @@ class Dikte:
|
|||||||
self.elapsed.restart()
|
self.elapsed.restart()
|
||||||
self.ticker.start()
|
self.ticker.start()
|
||||||
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
|
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):
|
def stop(self):
|
||||||
if self.state != RECORDING:
|
if self.state != RECORDING:
|
||||||
@@ -725,6 +868,12 @@ class Dikte:
|
|||||||
return
|
return
|
||||||
base = meeting.new_base()
|
base = meeting.new_base()
|
||||||
_, wav_path = cfg.meeting_paths(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(
|
self.meeting_recorder.start(
|
||||||
str(wav_path),
|
str(wav_path),
|
||||||
self.conf["meeting_mic_target"] or self.conf["mic_target"],
|
self.conf["meeting_mic_target"] or self.conf["mic_target"],
|
||||||
@@ -733,6 +882,7 @@ class Dikte:
|
|||||||
)
|
)
|
||||||
if not self.meeting_recorder.active:
|
if not self.meeting_recorder.active:
|
||||||
return # start() has already said what went wrong
|
return # start() has already said what went wrong
|
||||||
|
self._give_the_front_back(was_in_front)
|
||||||
self.meeting_base = base
|
self.meeting_base = base
|
||||||
self.meeting_elapsed.restart()
|
self.meeting_elapsed.restart()
|
||||||
self.meeting_ticker.start()
|
self.meeting_ticker.start()
|
||||||
@@ -859,11 +1009,13 @@ class Dikte:
|
|||||||
def _on_recorded(self, wav_path, duration, rms_values):
|
def _on_recorded(self, wav_path, duration, rms_values):
|
||||||
owner, self.recorder_owner = self.recorder_owner, None
|
owner, self.recorder_owner = self.recorder_owner, None
|
||||||
wants_paste = self.paste_override.pop(owner, None)
|
wants_paste = self.paste_override.pop(owner, None)
|
||||||
|
focus, self.front_before = self.front_before, None
|
||||||
if owner == ASK:
|
if owner == ASK:
|
||||||
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
|
||||||
paste=wants_paste)
|
paste=wants_paste, focus=focus)
|
||||||
else:
|
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):
|
def _on_finished(self, _raw, text, warning):
|
||||||
if warning:
|
if warning:
|
||||||
@@ -954,12 +1106,58 @@ class Dikte:
|
|||||||
if len(message) > len(first_line):
|
if len(message) > len(first_line):
|
||||||
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
|
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 ---------------------------------------------------------
|
# ---- settings ---------------------------------------------------------
|
||||||
|
|
||||||
def open_settings(self):
|
def open_settings(self):
|
||||||
if self.settings_window is None:
|
if self.settings_window is None:
|
||||||
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||||
self.settings_window.applied.connect(self._apply_settings)
|
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.finished.connect(self._settings_closed)
|
||||||
self.settings_window.show()
|
self.settings_window.show()
|
||||||
self.settings_window.raise_()
|
self.settings_window.raise_()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
from PyQt6.QtCore import QCoreApplication, QTimer
|
from PyQt6.QtCore import QCoreApplication, QTimer
|
||||||
|
|
||||||
@@ -30,10 +31,12 @@ from . import cleanup
|
|||||||
from . import config as cfg
|
from . import config as cfg
|
||||||
from . import filetranscribe
|
from . import filetranscribe
|
||||||
from . import hotkey
|
from . import hotkey
|
||||||
|
from . import hub
|
||||||
from . import ipc
|
from . import ipc
|
||||||
from . import integrate
|
from . import integrate
|
||||||
from . import meeting
|
from . import meeting
|
||||||
from . import paste
|
from . import paste
|
||||||
|
from . import update
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
|
||||||
NOT_RUNNING = 3
|
NOT_RUNNING = 3
|
||||||
@@ -786,6 +789,33 @@ def cmd_integrate(opts):
|
|||||||
f"{verb}:\n{listing}" if paths else "Nothing to change.")
|
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):
|
def cmd_status(opts):
|
||||||
reply = ipc.send("status")
|
reply = ipc.send("status")
|
||||||
if reply is None:
|
if reply is None:
|
||||||
@@ -1114,6 +1144,11 @@ def build_parser():
|
|||||||
integrated.set_defaults(func=cmd_integrate)
|
integrated.set_defaults(func=cmd_integrate)
|
||||||
|
|
||||||
# --- the application --------------------------------------------------
|
# --- 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)
|
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
|
||||||
for name, help_text in (("settings", "open the settings window"),
|
for name, help_text in (("settings", "open the settings window"),
|
||||||
("restart", "reload the running instance"),
|
("restart", "reload the running instance"),
|
||||||
|
|||||||
@@ -459,6 +459,9 @@ DEFAULTS = {
|
|||||||
"overlay_corner": "bottom-left",
|
"overlay_corner": "bottom-left",
|
||||||
"keep_audio": False,
|
"keep_audio": False,
|
||||||
"history_limit": 200,
|
"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_timestamps": False,
|
||||||
"file_cleanup": True,
|
"file_cleanup": True,
|
||||||
"file_cleanup_prompt": "", # empty -> language-specific default
|
"file_cleanup_prompt": "", # empty -> language-specific default
|
||||||
|
|||||||
+101
-39
@@ -1,15 +1,19 @@
|
|||||||
"""Transcribe an existing audio/video file with the same models.
|
"""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
|
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,
|
mp3 on top of that. Two things decide where a file is cut up: the upload limit,
|
||||||
and uncompressed audio reaches it after ten minutes where mp3 takes an hour.
|
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
|
A cut is not free, which is what the encoder buys and why nothing is cut more
|
||||||
second windows and decides for itself where one cue ends and the next begins; a
|
finely than that. Whisper hears in thirty second windows and decides for itself
|
||||||
chunk that starts in the middle of a sentence can come back as one cue per
|
where one cue ends and the next begins; a chunk that starts in the middle of a
|
||||||
window, twenty seconds of text at a time, for the whole rest of the chunk. So
|
sentence can come back as one cue per window, twenty seconds of text at a time,
|
||||||
the file is cut as rarely as the limit allows, what is cut overlaps, and
|
for the whole rest of the chunk. So what is cut overlaps, and stitch() drops
|
||||||
stitch() drops the half that was heard twice.
|
the half that was heard twice.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
@@ -19,6 +23,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import wave
|
import wave
|
||||||
|
|
||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
@@ -30,10 +35,14 @@ from . import paths
|
|||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room
|
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
|
MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less
|
||||||
OVERLAP_SECONDS = 30 # a whisper window: how far back a chunk starts
|
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
|
WAV_CHUNK_SECONDS = 600 # 19 MB, for the caller that uploads the WAV itself
|
||||||
CLEANUP_CHUNK_CHARS = 12000 # keep each cleanup call comfortably small
|
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
|
RATE = 16000
|
||||||
MIN_SUBTITLE_SECONDS = 1.5 # how long a cue with no end time of its own stays up
|
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):
|
def _check(self):
|
||||||
self._abort.check()
|
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):
|
def _work(self, path, timestamps, do_cleanup):
|
||||||
conf = self.conf
|
conf = self.conf
|
||||||
workdir = None
|
workdir = None
|
||||||
|
pieces = []
|
||||||
|
segments = []
|
||||||
try:
|
try:
|
||||||
if not shutil.which("ffmpeg"):
|
if not shutil.which("ffmpeg"):
|
||||||
raise api.ApiError(t("ffmpeg not found. Install it to transcribe files."))
|
raise api.ApiError(t("ffmpeg not found. Install it to transcribe files."))
|
||||||
@@ -105,39 +145,36 @@ class FileTranscriber(QObject):
|
|||||||
if len(chunks) > 1:
|
if len(chunks) > 1:
|
||||||
self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks)))
|
self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks)))
|
||||||
|
|
||||||
pieces = []
|
|
||||||
segments = []
|
|
||||||
for index, (chunk_path, offset) in enumerate(chunks, start=1):
|
for index, (chunk_path, offset) in enumerate(chunks, start=1):
|
||||||
self._check()
|
self._check()
|
||||||
self.progress.emit(
|
stage = (t("Transcribing chunk {index}/{count}…",
|
||||||
t("Transcribing chunk {index}/{count}…",
|
index=index, count=len(chunks))
|
||||||
index=index, count=len(chunks))
|
if len(chunks) > 1 else t("Transcribing…"))
|
||||||
if len(chunks) > 1 else t("Transcribing…")
|
self.progress.emit(stage)
|
||||||
)
|
|
||||||
if timestamps:
|
if timestamps:
|
||||||
segments = stitch(segments, [
|
heard = self._attempt(lambda: api.transcribe_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(
|
|
||||||
target,
|
target,
|
||||||
chunk_path,
|
chunk_path,
|
||||||
language=conf["language"],
|
language=conf["language"],
|
||||||
prompt=conf["transcribe_prompt"],
|
prompt=conf["transcribe_prompt"],
|
||||||
|
timeout=HOSTED_TIMEOUT,
|
||||||
aborter=self._abort,
|
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:
|
text = _joined(pieces, segments, timestamps)
|
||||||
pieces = [f"[{format_timestamp(start)}] {line}"
|
|
||||||
for start, _, line in segments]
|
|
||||||
text = "\n".join(pieces) if timestamps else " ".join(pieces)
|
|
||||||
|
|
||||||
if do_cleanup and text:
|
if do_cleanup and text:
|
||||||
self._check()
|
self._check()
|
||||||
@@ -149,7 +186,16 @@ class FileTranscriber(QObject):
|
|||||||
except Cancelled:
|
except Cancelled:
|
||||||
self.progress.emit(t("Stopped."))
|
self.progress.emit(t("Stopped."))
|
||||||
except (api.ApiError, OSError, subprocess.SubprocessError, wave.Error) as exc:
|
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:
|
finally:
|
||||||
self._local = None
|
self._local = None
|
||||||
if workdir:
|
if workdir:
|
||||||
@@ -182,12 +228,21 @@ class FileTranscriber(QObject):
|
|||||||
self._local = ggml.llm if cleanup.provider(conf) == "local" else None
|
self._local = ggml.llm if cleanup.provider(conf) == "local" else None
|
||||||
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
|
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
|
||||||
out = []
|
out = []
|
||||||
|
stage = t("Cleaning up…")
|
||||||
for block in split_text(text, timestamps):
|
for block in split_text(text, timestamps):
|
||||||
self._check()
|
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)
|
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):
|
def format_timestamp(seconds):
|
||||||
seconds = int(seconds)
|
seconds = int(seconds)
|
||||||
hours, rest = divmod(seconds, 3600)
|
hours, rest = divmod(seconds, 3600)
|
||||||
@@ -313,13 +368,20 @@ def wav_seconds(wav_path):
|
|||||||
def chunk_seconds(path, duration):
|
def chunk_seconds(path, duration):
|
||||||
"""How many seconds of this audio fit in one request, or 0 when all of it does.
|
"""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
|
Whichever of the two limits bites first. How much fits under the upload
|
||||||
depends on the speech, and the file on disk is the only honest answer.
|
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 duration <= 0:
|
||||||
if size <= UPLOAD_LIMIT or duration <= 0:
|
|
||||||
return 0.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):
|
def split_wav(wav_path, workdir, seconds=WAV_CHUNK_SECONDS, overlap=OVERLAP_SECONDS):
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ def release(repo, tag="latest", refresh=False):
|
|||||||
return data.get("tag_name") or tag, assets
|
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):
|
def files(repo, revision="main", refresh=False):
|
||||||
"""[Item] for every file in a Hugging Face repository.
|
"""[Item] for every file in a Hugging Face repository.
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,25 @@ TR = {
|
|||||||
"Silence threshold": "Sessizlik eşiği",
|
"Silence threshold": "Sessizlik eşiği",
|
||||||
"Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
|
"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 --------------------------------------------------
|
# --- settings: api --------------------------------------------------
|
||||||
"Keys": "Anahtarlar",
|
"Keys": "Anahtarlar",
|
||||||
"Speech to text": "Sesi yazıya çevirme",
|
"Speech to text": "Sesi yazıya çevirme",
|
||||||
@@ -297,6 +316,10 @@ TR = {
|
|||||||
"Converting audio…": "Ses dönüştürülüyor…",
|
"Converting audio…": "Ses dönüştürülüyor…",
|
||||||
"Splitting into {count} chunks…": "{count} parçaya bölünüyor…",
|
"Splitting into {count} chunks…": "{count} parçaya bölünüyor…",
|
||||||
"Transcribing chunk {index}/{count}…": "{index}/{count} parça yazıya çevriliyor…",
|
"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.",
|
"Done: {chars} characters.": "Bitti: {chars} karakter.",
|
||||||
"Stopped.": "Durduruldu.",
|
"Stopped.": "Durduruldu.",
|
||||||
"Failed: {error}": "Başarısız: {error}",
|
"Failed: {error}": "Başarısız: {error}",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
|
||||||
from PyQt6.QtWidgets import QWidget, QApplication
|
from PyQt6.QtWidgets import QWidget, QApplication
|
||||||
|
|
||||||
|
from . import mac_window
|
||||||
|
|
||||||
BARS = 22
|
BARS = 22
|
||||||
HEIGHT = 56
|
HEIGHT = 56
|
||||||
MIN_WIDTH = 210
|
MIN_WIDTH = 210
|
||||||
@@ -202,6 +204,11 @@ class Overlay(QWidget):
|
|||||||
self._reposition()
|
self._reposition()
|
||||||
if not self.isVisible():
|
if not self.isVisible():
|
||||||
self.show()
|
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:
|
if self._concealed:
|
||||||
self.raise_()
|
self.raise_()
|
||||||
self._concealed = False
|
self._concealed = False
|
||||||
|
|||||||
+37
-6
@@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""):
|
|||||||
def ready():
|
def ready():
|
||||||
return shutil.which(program) is not None
|
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():
|
if not ready():
|
||||||
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
||||||
tool=program))
|
tool=program))
|
||||||
@@ -296,11 +298,27 @@ def _ask_for_permission():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _macos_press(shortcut, delay):
|
def _macos_press(shortcut, delay, focus=None):
|
||||||
"""Post the key down and up straight into the window system.
|
"""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
|
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.
|
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)
|
keycode, flags = _macos_keys(shortcut)
|
||||||
services, core = _macos_api()
|
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 "
|
"macOS has not been told to let Dikte press keys. Turn Dikte on "
|
||||||
"under System Settings → Privacy & Security → Accessibility."
|
"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
|
time.sleep(delay) # let the selection settle and focus come back
|
||||||
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
|
||||||
@@ -464,11 +488,14 @@ class _WinInput(ctypes.Structure):
|
|||||||
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
|
_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.
|
"""Post the presses and releases straight into the input queue.
|
||||||
|
|
||||||
No permission stands in front of SendInput the way Accessibility does on
|
No permission stands in front of SendInput the way Accessibility does on
|
||||||
macOS: whatever window has focus receives the combination.
|
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)
|
codes = _win_keys(shortcut)
|
||||||
user32, _ = _win_api()
|
user32, _ = _win_api()
|
||||||
@@ -675,7 +702,11 @@ def paste_ready():
|
|||||||
return desktop().ready()
|
return desktop().ready()
|
||||||
|
|
||||||
|
|
||||||
def press(shortcut="", delay=0.12):
|
def press(shortcut="", delay=0.12, focus=None):
|
||||||
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
|
"""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 = desktop()
|
||||||
here.press(shortcut or here.shortcuts[0], delay)
|
here.press(shortcut or here.shortcuts[0], delay, focus)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
from . import api
|
from . import api
|
||||||
from . import assistant
|
from . import assistant
|
||||||
from . import audio
|
from . import audio
|
||||||
@@ -23,9 +24,11 @@ from . import config as cfg
|
|||||||
from . import filetranscribe
|
from . import filetranscribe
|
||||||
from . import ggml
|
from . import ggml
|
||||||
from . import hotkey
|
from . import hotkey
|
||||||
|
from . import hub
|
||||||
from . import ipc
|
from . import ipc
|
||||||
from . import meeting
|
from . import meeting
|
||||||
from . import paste
|
from . import paste
|
||||||
|
from . import update
|
||||||
from .filetranscribe import FileTranscriber
|
from .filetranscribe import FileTranscriber
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
@@ -526,6 +529,9 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
class SettingsWindow(QDialog):
|
class SettingsWindow(QDialog):
|
||||||
applied = pyqtSignal()
|
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):
|
def _sources_once(self):
|
||||||
"""One device listing per window build, shared by every combo.
|
"""One device listing per window build, shared by every combo.
|
||||||
@@ -540,6 +546,8 @@ class SettingsWindow(QDialog):
|
|||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
# Which key was tested, whether it worked, and what to write under it.
|
# Which key was tested, whether it worked, and what to write under it.
|
||||||
_test_done = pyqtSignal(str, bool, str)
|
_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):
|
def __init__(self, conf, meetings=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -556,6 +564,9 @@ class SettingsWindow(QDialog):
|
|||||||
self._key_fields = {}
|
self._key_fields = {}
|
||||||
self._testers = {}
|
self._testers = {}
|
||||||
self._shown_provider = ""
|
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.transcriber = FileTranscriber(conf, self)
|
||||||
self.setWindowTitle(t("Dikte Settings"))
|
self.setWindowTitle(t("Dikte Settings"))
|
||||||
|
|
||||||
@@ -590,6 +601,7 @@ class SettingsWindow(QDialog):
|
|||||||
self._models_loaded.connect(self._on_models_loaded)
|
self._models_loaded.connect(self._on_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
self._test_done.connect(self._on_test_done)
|
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.progress.connect(self._on_file_progress)
|
||||||
self.transcriber.finished.connect(self._on_file_finished)
|
self.transcriber.finished.connect(self._on_file_finished)
|
||||||
self.transcriber.failed.connect(self._on_file_failed)
|
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))
|
t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR))
|
||||||
)
|
)
|
||||||
form.addRow("", self.keep_audio)
|
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
|
return page
|
||||||
|
|
||||||
def _api_tab(self):
|
def _api_tab(self):
|
||||||
@@ -1591,6 +1620,8 @@ class SettingsWindow(QDialog):
|
|||||||
self.silence_db.setValue(int(conf["silence_db"]))
|
self.silence_db.setValue(int(conf["silence_db"]))
|
||||||
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
|
||||||
self.keep_audio.setChecked(conf["keep_audio"])
|
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():
|
for name, who in cfg.TRANSCRIBERS.items():
|
||||||
self._key_fields[name].setText(conf[who.key])
|
self._key_fields[name].setText(conf[who.key])
|
||||||
@@ -1696,6 +1727,7 @@ class SettingsWindow(QDialog):
|
|||||||
conf["silence_db"] = float(self.silence_db.value())
|
conf["silence_db"] = float(self.silence_db.value())
|
||||||
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
|
||||||
conf["keep_audio"] = self.keep_audio.isChecked()
|
conf["keep_audio"] = self.keep_audio.isChecked()
|
||||||
|
conf["update_check"] = self.update_check.isChecked()
|
||||||
|
|
||||||
provider = self.transcribe_provider.currentData() or "local"
|
provider = self.transcribe_provider.currentData() or "local"
|
||||||
if provider in self._models:
|
if provider in self._models:
|
||||||
@@ -1946,6 +1978,45 @@ class SettingsWindow(QDialog):
|
|||||||
button.setEnabled(True)
|
button.setEnabled(True)
|
||||||
answer.setText(("✓ " if ok else "✗ ") + message)
|
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 ------------------------------------------------------
|
# ---- audio file ------------------------------------------------------
|
||||||
|
|
||||||
def _choose_file(self):
|
def _choose_file(self):
|
||||||
|
|||||||
+158
@@ -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)
|
||||||
+10
-5
@@ -50,16 +50,20 @@ class Pipeline(QObject):
|
|||||||
def busy(self):
|
def busy(self):
|
||||||
return self._thread is not None and self._thread.is_alive()
|
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
|
"""`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
|
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:
|
if self.busy:
|
||||||
return
|
return
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._work,
|
target=self._work,
|
||||||
args=(wav_path, duration, list(rms_values), ask, paste),
|
args=(wav_path, duration, list(rms_values), ask, paste, focus),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
@@ -73,7 +77,8 @@ class Pipeline(QObject):
|
|||||||
"""
|
"""
|
||||||
self._stop.set()
|
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
|
conf = self.conf
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
raw = ""
|
raw = ""
|
||||||
@@ -171,7 +176,7 @@ class Pipeline(QObject):
|
|||||||
if wants_paste:
|
if wants_paste:
|
||||||
self.stage.emit(t("Pasting…"))
|
self.stage.emit(t("Pasting…"))
|
||||||
try:
|
try:
|
||||||
paste.press(conf["paste_shortcut"])
|
paste.press(conf["paste_shortcut"], focus=focus)
|
||||||
except paste.PasteError as exc:
|
except paste.PasteError as exc:
|
||||||
# The transcript is on the clipboard and in the history;
|
# The transcript is on the clipboard and in the history;
|
||||||
# a key press that would not land is a warning, not a
|
# a key press that would not land is a warning, not a
|
||||||
|
|||||||
+4
-1
@@ -25,6 +25,7 @@ from unittest import mock
|
|||||||
from dikte import assistant
|
from dikte import assistant
|
||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
from dikte import i18n
|
from dikte import i18n
|
||||||
|
from dikte import update
|
||||||
|
|
||||||
# What the application is, rather than what it does: PipeWire, wl-clipboard,
|
# 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
|
# 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",
|
MEETINGS_FILE=data_dir / "meetings.jsonl",
|
||||||
)
|
)
|
||||||
# Resolved from cfg.DATA_DIR when assistant was imported, so it needs
|
# 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(assistant, "SESSION_FILE", data_dir / "assistant.json")
|
||||||
|
self.patch_attr(update, "STATE_FILE", data_dir / "update.json")
|
||||||
|
|
||||||
i18n.set_language("en")
|
i18n.set_language("en")
|
||||||
self.addCleanup(i18n.set_language, "en")
|
self.addCleanup(i18n.set_language, "en")
|
||||||
|
|||||||
@@ -79,6 +79,33 @@ class Explain(DikteTest):
|
|||||||
def test_the_status_is_carried_through(self):
|
def test_the_status_is_carried_through(self):
|
||||||
self.assertEqual(self.error(429).status, 429)
|
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):
|
class ExtractError(unittest.TestCase):
|
||||||
def test_the_usual_shape(self):
|
def test_the_usual_shape(self):
|
||||||
|
|||||||
+67
-1
@@ -10,6 +10,8 @@ import contextlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
import webbrowser
|
||||||
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from dikte import audio
|
from dikte import audio
|
||||||
@@ -17,9 +19,11 @@ from dikte import cli
|
|||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
from dikte import ggml
|
from dikte import ggml
|
||||||
from dikte import hotkey
|
from dikte import hotkey
|
||||||
|
from dikte import hub
|
||||||
from dikte import ipc
|
from dikte import ipc
|
||||||
from dikte import paste
|
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:
|
class Options:
|
||||||
@@ -420,6 +424,68 @@ class Providers(DikteTest):
|
|||||||
self.assertIn("Groq", out)
|
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):
|
class Doctor(DikteTest):
|
||||||
"""One pass over everything the settings window checks behind its buttons."""
|
"""One pass over everything the settings window checks behind its buttons."""
|
||||||
|
|
||||||
|
|||||||
@@ -214,10 +214,22 @@ class ChunkSeconds(DikteTest):
|
|||||||
self.assertEqual(ft.chunk_seconds(self.file(1024), 600), 0.0)
|
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):
|
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.
|
# Twice the limit over twenty minutes, so a little under ten fits.
|
||||||
seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600)
|
seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 1200)
|
||||||
self.assertGreater(seconds, 1500)
|
self.assertGreater(seconds, 500)
|
||||||
self.assertLess(seconds, 1800)
|
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):
|
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)
|
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0)
|
||||||
@@ -400,6 +412,58 @@ class Transcriber(DikteTest):
|
|||||||
worker.stop()
|
worker.stop()
|
||||||
self.assertTrue(worker._abort.aborted)
|
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):
|
def test_a_second_start_while_one_is_running_is_ignored(self):
|
||||||
worker = ft.FileTranscriber(self.conf)
|
worker = ft.FileTranscriber(self.conf)
|
||||||
worker._thread = mock.Mock(is_alive=lambda: True)
|
worker._thread = mock.Mock(is_alive=lambda: True)
|
||||||
|
|||||||
@@ -444,6 +444,45 @@ class MacOS(ClipboardContract, DikteTest):
|
|||||||
self.assertFalse(paste.paste_ready())
|
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):
|
class MacClipboardSnapshot(DikteTest):
|
||||||
def test_every_native_type_is_restored_and_the_files_are_removed(self):
|
def test_every_native_type_is_restored_and_the_files_are_removed(self):
|
||||||
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
|
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from dikte import ipc
|
|||||||
from dikte import overlay as overlay_module
|
from dikte import overlay as overlay_module
|
||||||
from dikte import paste
|
from dikte import paste
|
||||||
from dikte import settings_ui
|
from dikte import settings_ui
|
||||||
|
from dikte import update
|
||||||
from tests.support import DikteTest, only_these_tools
|
from tests.support import DikteTest, only_these_tools
|
||||||
|
|
||||||
# One application for the whole run; Qt allows no second one.
|
# One application for the whole run; Qt allows no second one.
|
||||||
@@ -103,6 +104,7 @@ CHANGED = {
|
|||||||
"pause_shortcut": "Meta+P",
|
"pause_shortcut": "Meta+P",
|
||||||
"evdev_hotkey": True,
|
"evdev_hotkey": True,
|
||||||
"history_limit": 50,
|
"history_limit": 50,
|
||||||
|
"update_check": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -252,6 +254,31 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(shown, [provider])
|
self.assertEqual(shown, [provider])
|
||||||
self.assertFalse(box.isHidden())
|
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):
|
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."""
|
"""A tab nobody wrote must not reset what the command line set."""
|
||||||
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
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())
|
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):
|
class Overlay(DikteTest):
|
||||||
def overlay(self, **kwargs):
|
def overlay(self, **kwargs):
|
||||||
widget = overlay_module.Overlay(**kwargs)
|
widget = overlay_module.Overlay(**kwargs)
|
||||||
|
|||||||
@@ -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)
|
||||||
+13
-3
@@ -33,7 +33,8 @@ class Chain(DikteTest):
|
|||||||
transcribe_error=None,
|
transcribe_error=None,
|
||||||
cleaned="Book it for Thursday.",
|
cleaned="Book it for Thursday.",
|
||||||
cleanup_error=None, answer=("Booked.", ""), rms=None,
|
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)
|
pipeline = worker.Pipeline(self.conf)
|
||||||
done, failures, stages, cancels = [], [], [], []
|
done, failures, stages, cancels = [], [], [], []
|
||||||
pipeline.finished.connect(lambda *args: done.append(args))
|
pipeline.finished.connect(lambda *args: done.append(args))
|
||||||
@@ -64,7 +65,8 @@ class Chain(DikteTest):
|
|||||||
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
"copy": copy, "copy_bytes": copy_bytes, "press": press,
|
||||||
"read_clipboard": read_clipboard}
|
"read_clipboard": read_clipboard}
|
||||||
pipeline._work(self.wav, duration,
|
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,
|
return {"done": done, "failures": failures, "stages": stages,
|
||||||
"cancelled": cancels, **calls}
|
"cancelled": cancels, **calls}
|
||||||
|
|
||||||
@@ -76,7 +78,15 @@ class Chain(DikteTest):
|
|||||||
self.assertEqual(run["done"][0],
|
self.assertEqual(run["done"][0],
|
||||||
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
("uh, book it for Thursday", "Book it for Thursday.", ""))
|
||||||
run["copy"].assert_called_once_with("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):
|
def test_the_stages_are_named_as_they_happen(self):
|
||||||
run = self.run_chain()
|
run = self.run_chain()
|
||||||
|
|||||||
Reference in New Issue
Block a user