mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c503d12e62 | ||
|
|
4f0d320ff1 | ||
|
|
f6c6122253 | ||
|
|
9736bd0ecd | ||
|
|
d896680a4f | ||
|
|
7c135b1063 | ||
|
|
17a55e1efc | ||
|
|
a3bb5ed29b | ||
|
|
e681accc90 | ||
|
|
66cf3f3923 | ||
|
|
be09cc3ac6 | ||
|
|
0b06d6d16d | ||
|
|
e9488a51a4 | ||
|
|
0f30c852ab | ||
|
|
901e57e4d0 | ||
|
|
e8147f49f8 | ||
|
|
6f79e93d53 | ||
|
|
69194186c8 | ||
|
|
afc8ffd992 | ||
|
|
197f5385ee | ||
|
|
420cda9376 | ||
|
|
6f601ab969 | ||
|
|
3663c7fd57 | ||
|
|
df99456cde | ||
|
|
084c48151d | ||
|
|
6bf5c8b349 | ||
|
|
2927e15833 | ||
|
|
f55e201785 | ||
|
|
0be64c2ef9 | ||
|
|
c7b14ff84e | ||
|
|
45b18722be |
@@ -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,12 +133,14 @@ 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` |
|
||||||
|
|
||||||
An indicator in the screen corner shows a red dot, a live waveform and the
|
An indicator in the screen corner shows a red dot, a live waveform and the
|
||||||
elapsed time, then the stage it is on. It never takes focus. Pressing
|
elapsed time, then the stage it is on. It never takes focus. Pressing
|
||||||
`Ctrl+Space` again while Dikte is still working does nothing; nothing queues up.
|
`Ctrl+Space` again while the last dictation is still being cleaned up starts
|
||||||
|
the next one; it is transcribed and pasted in turn, behind the one still going.
|
||||||
A dictation and a command to the agent do wait on each other for the microphone,
|
A dictation and a command to the agent do wait on each other for the microphone,
|
||||||
which is one device, but for nothing else: each has its own indicator, and the
|
which is one device, but for nothing else: each has its own indicator, and the
|
||||||
second one stacks above the first while both are up.
|
second one stacks above the first while both are up.
|
||||||
@@ -240,6 +245,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
|
||||||
|
|||||||
+8
-3
@@ -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,12 +129,14 @@ 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` |
|
||||||
|
|
||||||
Ekranın köşesindeki gösterge kırmızı kayıt noktasını, canlı ses dalgasını ve
|
Ekranın köşesindeki gösterge kırmızı kayıt noktasını, canlı ses dalgasını ve
|
||||||
süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Dikte çalışırken
|
süreyi, ardından hangi aşamada olduğunu gösterir. Odak almaz. Önceki dikte daha
|
||||||
`Ctrl+Space`'e tekrar basmak bir şey yapmaz, sıraya da girmez. Dikte ile ajana
|
temizlenirken `Ctrl+Space`'e tekrar basmak yenisini başlatır; o da sırası
|
||||||
|
gelince, öndekinin ardından yazılıp yapıştırılır. Dikte ile ajana
|
||||||
verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu
|
verilen komut yalnızca mikrofon için birbirini bekler, o da tek aygıt olduğu
|
||||||
için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi
|
için; başka hiçbir şeyde beklemezler. Her birinin kendi göstergesi var, ikisi
|
||||||
birden ekrandayken ikincisi birincinin üstüne yerleşir.
|
birden ekrandayken ikincisi birincinin üstüne yerleşir.
|
||||||
@@ -234,6 +238,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,
|
||||||
|
|||||||
+441
-53
@@ -18,6 +18,7 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
# A Wayland client cannot place a window in a screen corner, so the indicator
|
# A Wayland client cannot place a window in a screen corner, so the indicator
|
||||||
# is drawn through XWayland.
|
# is drawn through XWayland.
|
||||||
@@ -33,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
|
||||||
|
|
||||||
@@ -44,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
|
||||||
@@ -77,11 +82,46 @@ 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):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.conf = cfg.Config()
|
self.conf = cfg.Config()
|
||||||
|
# run_app hands the one-instance lock over after construction; a Dikte
|
||||||
|
# built without one (the tests) restarts without touching a lock.
|
||||||
|
self.instance_lock = None
|
||||||
|
self._registry_shortcuts = True # settled by _apply_settings below
|
||||||
self.state = IDLE
|
self.state = IDLE
|
||||||
self.ask_state = IDLE
|
self.ask_state = IDLE
|
||||||
# Which of the two the microphone is currently serving, or None.
|
# Which of the two the microphone is currently serving, or None.
|
||||||
@@ -111,6 +151,17 @@ 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
|
||||||
|
# Dictations handed to the pipeline and not yet out of it. More than
|
||||||
|
# one is normal: the microphone is free while a transcript is being
|
||||||
|
# cleaned up, so the next dictation can already be spoken, and it then
|
||||||
|
# queues up behind the one still going.
|
||||||
|
self._transcripts_pending = 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
|
||||||
@@ -126,13 +177,20 @@ class Dikte:
|
|||||||
# Before anything of ours is started: a server from a Dikte that was
|
# Before anything of ours is started: a server from a Dikte that was
|
||||||
# killed outright is still holding a model in memory.
|
# killed outright is still holding a model in memory.
|
||||||
ggml.sweep()
|
ggml.sweep()
|
||||||
|
# The first dictation with no microphone picked would otherwise pay for
|
||||||
|
# the ffmpeg device listing on the key press itself, seconds of nothing
|
||||||
|
# happening at the least explicable moment. Warmed the way the models
|
||||||
|
# are, off the main thread; the listing caches itself.
|
||||||
|
if sys.platform == "win32" and not self.conf["mic_target"]:
|
||||||
|
threading.Thread(target=audio.list_sources, daemon=True).start()
|
||||||
|
|
||||||
self.recorder.level.connect(self._on_level)
|
self.recorder.level.connect(self._on_level)
|
||||||
self.recorder.stopped.connect(self._on_recorded)
|
self.recorder.stopped.connect(self._on_recorded)
|
||||||
|
self.recorder.died.connect(self._on_recorder_died)
|
||||||
self.recorder.failed.connect(self._on_recorder_error)
|
self.recorder.failed.connect(self._on_recorder_error)
|
||||||
self.pipeline.stage.connect(self.overlay.show_busy)
|
self.pipeline.stage.connect(self._on_stage)
|
||||||
self.pipeline.finished.connect(self._on_finished)
|
self.pipeline.finished.connect(self._on_finished)
|
||||||
self.pipeline.failed.connect(self._on_error)
|
self.pipeline.failed.connect(self._on_pipeline_failed)
|
||||||
self.ask_pipeline.stage.connect(self.ask_overlay.show_busy)
|
self.ask_pipeline.stage.connect(self.ask_overlay.show_busy)
|
||||||
self.ask_pipeline.finished.connect(self._on_ask_finished)
|
self.ask_pipeline.finished.connect(self._on_ask_finished)
|
||||||
self.ask_pipeline.failed.connect(self._on_ask_error)
|
self.ask_pipeline.failed.connect(self._on_ask_error)
|
||||||
@@ -150,7 +208,7 @@ class Dikte:
|
|||||||
|
|
||||||
self.elapsed = QElapsedTimer()
|
self.elapsed = QElapsedTimer()
|
||||||
self.meeting_elapsed = QElapsedTimer()
|
self.meeting_elapsed = QElapsedTimer()
|
||||||
self.last_toggle = QElapsedTimer()
|
self.last_toggle = {} # action name -> QElapsedTimer, see _repeated
|
||||||
self.last_evdev = {}
|
self.last_evdev = {}
|
||||||
self.ticker = QTimer()
|
self.ticker = QTimer()
|
||||||
self.ticker.setInterval(100)
|
self.ticker.setInterval(100)
|
||||||
@@ -159,6 +217,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()
|
||||||
@@ -211,6 +280,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)
|
||||||
@@ -227,6 +301,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):
|
||||||
@@ -285,13 +360,18 @@ class Dikte:
|
|||||||
BUSY: ("Working…", "view-refresh", "Dikte: working"),
|
BUSY: ("Working…", "view-refresh", "Dikte: working"),
|
||||||
}
|
}
|
||||||
label, icon, tip = labels[self.state]
|
label, icon, tip = labels[self.state]
|
||||||
|
if self.state == BUSY:
|
||||||
|
# Still working, but the microphone is free again: the menu offers
|
||||||
|
# the next dictation rather than a wait.
|
||||||
|
label = "Start recording"
|
||||||
agent = assistant.display_name(self.conf)
|
agent = assistant.display_name(self.conf)
|
||||||
|
|
||||||
self.toggle_action.setText(t(label))
|
self.toggle_action.setText(t(label))
|
||||||
# Free while the other one is thinking, blocked only while it is holding
|
# Blocked only while something is holding the microphone: a transcript
|
||||||
# the microphone.
|
# still being cleaned up queues the next dictation behind it, and the
|
||||||
|
# agent thinking never blocked it at all.
|
||||||
self.toggle_action.setEnabled(
|
self.toggle_action.setEnabled(
|
||||||
self.state == RECORDING or (self.state == IDLE and not self.recording)
|
self.state == RECORDING or not self.recording
|
||||||
)
|
)
|
||||||
asked = i18n.name(agent, "dative")
|
asked = i18n.name(agent, "dative")
|
||||||
self.ask_action.setText(
|
self.ask_action.setText(
|
||||||
@@ -378,7 +458,10 @@ class Dikte:
|
|||||||
# Where nothing was installed there is no shortcut to catch up, and
|
# Where nothing was installed there is no shortcut to catch up, and
|
||||||
# retiring the listener would leave the keys with nowhere to arrive.
|
# retiring the listener would leave the keys with nowhere to arrive.
|
||||||
timer = self.last_evdev.get(name)
|
timer = self.last_evdev.get(name)
|
||||||
if (hotkey.installs_shortcuts() and self.evdev.running
|
# The snapshot from _apply_settings: which desktop this is cannot
|
||||||
|
# change under a running process, and asking hotkey again here costs a
|
||||||
|
# PATH scan on every key press.
|
||||||
|
if (self._registry_shortcuts and self.evdev.running
|
||||||
and timer is not None and timer.elapsed() < ECHO_MS):
|
and timer is not None and timer.elapsed() < ECHO_MS):
|
||||||
self._retire_listener()
|
self._retire_listener()
|
||||||
return
|
return
|
||||||
@@ -447,10 +530,6 @@ class Dikte:
|
|||||||
|
|
||||||
def _dictation_request(self, cmd, request, reply):
|
def _dictation_request(self, cmd, request, reply):
|
||||||
before = self.state
|
before = self.state
|
||||||
# Only a request that said something about pasting changes it, so that
|
|
||||||
# the stop half of a `start --paste` does not undo the start half.
|
|
||||||
if "paste" in request:
|
|
||||||
self.paste_override[DICTATION] = request["paste"]
|
|
||||||
if cmd == "toggle":
|
if cmd == "toggle":
|
||||||
self.toggle()
|
self.toggle()
|
||||||
elif cmd == "stop":
|
elif cmd == "stop":
|
||||||
@@ -461,13 +540,20 @@ class Dikte:
|
|||||||
if seconds > 0 and self.state == RECORDING:
|
if seconds > 0 and self.state == RECORDING:
|
||||||
run = self._run_id
|
run = self._run_id
|
||||||
QTimer.singleShot(int(seconds * 1000), lambda: self._auto_stop(run))
|
QTimer.singleShot(int(seconds * 1000), lambda: self._auto_stop(run))
|
||||||
|
# Armed only when the request actually moved this run along: a request
|
||||||
|
# that no-opped (the microphone held by the other one, or nothing to
|
||||||
|
# stop) must not leave a preference behind for some later, unrelated
|
||||||
|
# run to pick up. A stop that lands keeps changing the run it ends,
|
||||||
|
# so the stop half of a `start --paste` still does not undo the start.
|
||||||
|
if "paste" in request and self.state != before:
|
||||||
|
self.paste_override[DICTATION] = request["paste"]
|
||||||
self._answer(DICTATION, before, self.state, request, reply)
|
self._answer(DICTATION, before, self.state, request, reply)
|
||||||
|
|
||||||
def _ask_request(self, request, reply):
|
def _ask_request(self, request, reply):
|
||||||
before = self.ask_state
|
before = self.ask_state
|
||||||
if "paste" in request:
|
|
||||||
self.paste_override[ASK] = request["paste"]
|
|
||||||
self.toggle_ask()
|
self.toggle_ask()
|
||||||
|
if "paste" in request and self.ask_state != before:
|
||||||
|
self.paste_override[ASK] = request["paste"]
|
||||||
self._answer(ASK, before, self.ask_state, request, reply)
|
self._answer(ASK, before, self.ask_state, request, reply)
|
||||||
|
|
||||||
def _meeting_request(self, cmd, request, reply):
|
def _meeting_request(self, cmd, request, reply):
|
||||||
@@ -532,40 +618,70 @@ class Dikte:
|
|||||||
def _toggle(self):
|
def _toggle(self):
|
||||||
# Two /dev/input nodes can carry the same keyboard, and a menu click can
|
# Two /dev/input nodes can carry the same keyboard, and a menu click can
|
||||||
# land on top of a key press; swallow the immediate repeat.
|
# land on top of a key press; swallow the immediate repeat.
|
||||||
if self._repeated():
|
if self._repeated("toggle"):
|
||||||
return
|
return
|
||||||
if self.state == RECORDING:
|
if self.state == RECORDING:
|
||||||
self.stop()
|
self.stop()
|
||||||
elif self.state == IDLE:
|
else:
|
||||||
|
# BUSY does not block: the microphone is free while the last
|
||||||
|
# dictation is being cleaned up, and the next one starts now and
|
||||||
|
# waits its turn in the pipeline.
|
||||||
self.start()
|
self.start()
|
||||||
# a request during its own BUSY is ignored; nothing queues up
|
|
||||||
|
|
||||||
def _toggle_ask(self):
|
def _toggle_ask(self):
|
||||||
if self._repeated():
|
if self._repeated("ask"):
|
||||||
return
|
return
|
||||||
if self.ask_state == RECORDING:
|
if self.ask_state == RECORDING:
|
||||||
self.stop_ask()
|
self.stop_ask()
|
||||||
elif self.ask_state == IDLE:
|
elif self.ask_state == IDLE:
|
||||||
self.start_ask()
|
self.start_ask()
|
||||||
|
|
||||||
def _repeated(self):
|
def _repeated(self, name):
|
||||||
if self.last_toggle.isValid() and self.last_toggle.elapsed() < 400:
|
# Per action, the way last_evdev already is: the window is meant to
|
||||||
|
# swallow a duplicate delivery of the same press, not a pause landing
|
||||||
|
# right after the toggle that started the recording.
|
||||||
|
timer = self.last_toggle.get(name)
|
||||||
|
if timer is None:
|
||||||
|
timer = self.last_toggle[name] = QElapsedTimer()
|
||||||
|
if timer.isValid() and timer.elapsed() < 400:
|
||||||
return True
|
return True
|
||||||
self.last_toggle.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:
|
# Only a held microphone blocks: a previous dictation still being
|
||||||
|
# transcribed or cleaned up is the pipeline's business, not the
|
||||||
|
# recorder's.
|
||||||
|
if self.state == RECORDING 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,
|
||||||
|
# and the error handler put everything back; setting RECORDING on top
|
||||||
|
# of that would strand the state machine with no signal ever coming.
|
||||||
|
# The same guard start_meeting has always had.
|
||||||
|
if not self.recorder.active:
|
||||||
|
return
|
||||||
self._set_state(RECORDING)
|
self._set_state(RECORDING)
|
||||||
|
|
||||||
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:
|
||||||
|
return
|
||||||
self._set_ask_state(RECORDING)
|
self._set_ask_state(RECORDING)
|
||||||
|
|
||||||
def _begin_recording(self, owner):
|
def _begin_recording(self, owner):
|
||||||
@@ -576,6 +692,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:
|
||||||
@@ -583,7 +773,9 @@ class Dikte:
|
|||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
self._clear_pause()
|
self._clear_pause()
|
||||||
self._set_state(BUSY)
|
self._set_state(BUSY)
|
||||||
self.overlay.show_busy(t("Transcribing…"))
|
self.overlay.show_busy(t("Waiting for the one before it…")
|
||||||
|
if self._transcripts_pending
|
||||||
|
else t("Transcribing…"))
|
||||||
self.recorder.stop()
|
self.recorder.stop()
|
||||||
|
|
||||||
def stop_ask(self):
|
def stop_ask(self):
|
||||||
@@ -603,7 +795,7 @@ class Dikte:
|
|||||||
the phone call in the middle of a dictation never reaches the model and
|
the phone call in the middle of a dictation never reaches the model and
|
||||||
the sentence around it is still one sentence.
|
the sentence around it is still one sentence.
|
||||||
"""
|
"""
|
||||||
if not self.recording or self._repeated():
|
if not self.recording or self._repeated("pause"):
|
||||||
return
|
return
|
||||||
self.paused = not self.paused
|
self.paused = not self.paused
|
||||||
if self.paused:
|
if self.paused:
|
||||||
@@ -635,6 +827,8 @@ class Dikte:
|
|||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
self._clear_pause()
|
self._clear_pause()
|
||||||
self.recorder.cancel()
|
self.recorder.cancel()
|
||||||
|
# The preference dies with the run it was given for.
|
||||||
|
self.paste_override.pop(ASK if asking else DICTATION, None)
|
||||||
self.recorder_owner = None
|
self.recorder_owner = None
|
||||||
# What goes over the socket is read by a program as often as by a
|
# What goes over the socket is read by a program as often as by a
|
||||||
# person, so it stays in one language; only what a run itself said
|
# person, so it stays in one language; only what a run itself said
|
||||||
@@ -646,7 +840,9 @@ class Dikte:
|
|||||||
self._settle(ASK, dropped)
|
self._settle(ASK, dropped)
|
||||||
else:
|
else:
|
||||||
self.overlay.dismiss()
|
self.overlay.dismiss()
|
||||||
self._set_state(IDLE)
|
# An earlier dictation may still be in the pipeline; only the
|
||||||
|
# recording was thrown away.
|
||||||
|
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
||||||
self._settle(DICTATION, dropped)
|
self._settle(DICTATION, dropped)
|
||||||
|
|
||||||
def cancel_ask(self):
|
def cancel_ask(self):
|
||||||
@@ -691,6 +887,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"],
|
||||||
@@ -699,6 +901,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()
|
||||||
@@ -825,31 +1028,56 @@ 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._transcripts_pending += 1
|
||||||
|
self.pipeline.run(wav_path, duration, rms_values,
|
||||||
|
paste=wants_paste, focus=focus)
|
||||||
|
|
||||||
|
def _on_stage(self, message):
|
||||||
|
# The corner belongs to the recording when one is on: the previous
|
||||||
|
# run's progress must not wipe the waveform mid-sentence.
|
||||||
|
if self.state != RECORDING:
|
||||||
|
self.overlay.show_busy(message)
|
||||||
|
|
||||||
|
def _transcript_settled(self, payload):
|
||||||
|
"""One run out of the pipeline; where dictation stands now.
|
||||||
|
|
||||||
|
A request that asked to wait is answered once the queue is empty: with
|
||||||
|
runs finishing in the order they were spoken, the one it stopped is the
|
||||||
|
last of them, and an earlier run's result would be the wrong answer.
|
||||||
|
"""
|
||||||
|
self._transcripts_pending -= 1
|
||||||
|
if self.state != RECORDING:
|
||||||
|
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
||||||
|
if not self._transcripts_pending:
|
||||||
|
self._settle(DICTATION, payload)
|
||||||
|
|
||||||
def _on_finished(self, _raw, text, warning):
|
def _on_finished(self, _raw, text, warning):
|
||||||
if warning:
|
if warning:
|
||||||
# The text was still pasted, but cleanup did not run. Say so loudly:
|
# The text was still pasted, but cleanup did not run. Say so loudly:
|
||||||
# a rejected key otherwise looks exactly like working dictation.
|
# a rejected key otherwise looks exactly like working dictation.
|
||||||
self.overlay.show_warning(
|
if self.state != RECORDING:
|
||||||
t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0])
|
self.overlay.show_warning(
|
||||||
)
|
t("Pasted raw, cleanup failed: {error}",
|
||||||
|
error=warning.splitlines()[0])
|
||||||
|
)
|
||||||
self.tray.showMessage(
|
self.tray.showMessage(
|
||||||
t("Dikte: cleanup failed"), warning,
|
t("Dikte: cleanup failed"), warning,
|
||||||
QSystemTrayIcon.MessageIcon.Warning, 10000,
|
QSystemTrayIcon.MessageIcon.Warning, 10000,
|
||||||
)
|
)
|
||||||
else:
|
elif self.state != RECORDING:
|
||||||
|
# While a new recording is on, the flash is skipped: the text
|
||||||
|
# arriving where the cursor is says everything it would have.
|
||||||
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
|
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
|
||||||
self.overlay.show_done(
|
self.overlay.show_done(
|
||||||
t("{action}: {preview}", action=action, preview=_preview(text))
|
t("{action}: {preview}", action=action, preview=_preview(text))
|
||||||
)
|
)
|
||||||
self._set_state(IDLE)
|
self._transcript_settled({"ok": True, "text": text, "raw": _raw,
|
||||||
self._settle(DICTATION, {"ok": True, "text": text, "raw": _raw,
|
"warning": warning})
|
||||||
"warning": warning})
|
|
||||||
|
|
||||||
def _on_ask_finished(self, _raw, text, warning):
|
def _on_ask_finished(self, _raw, text, warning):
|
||||||
agent = assistant.display_name(self.conf)
|
agent = assistant.display_name(self.conf)
|
||||||
@@ -882,12 +1110,43 @@ class Dikte:
|
|||||||
def _on_recorder_error(self, message):
|
def _on_recorder_error(self, message):
|
||||||
"""The microphone itself could not run, so it belongs to whoever asked."""
|
"""The microphone itself could not run, so it belongs to whoever asked."""
|
||||||
owner, self.recorder_owner = self.recorder_owner, None
|
owner, self.recorder_owner = self.recorder_owner, None
|
||||||
|
self.paste_override.pop(owner, None)
|
||||||
self.ticker.stop()
|
self.ticker.stop()
|
||||||
(self._on_ask_error if owner == ASK else self._on_error)(message)
|
(self._on_ask_error if owner == ASK else self._on_error)(message)
|
||||||
|
|
||||||
|
def _on_pipeline_failed(self, message):
|
||||||
|
"""A run the pipeline gave up on; whatever queued behind it still runs."""
|
||||||
|
if self.state == RECORDING:
|
||||||
|
# The corner belongs to the new recording; the failure still has to
|
||||||
|
# be seen somewhere.
|
||||||
|
self.tray.showMessage("Dikte", message,
|
||||||
|
QSystemTrayIcon.MessageIcon.Warning, 8000)
|
||||||
|
else:
|
||||||
|
self._report(message, self.overlay)
|
||||||
|
self._transcript_settled({"ok": False, "error": message})
|
||||||
|
|
||||||
|
def _on_recorder_died(self):
|
||||||
|
"""The capture quit under a live recording: keep what it caught.
|
||||||
|
|
||||||
|
Ended the way a key press would end it, so the captured half is
|
||||||
|
transcribed rather than thrown away, and said out loud, because the
|
||||||
|
user is still talking at a microphone nobody is reading.
|
||||||
|
"""
|
||||||
|
owner = self.recorder_owner
|
||||||
|
self.tray.showMessage(
|
||||||
|
"Dikte",
|
||||||
|
t("The recording stopped on its own; transcribing what was captured."),
|
||||||
|
QSystemTrayIcon.MessageIcon.Warning, 8000,
|
||||||
|
)
|
||||||
|
if owner == ASK and self.ask_state == RECORDING:
|
||||||
|
self.stop_ask()
|
||||||
|
elif self.state == RECORDING:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
def _on_error(self, message):
|
def _on_error(self, message):
|
||||||
|
"""The recorder or the key listener failed; no run reached the pipeline."""
|
||||||
self._report(message, self.overlay)
|
self._report(message, self.overlay)
|
||||||
self._set_state(IDLE)
|
self._set_state(BUSY if self._transcripts_pending else IDLE)
|
||||||
self._settle(DICTATION, {"ok": False, "error": message})
|
self._settle(DICTATION, {"ok": False, "error": message})
|
||||||
|
|
||||||
def _on_ask_error(self, message):
|
def _on_ask_error(self, message):
|
||||||
@@ -901,21 +1160,106 @@ 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._make_settings()
|
||||||
self.settings_window.applied.connect(self._apply_settings)
|
|
||||||
self.settings_window.finished.connect(self._settings_closed)
|
|
||||||
self.settings_window.show()
|
self.settings_window.show()
|
||||||
self.settings_window.raise_()
|
self.settings_window.raise_()
|
||||||
self.settings_window.activateWindow()
|
self.settings_window.activateWindow()
|
||||||
|
|
||||||
|
def _make_settings(self):
|
||||||
|
"""Build the window without showing it, so a caller that knows where
|
||||||
|
it belongs can place it first."""
|
||||||
|
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||||
|
self.settings_window.applied.connect(self._apply_settings)
|
||||||
|
self.settings_window.language_changed.connect(self._reopen_settings)
|
||||||
|
self.settings_window.update_found.connect(self._found_update)
|
||||||
|
self.settings_window.finished.connect(self._settings_closed)
|
||||||
|
|
||||||
def _settings_closed(self, *_):
|
def _settings_closed(self, *_):
|
||||||
# Don't drop the object while its own signal is still being delivered.
|
# Don't drop the object while its own signal is still being delivered.
|
||||||
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
|
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
|
||||||
|
|
||||||
|
def _reopen_settings(self):
|
||||||
|
"""Replace the settings window, so a language change reaches it too.
|
||||||
|
|
||||||
|
A save switches the language everywhere strings are made at the moment
|
||||||
|
they are shown: the tray is rebuilt, the indicator and the message box
|
||||||
|
translate as they speak. The settings window is the one place written
|
||||||
|
once, at construction, so the window that took the new language is the
|
||||||
|
one place still showing the old one. A fresh window comes up where the
|
||||||
|
old one stood, on the same tab.
|
||||||
|
"""
|
||||||
|
old = self.settings_window
|
||||||
|
if old is None:
|
||||||
|
return
|
||||||
|
tab = old.tabs.currentIndex()
|
||||||
|
geometry = old.geometry()
|
||||||
|
# Replaced rather than merely closed: left connected, _settings_closed
|
||||||
|
# would drop the reference to the new window a moment after it is made.
|
||||||
|
old.finished.disconnect(self._settings_closed)
|
||||||
|
old.close()
|
||||||
|
# No deleteLater: a daemon thread of the old window's may still be
|
||||||
|
# running, and a closure holding self is what keeps the object alive
|
||||||
|
# until the thread is done. Dropping the reference is how the ordinary
|
||||||
|
# close path lets a window go, and it is enough here too.
|
||||||
|
self.settings_window = None
|
||||||
|
self._make_settings()
|
||||||
|
# Placed and turned to the old tab before it is shown, so the new
|
||||||
|
# window does not come up at the default size and jump.
|
||||||
|
self.settings_window.setGeometry(geometry)
|
||||||
|
self.settings_window.tabs.setCurrentIndex(tab)
|
||||||
|
self.settings_window.show()
|
||||||
|
self.settings_window.raise_()
|
||||||
|
self.settings_window.activateWindow()
|
||||||
|
|
||||||
def _apply_local(self):
|
def _apply_local(self):
|
||||||
"""Pass the local settings on, and hold the models ready if asked to.
|
"""Pass the local settings on, and hold the models ready if asked to.
|
||||||
|
|
||||||
@@ -956,10 +1300,13 @@ class Dikte:
|
|||||||
self._apply_local()
|
self._apply_local()
|
||||||
self._build_tray()
|
self._build_tray()
|
||||||
self._refresh_tray()
|
self._refresh_tray()
|
||||||
|
# Taken once here for _external: the answer cannot change under a
|
||||||
|
# running process, and re-deriving it there is a PATH scan per press.
|
||||||
|
self._registry_shortcuts = hotkey.installs_shortcuts()
|
||||||
# Where the desktop has no shortcut registry of its own, the listener is
|
# Where the desktop has no shortcut registry of its own, the listener is
|
||||||
# not the fallback the setting offers to turn on: it is the only way the
|
# not the fallback the setting offers to turn on: it is the only way the
|
||||||
# keys arrive at all, so it runs whatever the setting says.
|
# keys arrive at all, so it runs whatever the setting says.
|
||||||
if self.conf["evdev_hotkey"] or not hotkey.installs_shortcuts():
|
if self.conf["evdev_hotkey"] or not self._registry_shortcuts:
|
||||||
self.evdev.start({name: self.conf[spec.setting]
|
self.evdev.start({name: self.conf[spec.setting]
|
||||||
for name, spec in hotkey.SHORTCUTS.items()})
|
for name, spec in hotkey.SHORTCUTS.items()})
|
||||||
else:
|
else:
|
||||||
@@ -981,22 +1328,25 @@ class Dikte:
|
|||||||
if self.server is not None:
|
if self.server is not None:
|
||||||
self.server.close()
|
self.server.close()
|
||||||
QLocalServer.removeServer(SERVER_NAME)
|
QLocalServer.removeServer(SERVER_NAME)
|
||||||
args = ipc.launcher() + ["--gui"]
|
# The lock too, or the replacement would take this restart for a
|
||||||
if sys.platform == "win32":
|
# double start and hand the attention back to a process on its way out.
|
||||||
# execv on Windows mangles arguments with spaces and leaves the two
|
if self.instance_lock is not None:
|
||||||
# processes sharing a console; a detached start does neither.
|
self.instance_lock.unlock()
|
||||||
subprocess.Popen(
|
ipc.respawn(["--gui"])
|
||||||
args,
|
# respawn only returns on Windows, where the replacement was started
|
||||||
creationflags=(subprocess.DETACHED_PROCESS
|
# detached and this process still has to leave on its own.
|
||||||
| subprocess.CREATE_NEW_PROCESS_GROUP),
|
QApplication.instance().quit()
|
||||||
close_fds=True,
|
|
||||||
)
|
|
||||||
QApplication.instance().quit()
|
|
||||||
return
|
|
||||||
os.execv(args[0], args)
|
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self._quitting = True
|
self._quitting = True
|
||||||
|
# Waiters first, while the connections still work: a `--wait` left
|
||||||
|
# unanswered reads to the terminal as an instance too old to answer,
|
||||||
|
# which points the user at a version problem that does not exist.
|
||||||
|
# In one language, like every other error that goes over the socket:
|
||||||
|
# a script reads these as often as a person does.
|
||||||
|
for kind in (DICTATION, ASK, MEETING):
|
||||||
|
self._settle(kind, {"ok": False,
|
||||||
|
"error": "the instance is shutting down"})
|
||||||
self.evdev.stop()
|
self.evdev.stop()
|
||||||
if self.recording:
|
if self.recording:
|
||||||
self.recorder.cancel()
|
self.recorder.cancel()
|
||||||
@@ -1119,9 +1469,44 @@ def _stay_out_of_the_dock():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_over(command):
|
||||||
|
"""Give the running instance the attention this start was asking for.
|
||||||
|
|
||||||
|
A start carrying a verb forwards only that verb; a bare double start asks
|
||||||
|
for the Settings window as the sign of life the click was looking for.
|
||||||
|
Retried for a moment, because the copy that won the lock may not be
|
||||||
|
listening yet.
|
||||||
|
"""
|
||||||
|
verb = command or "settings"
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if ipc.send(verb) is not None:
|
||||||
|
return
|
||||||
|
time.sleep(0.2)
|
||||||
|
print("dikte: another copy holds the lock but never answered")
|
||||||
|
|
||||||
|
|
||||||
def run_app(args):
|
def run_app(args):
|
||||||
command = args[0] if args else ""
|
command = args[0] if args else ""
|
||||||
|
|
||||||
|
# One Dikte per user. The lock closes the simultaneous-start window two
|
||||||
|
# probes would both fall through; the probe still runs behind it, because
|
||||||
|
# an instance from before the lock existed holds only the socket. Both
|
||||||
|
# sit before the QApplication, so a second copy costs a moment and not a
|
||||||
|
# second tray icon. The lock lives in this frame, which app.exec() below
|
||||||
|
# keeps alive for exactly the process's lifetime.
|
||||||
|
lock = ipc.instance_lock()
|
||||||
|
if lock is not None and not lock.tryLock(0):
|
||||||
|
_hand_over(command)
|
||||||
|
return 0
|
||||||
|
if ipc.already_serving():
|
||||||
|
print("dikte: already running; handing it the attention")
|
||||||
|
if command:
|
||||||
|
ipc.send(command)
|
||||||
|
else:
|
||||||
|
ipc.send("settings")
|
||||||
|
return 0
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Dikte")
|
app.setApplicationName("Dikte")
|
||||||
app.setDesktopFileName("dikte")
|
app.setDesktopFileName("dikte")
|
||||||
@@ -1149,6 +1534,9 @@ def run_app(args):
|
|||||||
print("dikte: no system tray found, running anyway")
|
print("dikte: no system tray found, running anyway")
|
||||||
|
|
||||||
dikte = Dikte(app)
|
dikte = Dikte(app)
|
||||||
|
# Handed over so that restart() can let go of it before the replacement
|
||||||
|
# tries to take it.
|
||||||
|
dikte.instance_lock = lock
|
||||||
|
|
||||||
server = QLocalServer()
|
server = QLocalServer()
|
||||||
# Qt puts the socket in /tmp, so keep it to this user: commands like
|
# Qt puts the socket in /tmp, so keep it to this user: commands like
|
||||||
|
|||||||
+120
-40
@@ -24,13 +24,17 @@ while they work.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from . import api
|
from . import api
|
||||||
from . import config as cfg
|
from . import config as cfg
|
||||||
|
from . import paths
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
|
SESSION_FILE = cfg.DATA_DIR / "assistant.json"
|
||||||
@@ -114,13 +118,19 @@ def display_name(conf):
|
|||||||
# one along costs tokens and invites an answer to the wrong question. Switching
|
# one along costs tokens and invites an answer to the wrong question. Switching
|
||||||
# provider drops it too, since none of them can pick up another's thread.
|
# provider drops it too, since none of them can pick up another's thread.
|
||||||
|
|
||||||
def _read_row(name, max_age_seconds):
|
def _read_session():
|
||||||
|
"""The stored conversation row, or {} however the file fails to read."""
|
||||||
try:
|
try:
|
||||||
with open(SESSION_FILE, encoding="utf-8") as fh:
|
with open(SESSION_FILE, encoding="utf-8") as fh:
|
||||||
row = json.load(fh)
|
row = json.load(fh)
|
||||||
except (OSError, json.JSONDecodeError, ValueError):
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
return {}
|
return {}
|
||||||
if not isinstance(row, dict) or row.get("provider") != name:
|
return row if isinstance(row, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_row(name, max_age_seconds):
|
||||||
|
row = _read_session()
|
||||||
|
if row.get("provider") != name:
|
||||||
return {}
|
return {}
|
||||||
if max_age_seconds and time.time() - row.get("ts", 0) > max_age_seconds:
|
if max_age_seconds and time.time() - row.get("ts", 0) > max_age_seconds:
|
||||||
return {}
|
return {}
|
||||||
@@ -159,22 +169,13 @@ def clear_session():
|
|||||||
|
|
||||||
def stored_provider():
|
def stored_provider():
|
||||||
"""Whose conversation is on disk, whatever the setting says now."""
|
"""Whose conversation is on disk, whatever the setting says now."""
|
||||||
try:
|
return str(_read_session().get("provider", ""))
|
||||||
with open(SESSION_FILE, encoding="utf-8") as fh:
|
|
||||||
row = json.load(fh)
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError):
|
|
||||||
return ""
|
|
||||||
return str(row.get("provider", "")) if isinstance(row, dict) else ""
|
|
||||||
|
|
||||||
|
|
||||||
def session_age():
|
def session_age():
|
||||||
"""Seconds since the stored conversation was last used, or None."""
|
"""Seconds since the stored conversation was last used, or None."""
|
||||||
try:
|
row = _read_session()
|
||||||
with open(SESSION_FILE, encoding="utf-8") as fh:
|
if not (row.get("session") or row.get("messages")):
|
||||||
row = json.load(fh)
|
|
||||||
except (OSError, json.JSONDecodeError, ValueError):
|
|
||||||
return None
|
|
||||||
if not isinstance(row, dict) or not (row.get("session") or row.get("messages")):
|
|
||||||
return None
|
return None
|
||||||
return time.time() - row.get("ts", 0)
|
return time.time() - row.get("ts", 0)
|
||||||
|
|
||||||
@@ -338,6 +339,33 @@ def _codex_label(item):
|
|||||||
return t("Using {name}…", name=item_type or "a tool")
|
return t("Using {name}…", name=item_type or "a tool")
|
||||||
|
|
||||||
|
|
||||||
|
def codex_models():
|
||||||
|
"""The models Codex itself would offer right now, best first.
|
||||||
|
|
||||||
|
`codex debug models` prints the catalog the CLI's own model picker reads,
|
||||||
|
fetched from OpenAI and cached beside Codex's config, so the list is as
|
||||||
|
current as the installed Codex and there is no second list to keep up to
|
||||||
|
date here. Entries the picker hides are internal and stay hidden. A machine
|
||||||
|
without Codex, or one too old to have the command, answers with nothing and
|
||||||
|
the caller keeps its built-in list.
|
||||||
|
"""
|
||||||
|
if not shutil.which("codex"):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(["codex", "debug", "models"],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
catalog = json.loads(proc.stdout or "null")
|
||||||
|
except (OSError, subprocess.SubprocessError, ValueError):
|
||||||
|
return []
|
||||||
|
if not isinstance(catalog, dict):
|
||||||
|
return []
|
||||||
|
rows = [row for row in catalog.get("models") or []
|
||||||
|
if isinstance(row, dict) and row.get("slug")
|
||||||
|
and row.get("visibility") != "hide"]
|
||||||
|
rows.sort(key=lambda row: row.get("priority") or 0)
|
||||||
|
return [row["slug"] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
# --- OpenRouter -----------------------------------------------------------
|
# --- OpenRouter -----------------------------------------------------------
|
||||||
|
|
||||||
def _ask_openrouter(prompt, conf, on_stage):
|
def _ask_openrouter(prompt, conf, on_stage):
|
||||||
@@ -373,14 +401,24 @@ def _stream(cmd, conf, on_event, should_stop):
|
|||||||
Returns (exit code, stderr). Raises Cancelled when the stop was asked for,
|
Returns (exit code, stderr). Raises Cancelled when the stop was asked for,
|
||||||
and AssistantError when the clock ran out.
|
and AssistantError when the clock ran out.
|
||||||
"""
|
"""
|
||||||
|
# stderr lands in a file rather than a pipe: nobody drains it while stdout
|
||||||
|
# is being read, and a CLI chatty enough on stderr would fill the pipe's
|
||||||
|
# buffer and wedge both of us. A file has no such limit, and is read once
|
||||||
|
# at the end, which is the only moment stderr matters.
|
||||||
|
stderr_file = tempfile.TemporaryFile()
|
||||||
|
# On POSIX the run gets its own session, so that ending it can take down
|
||||||
|
# every subprocess it started, not just the CLI itself.
|
||||||
|
grouped = {"start_new_session": True} if os.name == "posix" else {}
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
|
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
stdout=subprocess.PIPE, stderr=stderr_file,
|
||||||
text=True, encoding="utf-8", errors="replace", bufsize=1,
|
text=True, encoding="utf-8", errors="replace", bufsize=1,
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=paths.NO_WINDOW,
|
||||||
|
**grouped,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
stderr_file.close()
|
||||||
raise AssistantError(t("Could not run {binary}: {error}",
|
raise AssistantError(t("Could not run {binary}: {error}",
|
||||||
binary=cmd[0], error=exc)) from exc
|
binary=cmd[0], error=exc)) from exc
|
||||||
|
|
||||||
@@ -409,7 +447,7 @@ def _stream(cmd, conf, on_event, should_stop):
|
|||||||
if isinstance(event, dict):
|
if isinstance(event, dict):
|
||||||
on_event(event)
|
on_event(event)
|
||||||
finally:
|
finally:
|
||||||
stderr = _finish(proc)
|
stderr = _finish(proc, stderr_file)
|
||||||
watchdog.join(timeout=1)
|
watchdog.join(timeout=1)
|
||||||
|
|
||||||
if ended["cancelled"]:
|
if ended["cancelled"]:
|
||||||
@@ -420,12 +458,31 @@ def _stream(cmd, conf, on_event, should_stop):
|
|||||||
return proc.returncode, stderr
|
return proc.returncode, stderr
|
||||||
|
|
||||||
|
|
||||||
|
# Failures that a fresh session cannot cure: an exhausted quota, a signed-out
|
||||||
|
# CLI, a network that is down. A resumed run that dies with one of these is
|
||||||
|
# reported as what it is, not retried without the session, because the retry
|
||||||
|
# would fail the same way after making the user wait through a second run.
|
||||||
|
_API_TROUBLE = re.compile(
|
||||||
|
r"(?i)rate.?limit|quota|overloaded|too many requests|credit|billing|"
|
||||||
|
r"insufficient|unauthorized|forbidden|authentication|invalid.{0,8}key|"
|
||||||
|
r"log ?in|logged.?out|network|connection|ECONN|ENOTFOUND|ETIMEDOUT|"
|
||||||
|
r"\b(401|403|429|5\d\d)\b")
|
||||||
|
|
||||||
|
|
||||||
def _conclude(found, code, stderr, session, service):
|
def _conclude(found, code, stderr, session, service):
|
||||||
"""Turn what the stream said into an answer, or into the reason there is none."""
|
"""Turn what the stream said into an answer, or into the reason there is none."""
|
||||||
if code != 0 and not found["answer"]:
|
if code != 0 and not found["answer"]:
|
||||||
if session and _session_missing(stderr):
|
# A resumed run that died with nothing to show is treated as the
|
||||||
|
# session being gone, whatever the wording: this code used to look for
|
||||||
|
# "session ... not found" in stderr, but a CLI update or another
|
||||||
|
# language rewords that and the recovery stops working. Retrying costs
|
||||||
|
# one clean start, and cannot loop because the retry resumes nothing.
|
||||||
|
# Recognised API trouble is the exception: it is not the session's
|
||||||
|
# fault, and the retry would only repeat it.
|
||||||
|
blame = last_line(stderr) or found["failure"] or ""
|
||||||
|
if session and not _API_TROUBLE.search(blame):
|
||||||
raise _SessionGone()
|
raise _SessionGone()
|
||||||
raise AssistantError(last_line(stderr) or found["failure"] or t(
|
raise AssistantError(blame or t(
|
||||||
"{service} exited with code {code}.", service=service, code=code))
|
"{service} exited with code {code}.", service=service, code=code))
|
||||||
if found["failure"] and not found["answer"]:
|
if found["failure"] and not found["answer"]:
|
||||||
raise AssistantError(found["failure"])
|
raise AssistantError(found["failure"])
|
||||||
@@ -436,14 +493,6 @@ def _conclude(found, code, stderr, session, service):
|
|||||||
return found["answer"], found["warning"]
|
return found["answer"], found["warning"]
|
||||||
|
|
||||||
|
|
||||||
def _session_missing(stderr):
|
|
||||||
lowered = (stderr or "").lower()
|
|
||||||
if "session" in lowered or "thread" in lowered or "conversation" in lowered:
|
|
||||||
return any(word in lowered for word in ("not found", "no such", "unknown",
|
|
||||||
"does not exist", "no conversation"))
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _watch(proc, deadline, should_stop, ended):
|
def _watch(proc, deadline, should_stop, ended):
|
||||||
while proc.poll() is None:
|
while proc.poll() is None:
|
||||||
if should_stop is not None and should_stop():
|
if should_stop is not None and should_stop():
|
||||||
@@ -454,29 +503,60 @@ def _watch(proc, deadline, should_stop, ended):
|
|||||||
break
|
break
|
||||||
time.sleep(0.25)
|
time.sleep(0.25)
|
||||||
if ended["cancelled"] or ended["timed_out"]:
|
if ended["cancelled"] or ended["timed_out"]:
|
||||||
_kill(proc)
|
kill_tree(proc)
|
||||||
|
|
||||||
|
|
||||||
def _kill(proc):
|
def kill_tree(proc):
|
||||||
|
"""End the process and everything it started.
|
||||||
|
|
||||||
|
A CLI runs tools as subprocesses of its own, and ending only the CLI would
|
||||||
|
leave those behind, still working on a question nobody is waiting for.
|
||||||
|
Shared with cleanup, which runs the same two programs. Every failure here
|
||||||
|
is swallowed: the process being already gone is the outcome being asked for.
|
||||||
|
"""
|
||||||
|
if os.name == "nt":
|
||||||
|
# There is no process group to signal on Windows; taskkill walks the
|
||||||
|
# tree instead. The wait after it is best-effort, so a tree that will
|
||||||
|
# not die does not hang the caller on top of everything else.
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
||||||
|
capture_output=True,
|
||||||
|
creationflags=paths.NO_WINDOW,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
# The Popen was started with start_new_session=True, so the pid names a
|
||||||
|
# whole session to signal. SIGTERM first for a clean exit, SIGKILL for a
|
||||||
|
# tree that ignored it.
|
||||||
|
try:
|
||||||
|
os.killpg(proc.pid, signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=3)
|
proc.wait(timeout=3)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
try:
|
||||||
except OSError:
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
pass
|
except (ProcessLookupError, PermissionError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _finish(proc):
|
def _finish(proc, stderr_file):
|
||||||
try:
|
|
||||||
stderr = proc.stderr.read() or ""
|
|
||||||
except (OSError, ValueError):
|
|
||||||
stderr = ""
|
|
||||||
try:
|
try:
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
kill_tree(proc)
|
||||||
for stream in (proc.stdout, proc.stderr):
|
# Read back what the CLI wrote to its stderr file, decoded leniently: a
|
||||||
|
# dying CLI is exactly the one likely to print something half-encoded.
|
||||||
|
try:
|
||||||
|
stderr_file.seek(0)
|
||||||
|
stderr = stderr_file.read().decode("utf-8", "replace")
|
||||||
|
except (OSError, ValueError):
|
||||||
|
stderr = ""
|
||||||
|
for stream in (proc.stdout, stderr_file):
|
||||||
try:
|
try:
|
||||||
stream.close()
|
stream.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
+157
-50
@@ -31,11 +31,21 @@ import wave
|
|||||||
|
|
||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
|
from . import paths
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
# Console programs started from a windowless process would otherwise each open
|
# Squaring a chunk sample by sample in Python is the most expensive thing the
|
||||||
# a console window of their own on Windows.
|
# level meter does, and it does it for every chunk of every recording. sumprod
|
||||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
|
# stays in C for the whole sum; it arrived in 3.12 and the floor here is 3.11,
|
||||||
|
# so the plain loop remains as the fallback. Both produce the same integer.
|
||||||
|
try:
|
||||||
|
from math import sumprod
|
||||||
|
except ImportError:
|
||||||
|
sumprod = None
|
||||||
|
|
||||||
|
# See paths.NO_WINDOW; re-exported here because this module's callers and
|
||||||
|
# tests have always read it under this name.
|
||||||
|
NO_WINDOW = paths.NO_WINDOW
|
||||||
|
|
||||||
RATE = 16000
|
RATE = 16000
|
||||||
CHANNELS = 1
|
CHANNELS = 1
|
||||||
@@ -79,12 +89,15 @@ class Recorder(QObject):
|
|||||||
|
|
||||||
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
||||||
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
||||||
|
died = pyqtSignal() # the capture quit mid-recording
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._proc = None
|
self._proc = None
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
self._log = None
|
||||||
|
self._run = None
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
self._rms = []
|
self._rms = []
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
@@ -126,12 +139,17 @@ class Recorder(QObject):
|
|||||||
self.failed.emit(t(sound().missing))
|
self.failed.emit(t(sound().missing))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# The recorder keeps talking to stderr for as long as it runs; a pipe
|
||||||
|
# nobody drains would eventually block it, so it writes to a file.
|
||||||
|
self._drop_log()
|
||||||
|
self._log = tempfile.TemporaryFile()
|
||||||
try:
|
try:
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0,
|
cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0,
|
||||||
creationflags=NO_WINDOW,
|
creationflags=NO_WINDOW,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
self._drop_log()
|
||||||
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -141,15 +159,21 @@ class Recorder(QObject):
|
|||||||
self._stopping = False
|
self._stopping = False
|
||||||
self._paused = False
|
self._paused = False
|
||||||
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
||||||
self._thread = threading.Thread(target=self._pump, daemon=True)
|
# The pump is handed this run's objects rather than reading them off
|
||||||
|
# self, and a token to say whose run it still is: a pump that outlives
|
||||||
|
# its 2 s join must not touch the recording that comes after it.
|
||||||
|
self._run = object()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._pump, daemon=True,
|
||||||
|
args=(self._run, self._proc, self._proc.stdout,
|
||||||
|
self._buffer, self._rms, self._max_bytes),
|
||||||
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _pump(self):
|
def _pump(self, run, proc, stdout, buffer, rms, max_bytes):
|
||||||
proc = self._proc
|
|
||||||
stdout = proc.stdout
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = stdout.read(CHUNK_BYTES)
|
chunk = _read_exact(stdout, CHUNK_BYTES)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
if self._paused:
|
if self._paused:
|
||||||
@@ -157,33 +181,58 @@ class Recorder(QObject):
|
|||||||
# nobody empties fills up, and the capture program blocks on
|
# nobody empties fills up, and the capture program blocks on
|
||||||
# a full one instead of waiting quietly for the resume.
|
# a full one instead of waiting quietly for the resume.
|
||||||
continue
|
continue
|
||||||
peak, rms = chunk_levels(chunk)
|
peak, chunk_rms = chunk_levels(chunk)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._buffer.extend(chunk)
|
buffer.extend(chunk)
|
||||||
self._rms.append(rms)
|
rms.append(chunk_rms)
|
||||||
too_long = len(self._buffer) >= self._max_bytes
|
too_long = len(buffer) >= max_bytes
|
||||||
|
if self._run is not run:
|
||||||
|
# This recording was given up on; whatever happens now
|
||||||
|
# belongs to the run that replaced it, not to this one.
|
||||||
|
return
|
||||||
self.level.emit(peak)
|
self.level.emit(peak)
|
||||||
if too_long:
|
if too_long:
|
||||||
self._terminate()
|
self._terminate()
|
||||||
break
|
break
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
if self._run is not run:
|
||||||
|
return
|
||||||
|
if self._stopping or self._cancelled:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
captured = bool(buffer)
|
||||||
|
if captured:
|
||||||
|
# Sound had already arrived and nobody asked it to end: the device
|
||||||
|
# went away, or the recorder fell over mid-dictation. That has to
|
||||||
|
# be said while there is still something worth keeping.
|
||||||
|
self.died.emit()
|
||||||
|
return
|
||||||
# Nobody asked it to end and it captured nothing: the recorder is not
|
# Nobody asked it to end and it captured nothing: the recorder is not
|
||||||
# installed properly, or the device was refused. Said out loud here,
|
# installed properly, or the device was refused. Said out loud here,
|
||||||
# because stop() would otherwise report it as a recording that was too
|
# because stop() would otherwise report it as a recording that was too
|
||||||
# short, which sends the user looking in the wrong place.
|
# short, which sends the user looking in the wrong place.
|
||||||
with self._lock:
|
detail = self._error_tail()
|
||||||
captured = bool(self._buffer)
|
# poll() first, because returncode stays None until somebody reaps the
|
||||||
if self._stopping or self._cancelled or captured:
|
# process, and "exit code None" answers nothing.
|
||||||
return
|
code = proc.poll()
|
||||||
try:
|
if not detail and code is not None:
|
||||||
detail = proc.stderr.read().decode("utf-8", "replace").strip()
|
detail = f"exit code {code}"
|
||||||
except (AttributeError, OSError):
|
if detail:
|
||||||
detail = ""
|
self.failed.emit(t(
|
||||||
self.failed.emit(t(
|
"Audio recorder stopped before receiving sound: {error}",
|
||||||
"Audio recorder stopped before receiving sound: {error}",
|
error=detail,
|
||||||
error=detail or f"exit code {proc.returncode}",
|
))
|
||||||
))
|
else:
|
||||||
|
self.failed.emit(t("Audio recorder stopped before receiving sound"))
|
||||||
|
|
||||||
|
def _error_tail(self):
|
||||||
|
log = self._log
|
||||||
|
return _last_log_line(log) if log is not None else ""
|
||||||
|
|
||||||
|
def _drop_log(self):
|
||||||
|
log, self._log = self._log, None
|
||||||
|
_close_log(log)
|
||||||
|
|
||||||
def _terminate(self):
|
def _terminate(self):
|
||||||
self._stopping = True
|
self._stopping = True
|
||||||
@@ -195,7 +244,10 @@ class Recorder(QObject):
|
|||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except OSError:
|
# Reaped even after a kill, or the child stays a zombie
|
||||||
|
# holding its slot in the process table.
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
@@ -205,6 +257,8 @@ class Recorder(QObject):
|
|||||||
self._thread.join(timeout=2)
|
self._thread.join(timeout=2)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
self._run = None
|
||||||
|
self._drop_log()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
|
|
||||||
@@ -217,7 +271,11 @@ class Recorder(QObject):
|
|||||||
self._thread.join(timeout=2)
|
self._thread.join(timeout=2)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
self._run = None
|
||||||
|
self._drop_log()
|
||||||
|
|
||||||
|
# The same buffer object the pump was handed, harvested under the same
|
||||||
|
# lock it appends with.
|
||||||
with self._lock:
|
with self._lock:
|
||||||
pcm = bytes(self._buffer)
|
pcm = bytes(self._buffer)
|
||||||
rms = list(self._rms)
|
rms = list(self._rms)
|
||||||
@@ -231,7 +289,13 @@ class Recorder(QObject):
|
|||||||
self.failed.emit(t("Recording too short, speak for at least 0.3 s"))
|
self.failed.emit(t("Recording too short, speak for at least 0.3 s"))
|
||||||
return
|
return
|
||||||
|
|
||||||
path = write_wav(pcm)
|
try:
|
||||||
|
path = write_wav(pcm)
|
||||||
|
except (OSError, wave.Error) as exc:
|
||||||
|
# A full disk or an unwritable temp directory costs this recording
|
||||||
|
# either way; a message beats a traceback in the journal.
|
||||||
|
self.failed.emit(t("Could not write the recording: {error}", error=exc))
|
||||||
|
return
|
||||||
self.stopped.emit(path, frames / RATE, rms)
|
self.stopped.emit(path, frames / RATE, rms)
|
||||||
|
|
||||||
|
|
||||||
@@ -284,6 +348,7 @@ class MeetingRecorder(QObject):
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._procs = []
|
self._procs = []
|
||||||
|
self._interrupted = set()
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._wav = None
|
self._wav = None
|
||||||
self._logs = []
|
self._logs = []
|
||||||
@@ -336,6 +401,7 @@ class MeetingRecorder(QObject):
|
|||||||
# nobody drains would eventually block it, so it writes to a file.
|
# nobody drains would eventually block it, so it writes to a file.
|
||||||
self._logs = [tempfile.TemporaryFile() for _ in commands]
|
self._logs = [tempfile.TemporaryFile() for _ in commands]
|
||||||
self._procs = []
|
self._procs = []
|
||||||
|
self._interrupted = set()
|
||||||
for command, log in zip(commands, self._logs):
|
for command, log in zip(commands, self._logs):
|
||||||
self._procs.append(subprocess.Popen(
|
self._procs.append(subprocess.Popen(
|
||||||
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
|
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
|
||||||
@@ -440,14 +506,20 @@ class MeetingRecorder(QObject):
|
|||||||
try:
|
try:
|
||||||
_interrupt(proc)
|
_interrupt(proc)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
continue
|
||||||
|
# ffmpeg reports being interrupted as a failure; stop() needs to
|
||||||
|
# know which exits were our own doing and which were real deaths.
|
||||||
|
self._interrupted.add(proc)
|
||||||
for proc in running:
|
for proc in running:
|
||||||
try:
|
try:
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except OSError:
|
# Reaped even after a kill, or the child stays a zombie
|
||||||
|
# holding its slot in the process table.
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _close_file(self):
|
def _close_file(self):
|
||||||
@@ -460,24 +532,23 @@ class MeetingRecorder(QObject):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _error_tail(self):
|
def _error_tail(self):
|
||||||
tails = []
|
tails = [_last_log_line(log) for log in self._logs]
|
||||||
for log in self._logs:
|
return " | ".join(tail for tail in tails if tail)
|
||||||
try:
|
|
||||||
log.seek(0)
|
|
||||||
text = log.read().decode("utf-8", "replace").strip()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
lines = [line for line in text.splitlines() if line.strip()]
|
|
||||||
if lines:
|
|
||||||
tails.append(lines[-1])
|
|
||||||
return " | ".join(tails)
|
|
||||||
|
|
||||||
def _finish_process(self):
|
def _finish_process(self):
|
||||||
self._terminate()
|
self._terminate()
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=3)
|
self._thread.join(timeout=3)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
codes = [proc.poll() for proc in self._procs]
|
codes = []
|
||||||
|
for proc in self._procs:
|
||||||
|
code = proc.poll()
|
||||||
|
# A nonzero exit from a process we interrupted ourselves is ffmpeg
|
||||||
|
# complaining about our own stop; one that had already died on its
|
||||||
|
# own keeps its code, because that one is the story.
|
||||||
|
if code and proc in self._interrupted:
|
||||||
|
code = 0
|
||||||
|
codes.append(code)
|
||||||
code = next((value for value in codes if value), 0)
|
code = next((value for value in codes if value), 0)
|
||||||
self._procs = []
|
self._procs = []
|
||||||
self._close_file()
|
self._close_file()
|
||||||
@@ -534,13 +605,30 @@ class MeetingRecorder(QObject):
|
|||||||
|
|
||||||
def _drop_log(self):
|
def _drop_log(self):
|
||||||
for log in self._logs:
|
for log in self._logs:
|
||||||
try:
|
_close_log(log)
|
||||||
log.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
self._logs = []
|
self._logs = []
|
||||||
|
|
||||||
|
|
||||||
|
def _last_log_line(log):
|
||||||
|
"""The last thing a recorder said before it ended, or ''."""
|
||||||
|
try:
|
||||||
|
log.seek(0)
|
||||||
|
text = log.read().decode("utf-8", "replace").strip()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return ""
|
||||||
|
lines = [line for line in text.splitlines() if line.strip()]
|
||||||
|
return lines[-1] if lines else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _close_log(log):
|
||||||
|
if log is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
log.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def chunk_levels(chunk):
|
def chunk_levels(chunk):
|
||||||
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
|
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
|
||||||
samples = array.array("h")
|
samples = array.array("h")
|
||||||
@@ -549,7 +637,9 @@ def chunk_levels(chunk):
|
|||||||
return 0.0, 0.0
|
return 0.0, 0.0
|
||||||
samples.frombytes(chunk[:usable])
|
samples.frombytes(chunk[:usable])
|
||||||
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
|
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
|
||||||
rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32768.0
|
power = (sumprod(samples, samples) if sumprod is not None
|
||||||
|
else sum(s * s for s in samples))
|
||||||
|
rms = math.sqrt(power / len(samples)) / 32768.0
|
||||||
return min(1.0, peak), min(1.0, rms)
|
return min(1.0, peak), min(1.0, rms)
|
||||||
|
|
||||||
|
|
||||||
@@ -638,6 +728,13 @@ MERGE_FILTER = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Whether pw-record takes --raw, asked of the binary once per process: the
|
||||||
|
# probe costs a subprocess, and the answer cannot change under a running
|
||||||
|
# application. Kept here rather than inside _pw_record_raw_option so the probe
|
||||||
|
# itself stays testable against different binaries.
|
||||||
|
_PW_RAW = None
|
||||||
|
|
||||||
|
|
||||||
def _pulse_record(target):
|
def _pulse_record(target):
|
||||||
"""parec, or pw-record where PulseAudio's tools were left out.
|
"""parec, or pw-record where PulseAudio's tools were left out.
|
||||||
|
|
||||||
@@ -645,6 +742,7 @@ def _pulse_record(target):
|
|||||||
service, and its source names are the same ones shown by list_sources().
|
service, and its source names are the same ones shown by list_sources().
|
||||||
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||||
"""
|
"""
|
||||||
|
global _PW_RAW
|
||||||
if shutil.which("parec"):
|
if shutil.which("parec"):
|
||||||
cmd = [
|
cmd = [
|
||||||
"parec", "--record", "--raw", f"--rate={RATE}",
|
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||||
@@ -660,8 +758,10 @@ def _pulse_record(target):
|
|||||||
cmd.append(f"--device={target}")
|
cmd.append(f"--device={target}")
|
||||||
return cmd
|
return cmd
|
||||||
if shutil.which("pw-record"):
|
if shutil.which("pw-record"):
|
||||||
|
if _PW_RAW is None:
|
||||||
|
_PW_RAW = _pw_record_raw_option()
|
||||||
cmd = [
|
cmd = [
|
||||||
"pw-record", *_pw_record_raw_option(), f"--rate={RATE}",
|
"pw-record", *_PW_RAW, f"--rate={RATE}",
|
||||||
f"--channels={CHANNELS}", "--format=s16",
|
f"--channels={CHANNELS}", "--format=s16",
|
||||||
]
|
]
|
||||||
if target:
|
if target:
|
||||||
@@ -682,8 +782,11 @@ def _pw_record_raw_option():
|
|||||||
that line, so ask the installed binary which form it understands.
|
that line, so ask the installed binary which form it understands.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# utf-8 spelled out: subprocess otherwise decodes with the locale's
|
||||||
|
# codec, and help text through a codec it was not written in raises.
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pw-record", "--help"], capture_output=True, text=True, timeout=2
|
["pw-record", "--help"], capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace", timeout=2,
|
||||||
)
|
)
|
||||||
help_text = (result.stdout or "") + (result.stderr or "")
|
help_text = (result.stdout or "") + (result.stderr or "")
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
@@ -708,9 +811,12 @@ def _pactl_sources():
|
|||||||
if not shutil.which("pactl"):
|
if not shutil.which("pactl"):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
|
# utf-8 spelled out: device descriptions carry whatever alphabet the
|
||||||
|
# machine speaks, and the locale's codec is not always able to say so.
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["pactl", "-f", "json", "list", "sources"],
|
["pactl", "-f", "json", "list", "sources"],
|
||||||
capture_output=True, text=True, timeout=5, check=True,
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||||
|
timeout=5, check=True,
|
||||||
).stdout
|
).stdout
|
||||||
return json.loads(out)
|
return json.loads(out)
|
||||||
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
|
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
|
||||||
@@ -739,7 +845,8 @@ def _pulse_default_output():
|
|||||||
try:
|
try:
|
||||||
sink = subprocess.run(
|
sink = subprocess.run(
|
||||||
["pactl", "get-default-sink"],
|
["pactl", "get-default-sink"],
|
||||||
capture_output=True, text=True, timeout=5, check=True,
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||||
|
timeout=5, check=True,
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+39
-16
@@ -21,6 +21,7 @@ import tempfile
|
|||||||
from . import api
|
from . import api
|
||||||
from . import assistant
|
from . import assistant
|
||||||
from . import ggml
|
from . import ggml
|
||||||
|
from . import paths
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
PROVIDERS = ("openrouter", "local", "claude", "codex")
|
PROVIDERS = ("openrouter", "local", "claude", "codex")
|
||||||
@@ -194,21 +195,43 @@ def _output(cmd, timeout, service):
|
|||||||
"{binary} not found. Install it, or have OpenRouter clean up "
|
"{binary} not found. Install it, or have OpenRouter clean up "
|
||||||
"instead, under Settings → API and models.", binary=binary,
|
"instead, under Settings → API and models.", binary=binary,
|
||||||
))
|
))
|
||||||
|
# Both streams land in files rather than pipes: nobody drains a pipe while
|
||||||
|
# the process is being waited out, and a CLI chatty enough would fill the
|
||||||
|
# buffer and wedge. And a timeout must end the CLI's tool subprocesses too,
|
||||||
|
# not just the CLI, which subprocess.run's timeout does not do; hence the
|
||||||
|
# own session on POSIX and assistant.kill_tree on the way out.
|
||||||
|
out_file = tempfile.TemporaryFile()
|
||||||
|
err_file = tempfile.TemporaryFile()
|
||||||
|
grouped = {"start_new_session": True} if os.name == "posix" else {}
|
||||||
try:
|
try:
|
||||||
done = subprocess.run(
|
try:
|
||||||
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
|
proc = subprocess.Popen(
|
||||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
|
||||||
timeout=timeout,
|
stdout=out_file, stderr=err_file,
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=paths.NO_WINDOW,
|
||||||
)
|
**grouped,
|
||||||
except subprocess.TimeoutExpired:
|
)
|
||||||
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
|
except OSError as exc:
|
||||||
service=service, seconds=timeout)) from None
|
raise CleanupError(t("Could not run {binary}: {error}",
|
||||||
except OSError as exc:
|
binary=binary, error=exc)) from exc
|
||||||
raise CleanupError(t("Could not run {binary}: {error}",
|
try:
|
||||||
binary=binary, error=exc)) from exc
|
proc.wait(timeout=timeout)
|
||||||
if done.returncode != 0:
|
except subprocess.TimeoutExpired:
|
||||||
raise CleanupError(assistant.last_line(done.stderr) or t(
|
assistant.kill_tree(proc)
|
||||||
|
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
|
||||||
|
service=service, seconds=timeout)) from None
|
||||||
|
out_file.seek(0)
|
||||||
|
stdout = out_file.read().decode("utf-8", "replace")
|
||||||
|
err_file.seek(0)
|
||||||
|
stderr = err_file.read().decode("utf-8", "replace")
|
||||||
|
finally:
|
||||||
|
for handle in (out_file, err_file):
|
||||||
|
try:
|
||||||
|
handle.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise CleanupError(assistant.last_line(stderr) or t(
|
||||||
"{service} exited with code {code}.",
|
"{service} exited with code {code}.",
|
||||||
service=service, code=done.returncode))
|
service=service, code=proc.returncode))
|
||||||
return (done.stdout or "").strip()
|
return stdout.strip()
|
||||||
|
|||||||
+95
-34
@@ -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
|
||||||
@@ -133,21 +136,10 @@ def _ask_instance(opts, cmd, wait=False, **args):
|
|||||||
|
|
||||||
def launch_gui(verb=""):
|
def launch_gui(verb=""):
|
||||||
"""No instance running, so become the application itself."""
|
"""No instance running, so become the application itself."""
|
||||||
args = ipc.launcher()
|
ipc.respawn(([verb] if verb else []) + ["--gui"])
|
||||||
if verb:
|
# respawn only returns on Windows, where the application was started
|
||||||
args.append(verb)
|
# detached and this console process's job is over.
|
||||||
args.append("--gui")
|
sys.exit(0)
|
||||||
if sys.platform == "win32":
|
|
||||||
# execv on Windows mangles arguments with spaces and would leave the
|
|
||||||
# application tied to this console; start it detached instead.
|
|
||||||
subprocess.Popen(
|
|
||||||
args,
|
|
||||||
creationflags=(subprocess.DETACHED_PROCESS
|
|
||||||
| subprocess.CREATE_NEW_PROCESS_GROUP),
|
|
||||||
close_fds=True,
|
|
||||||
)
|
|
||||||
sys.exit(0)
|
|
||||||
os.execv(args[0], args)
|
|
||||||
|
|
||||||
|
|
||||||
def _not_running(opts):
|
def _not_running(opts):
|
||||||
@@ -516,7 +508,7 @@ def cmd_history_clear(opts):
|
|||||||
|
|
||||||
# --- settings ---------------------------------------------------------------
|
# --- settings ---------------------------------------------------------------
|
||||||
|
|
||||||
SECRET_KEYS = ("openai_api_key", "openrouter_api_key")
|
SECRET_KEYS = ("openai_api_key", "groq_api_key", "openrouter_api_key")
|
||||||
|
|
||||||
|
|
||||||
def _mask(key, value):
|
def _mask(key, value):
|
||||||
@@ -797,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:
|
||||||
@@ -839,27 +858,51 @@ def cmd_doctor(opts):
|
|||||||
programs = {name: shutil.which(name) or "" for name in wanted if name}
|
programs = {name: shutil.which(name) or "" for name in wanted if name}
|
||||||
target = conf.transcribe_target()
|
target = conf.transcribe_target()
|
||||||
cleaner = cleanup.provider(conf)
|
cleaner = cleanup.provider(conf)
|
||||||
|
# What each provider actually needs: the local ones have no key to check,
|
||||||
|
# and marking them by the key they do not use reported every fully local
|
||||||
|
# setup as broken.
|
||||||
|
transcribe_ready = conf.transcribe_ready()
|
||||||
|
if cleaner == "openrouter":
|
||||||
|
cleanup_ready = bool(conf.openrouter_key())
|
||||||
|
elif cleaner == "local":
|
||||||
|
cleanup_ready = conf.local_llm_ready()
|
||||||
|
else:
|
||||||
|
cleanup_ready = bool(programs.get(cleanup.executable(cleaner), ""))
|
||||||
checks = {
|
checks = {
|
||||||
"programs": programs,
|
"programs": programs,
|
||||||
"transcription": {"provider": target.provider, "model": target.model,
|
"transcription": {"provider": target.provider, "model": target.model,
|
||||||
"key": bool(target.api_key)},
|
"key": bool(target.api_key),
|
||||||
|
"ready": transcribe_ready},
|
||||||
"cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
|
"cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner,
|
||||||
"model": cleanup.model(conf),
|
"model": cleanup.model(conf),
|
||||||
"key": bool(conf.openrouter_key())},
|
"key": bool(conf.openrouter_key()),
|
||||||
|
"ready": cleanup_ready},
|
||||||
"agent": {"provider": assistant.provider(conf),
|
"agent": {"provider": assistant.provider(conf),
|
||||||
"directory": assistant.working_dir(conf)},
|
"directory": assistant.working_dir(conf)},
|
||||||
"running": ipc.send("status") is not None,
|
"running": ipc.send("status") is not None,
|
||||||
}
|
}
|
||||||
|
if target.provider == "local":
|
||||||
|
transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service}, "
|
||||||
|
f"transcribing on {target.model or 'no model yet'}")
|
||||||
|
else:
|
||||||
|
transcribe_line = (f"{'✓' if transcribe_ready else '✗'} {target.service} "
|
||||||
|
f"key, transcribing on {target.model}")
|
||||||
|
if cleaner == "openrouter":
|
||||||
|
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} OpenRouter key, "
|
||||||
|
f"cleaning up on {conf['cleanup_model']}")
|
||||||
|
elif cleaner == "local":
|
||||||
|
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} Local model, "
|
||||||
|
f"cleaning up on {conf['local_llm_model'] or 'no model yet'}")
|
||||||
|
else:
|
||||||
|
# Cleanup on a CLI needs no key, so what is checked is the program.
|
||||||
|
cleanup_line = (f"{'✓' if cleanup_ready else '✗'} "
|
||||||
|
f"{cleanup.executable(cleaner)}, cleaning up on "
|
||||||
|
f"{cleanup.model(conf)}")
|
||||||
lines = [f"{'✓' if path else '✗'} {name:14} {path or 'not on your PATH'}"
|
lines = [f"{'✓' if path else '✗'} {name:14} {path or 'not on your PATH'}"
|
||||||
for name, path in programs.items()]
|
for name, path in programs.items()]
|
||||||
lines += [
|
lines += [
|
||||||
f"{'✓' if target.api_key else '✗'} {target.service} key, transcribing on "
|
transcribe_line,
|
||||||
f"{target.model}",
|
cleanup_line,
|
||||||
# Cleanup on a CLI needs no key, so what is checked is the program.
|
|
||||||
(f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on "
|
|
||||||
f"{conf['cleanup_model']}") if cleaner == "openrouter" else
|
|
||||||
(f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} "
|
|
||||||
f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"),
|
|
||||||
f"{'✓' if checks['running'] else '·'} application "
|
f"{'✓' if checks['running'] else '·'} application "
|
||||||
+ ("running" if checks["running"] else "not running"),
|
+ ("running" if checks["running"] else "not running"),
|
||||||
]
|
]
|
||||||
@@ -982,13 +1025,14 @@ def build_parser():
|
|||||||
transcribe.set_defaults(func=cmd_transcribe)
|
transcribe.set_defaults(func=cmd_transcribe)
|
||||||
|
|
||||||
# --- meetings ---------------------------------------------------------
|
# --- meetings ---------------------------------------------------------
|
||||||
for name, help_text in (("meeting", "start a meeting, or end it and write it up"),
|
page = leaf(subs, "meeting", "start a meeting, or end it and write it up")
|
||||||
("meeting-cancel", "")):
|
page.add_argument("--wait", action="store_true",
|
||||||
page = leaf(subs, name, help_text)
|
help="wait for the minutes to be written")
|
||||||
page.add_argument("--wait", action="store_true",
|
page.add_argument("--timeout", type=float, default=0)
|
||||||
help="wait for the minutes to be written")
|
page.set_defaults(func=cmd_meeting)
|
||||||
page.add_argument("--timeout", type=float, default=0)
|
# No --wait here: a cancel is answered on the spot, and a flag the server
|
||||||
page.set_defaults(func=cmd_meeting)
|
# would ignore is a promise the help text cannot keep.
|
||||||
|
leaf(subs, "meeting-cancel", "").set_defaults(func=cmd_meeting)
|
||||||
|
|
||||||
meetings = leaf(subs, "meetings", "recorded meetings and their minutes")
|
meetings = leaf(subs, "meetings", "recorded meetings and their minutes")
|
||||||
inner = meetings.add_subparsers(dest="meetings", metavar="")
|
inner = meetings.add_subparsers(dest="meetings", metavar="")
|
||||||
@@ -1007,12 +1051,14 @@ def build_parser():
|
|||||||
delete.add_argument("which", nargs="+")
|
delete.add_argument("which", nargs="+")
|
||||||
delete.set_defaults(func=cmd_meetings_delete)
|
delete.set_defaults(func=cmd_meetings_delete)
|
||||||
for name, verb, help_text in (("start", "meeting-start", "start recording one"),
|
for name, verb, help_text in (("start", "meeting-start", "start recording one"),
|
||||||
("stop", "meeting-stop", "end it and write it up"),
|
("stop", "meeting-stop", "end it and write it up")):
|
||||||
("cancel", "meeting-cancel", "throw the recording away")):
|
|
||||||
page = leaf(inner, name, help_text)
|
page = leaf(inner, name, help_text)
|
||||||
page.add_argument("--wait", action="store_true")
|
page.add_argument("--wait", action="store_true")
|
||||||
page.add_argument("--timeout", type=float, default=0)
|
page.add_argument("--timeout", type=float, default=0)
|
||||||
page.set_defaults(func=cmd_meeting, verb=verb)
|
page.set_defaults(func=cmd_meeting, verb=verb)
|
||||||
|
# cancel takes no --wait: see the top-level meeting-cancel.
|
||||||
|
leaf(inner, "cancel", "throw the recording away").set_defaults(
|
||||||
|
func=cmd_meeting, verb="meeting-cancel")
|
||||||
|
|
||||||
# --- history ----------------------------------------------------------
|
# --- history ----------------------------------------------------------
|
||||||
history = leaf(subs, "history", "past dictations")
|
history = leaf(subs, "history", "past dictations")
|
||||||
@@ -1098,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"),
|
||||||
@@ -1118,6 +1169,16 @@ def _needs_subcommand(parser):
|
|||||||
|
|
||||||
def run(argv):
|
def run(argv):
|
||||||
global _app
|
global _app
|
||||||
|
# A redirected stdout on Windows falls back to the console codepage,
|
||||||
|
# strict, and a transcript (or doctor's ✓) with a character outside it
|
||||||
|
# would then fail the run after the work succeeded. Interactively nothing
|
||||||
|
# changes: the console is written through its own Unicode API.
|
||||||
|
if sys.platform == "win32" and not os.environ.get("PYTHONIOENCODING"):
|
||||||
|
for stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
stream.reconfigure(errors="replace")
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
opts = parser.parse_args(argv)
|
opts = parser.parse_args(argv)
|
||||||
# No verb at all is the plain `dikte`, which means the settings window.
|
# No verb at all is the plain `dikte`, which means the settings window.
|
||||||
|
|||||||
+127
-33
@@ -5,6 +5,8 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from . import api
|
from . import api
|
||||||
from . import ggml
|
from . import ggml
|
||||||
@@ -457,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
|
||||||
@@ -525,6 +530,30 @@ TRANSCRIBERS = {
|
|||||||
"openrouter_base_url", "openrouter_transcribe_model"),
|
"openrouter_base_url", "openrouter_transcribe_model"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# One lock for the history file and the meeting index both, rather than one
|
||||||
|
# each: the files are a few kilobytes, the writes happen a handful of times an
|
||||||
|
# hour, and a second lock would only add a way to take them in the wrong order.
|
||||||
|
_FILES_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_with_retry(tmp, target):
|
||||||
|
"""The atomic swap, tried again briefly when the target is held.
|
||||||
|
|
||||||
|
On Windows an antivirus or sync tool opens a freshly written file to look
|
||||||
|
at it, and a rename over the file fails for as long as it is held. The
|
||||||
|
hold lasts milliseconds, so three tries with a short sleep cover it; a
|
||||||
|
file held longer than that is a real error and is raised as one.
|
||||||
|
"""
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
tmp.replace(target)
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
if attempt == 2:
|
||||||
|
raise
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
# Corners used to be stored with Turkish names.
|
# Corners used to be stored with Turkish names.
|
||||||
_CORNER_MIGRATION = {
|
_CORNER_MIGRATION = {
|
||||||
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
|
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
|
||||||
@@ -545,7 +574,19 @@ class Config:
|
|||||||
self.data.update({k: v for k, v in stored.items() if k in DEFAULTS})
|
self.data.update({k: v for k, v in stored.items() if k in DEFAULTS})
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
except (json.JSONDecodeError, OSError) as exc:
|
except json.JSONDecodeError as exc:
|
||||||
|
# Set aside rather than left in place: the next save would write
|
||||||
|
# the defaults over it, and whatever broke the file deserves to
|
||||||
|
# still be there to look at. Best effort; a rename that fails
|
||||||
|
# changes nothing about falling back to the defaults.
|
||||||
|
broken = CONFIG_FILE.with_suffix(".json.broken")
|
||||||
|
try:
|
||||||
|
CONFIG_FILE.replace(broken)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(f"dikte: could not read settings ({exc}), using defaults; "
|
||||||
|
f"the unreadable file was kept as {broken}")
|
||||||
|
except OSError as exc:
|
||||||
print(f"dikte: could not read settings ({exc}), using defaults")
|
print(f"dikte: could not read settings ({exc}), using defaults")
|
||||||
self.data["overlay_corner"] = _CORNER_MIGRATION.get(
|
self.data["overlay_corner"] = _CORNER_MIGRATION.get(
|
||||||
self.data["overlay_corner"], self.data["overlay_corner"]
|
self.data["overlay_corner"], self.data["overlay_corner"]
|
||||||
@@ -560,8 +601,13 @@ class Config:
|
|||||||
tmp = CONFIG_FILE.with_suffix(".json.tmp")
|
tmp = CONFIG_FILE.with_suffix(".json.tmp")
|
||||||
with open(tmp, "w", encoding="utf-8") as fh:
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
json.dump(self.data, fh, ensure_ascii=False, indent=2)
|
json.dump(self.data, fh, ensure_ascii=False, indent=2)
|
||||||
|
# Pushed to the disk before the rename: swapping in a file that
|
||||||
|
# still lives in the page cache turns a power cut into a settings
|
||||||
|
# wipe, which the atomic replace exists to prevent.
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
os.chmod(tmp, 0o600)
|
os.chmod(tmp, 0o600)
|
||||||
tmp.replace(CONFIG_FILE)
|
_replace_with_retry(tmp, CONFIG_FILE)
|
||||||
i18n.set_language(self.data["ui_language"])
|
i18n.set_language(self.data["ui_language"])
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
@@ -727,12 +773,22 @@ def default_assistant_prompt():
|
|||||||
|
|
||||||
def append_history(entry):
|
def append_history(entry):
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
|
with _FILES_LOCK:
|
||||||
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def read_history(limit=None):
|
def read_history(limit=None):
|
||||||
"""Newest last. A limit of None (or 0) reads the whole file."""
|
"""Newest last. A limit of None (or 0) reads the whole file."""
|
||||||
|
# Locked even though the rewrites are atomic: it costs nothing, and a read
|
||||||
|
# that waits out a rewrite in flight hands back the settled file rather
|
||||||
|
# than whichever side of the swap it happened to land on.
|
||||||
|
with _FILES_LOCK:
|
||||||
|
return _read_history(limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_history(limit=None):
|
||||||
|
"""The body of read_history, for callers already holding the lock."""
|
||||||
try:
|
try:
|
||||||
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
||||||
lines = fh.readlines()
|
lines = fh.readlines()
|
||||||
@@ -755,40 +811,66 @@ def _write_history(lines):
|
|||||||
tmp = HISTORY_FILE.with_suffix(".jsonl.tmp")
|
tmp = HISTORY_FILE.with_suffix(".jsonl.tmp")
|
||||||
with open(tmp, "w", encoding="utf-8") as fh:
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
fh.writelines(lines)
|
fh.writelines(lines)
|
||||||
tmp.replace(HISTORY_FILE)
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
_replace_with_retry(tmp, HISTORY_FILE)
|
||||||
|
|
||||||
|
|
||||||
def trim_history(limit):
|
def trim_history(limit):
|
||||||
"""Drop the oldest entries once the file passes `limit` rows. 0 means keep all."""
|
"""Drop the oldest entries once the file passes `limit` rows. 0 means keep all."""
|
||||||
if not limit or limit < 0:
|
if not limit or limit < 0:
|
||||||
return
|
return
|
||||||
try:
|
# Read and rewrite under one lock, so a dictation appended in between the
|
||||||
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
# two is not erased by a rewrite that never saw it.
|
||||||
lines = fh.readlines()
|
with _FILES_LOCK:
|
||||||
except OSError:
|
try:
|
||||||
return
|
with open(HISTORY_FILE, encoding="utf-8") as fh:
|
||||||
if len(lines) <= limit:
|
lines = fh.readlines()
|
||||||
return
|
except OSError:
|
||||||
_write_history(lines[-limit:])
|
return
|
||||||
|
if len(lines) <= limit:
|
||||||
|
return
|
||||||
|
_write_history(lines[-limit:])
|
||||||
|
|
||||||
|
|
||||||
def _row_key(row):
|
def _row_key(row):
|
||||||
return json.dumps(row, ensure_ascii=False, sort_keys=True)
|
return json.dumps(row, ensure_ascii=False, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def amend_history(entry, **changes):
|
||||||
|
"""Patch one entry in place, matched on its whole content like delete_history.
|
||||||
|
|
||||||
|
For the caller that learns something after its row is already written: the
|
||||||
|
row goes in before the paste is attempted, and a paste that then fails
|
||||||
|
still has to end up in the record. None when the row is gone, which a trim
|
||||||
|
in between can legitimately make true."""
|
||||||
|
wanted = _row_key(entry)
|
||||||
|
with _FILES_LOCK:
|
||||||
|
rows = _read_history()
|
||||||
|
for row in rows:
|
||||||
|
if _row_key(row) == wanted:
|
||||||
|
row.update(changes)
|
||||||
|
_write_history([json.dumps(r, ensure_ascii=False) + "\n"
|
||||||
|
for r in rows])
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def delete_history(rows):
|
def delete_history(rows):
|
||||||
"""Remove the given entries, matched on their whole content rather than on a
|
"""Remove the given entries, matched on their whole content rather than on a
|
||||||
line number: the worker may have appended a new one since the list was read."""
|
line number: the worker may have appended a new one since the list was read."""
|
||||||
doomed = {_row_key(row) for row in rows}
|
doomed = {_row_key(row) for row in rows}
|
||||||
if not doomed:
|
if not doomed:
|
||||||
return
|
return
|
||||||
kept = [json.dumps(row, ensure_ascii=False) + "\n"
|
with _FILES_LOCK:
|
||||||
for row in read_history() if _row_key(row) not in doomed]
|
kept = [json.dumps(row, ensure_ascii=False) + "\n"
|
||||||
_write_history(kept)
|
for row in _read_history() if _row_key(row) not in doomed]
|
||||||
|
_write_history(kept)
|
||||||
|
|
||||||
|
|
||||||
def clear_history():
|
def clear_history():
|
||||||
HISTORY_FILE.unlink(missing_ok=True)
|
with _FILES_LOCK:
|
||||||
|
HISTORY_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# --- meetings -------------------------------------------------------------
|
# --- meetings -------------------------------------------------------------
|
||||||
@@ -804,6 +886,12 @@ def meeting_paths(base):
|
|||||||
|
|
||||||
def read_meetings():
|
def read_meetings():
|
||||||
"""Newest last."""
|
"""Newest last."""
|
||||||
|
with _FILES_LOCK:
|
||||||
|
return _read_meetings()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_meetings():
|
||||||
|
"""The body of read_meetings, for callers already holding the lock."""
|
||||||
try:
|
try:
|
||||||
with open(MEETINGS_FILE, encoding="utf-8") as fh:
|
with open(MEETINGS_FILE, encoding="utf-8") as fh:
|
||||||
lines = fh.readlines()
|
lines = fh.readlines()
|
||||||
@@ -826,29 +914,33 @@ def _write_meetings(rows):
|
|||||||
with open(tmp, "w", encoding="utf-8") as fh:
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||||
tmp.replace(MEETINGS_FILE)
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
_replace_with_retry(tmp, MEETINGS_FILE)
|
||||||
|
|
||||||
|
|
||||||
def save_meeting(entry):
|
def save_meeting(entry):
|
||||||
"""Insert the row, or replace the one with the same base."""
|
"""Insert the row, or replace the one with the same base."""
|
||||||
rows = read_meetings()
|
with _FILES_LOCK:
|
||||||
for index, row in enumerate(rows):
|
rows = _read_meetings()
|
||||||
if row["base"] == entry["base"]:
|
for index, row in enumerate(rows):
|
||||||
rows[index] = entry
|
if row["base"] == entry["base"]:
|
||||||
break
|
rows[index] = entry
|
||||||
else:
|
break
|
||||||
rows.append(entry)
|
else:
|
||||||
_write_meetings(rows)
|
rows.append(entry)
|
||||||
|
_write_meetings(rows)
|
||||||
|
|
||||||
|
|
||||||
def update_meeting(base, **changes):
|
def update_meeting(base, **changes):
|
||||||
"""Patch one row and hand it back, or None when it is gone."""
|
"""Patch one row and hand it back, or None when it is gone."""
|
||||||
rows = read_meetings()
|
with _FILES_LOCK:
|
||||||
for row in rows:
|
rows = _read_meetings()
|
||||||
if row["base"] == base:
|
for row in rows:
|
||||||
row.update(changes)
|
if row["base"] == base:
|
||||||
_write_meetings(rows)
|
row.update(changes)
|
||||||
return row
|
_write_meetings(rows)
|
||||||
|
return row
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -857,7 +949,9 @@ def delete_meetings(bases):
|
|||||||
doomed = set(bases)
|
doomed = set(bases)
|
||||||
if not doomed:
|
if not doomed:
|
||||||
return
|
return
|
||||||
_write_meetings([row for row in read_meetings() if row["base"] not in doomed])
|
with _FILES_LOCK:
|
||||||
|
_write_meetings([row for row in _read_meetings()
|
||||||
|
if row["base"] not in doomed])
|
||||||
for base in doomed:
|
for base in doomed:
|
||||||
for path in meeting_paths(base):
|
for path in meeting_paths(base):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+107
-41
@@ -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
|
||||||
@@ -26,13 +31,18 @@ from PyQt6.QtCore import QObject, pyqtSignal
|
|||||||
from . import api
|
from . import api
|
||||||
from . import cleanup
|
from . import cleanup
|
||||||
from . import ggml
|
from . import ggml
|
||||||
|
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
|
||||||
|
|
||||||
@@ -86,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."))
|
||||||
@@ -104,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()
|
||||||
@@ -148,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:
|
||||||
@@ -181,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)
|
||||||
@@ -282,8 +338,11 @@ def _ffmpeg(args, out, aborter=None):
|
|||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
["ffmpeg", "-nostdin", "-y", *args],
|
["ffmpeg", "-nostdin", "-y", *args],
|
||||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
text=True,
|
# ffmpeg writes UTF-8 whatever the locale says; read as the Windows
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
# codepage its messages mojibake, and a byte the codepage cannot place
|
||||||
|
# raises from inside communicate itself.
|
||||||
|
text=True, encoding="utf-8", errors="replace",
|
||||||
|
creationflags=paths.NO_WINDOW,
|
||||||
)
|
)
|
||||||
# A two hour film is a minute of ffmpeg, which is a minute of a Stop button
|
# A two hour film is a minute of ffmpeg, which is a minute of a Stop button
|
||||||
# doing nothing unless the abort reaches the process itself.
|
# doing nothing unless the abort reaches the process itself.
|
||||||
@@ -309,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):
|
||||||
|
|||||||
+211
-51
@@ -63,6 +63,10 @@ MODELS_DIR = DATA_DIR / "models"
|
|||||||
# Loading a large model onto a GPU is the slow part of a start, and on a cold
|
# Loading a large model onto a GPU is the slow part of a start, and on a cold
|
||||||
# page cache a large LLM read from a spinning disk is slower still.
|
# page cache a large LLM read from a spinning disk is slower still.
|
||||||
STARTUP_TIMEOUT = 180.0
|
STARTUP_TIMEOUT = 180.0
|
||||||
|
# A child that loses the bind race fails and exits at once; a model that fails
|
||||||
|
# to load takes longer than this to be read in first. The line between "worth
|
||||||
|
# another port" and "would fail the same way again" is drawn on time.
|
||||||
|
EARLY_EXIT_WINDOW = 5.0
|
||||||
DOWNLOAD_CHUNK = 1 << 20
|
DOWNLOAD_CHUNK = 1 << 20
|
||||||
|
|
||||||
# `health` is the path that answers only once the model is in memory. whisper
|
# `health` is the path that answers only once the model is in memory. whisper
|
||||||
@@ -193,7 +197,16 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
|
|||||||
part.unlink(missing_ok=True)
|
part.unlink(missing_ok=True)
|
||||||
raise LocalError(t("{name} does not match its published checksum. "
|
raise LocalError(t("{name} does not match its published checksum. "
|
||||||
"Nothing was installed.", name=item.name))
|
"Nothing was installed.", name=item.name))
|
||||||
part.replace(target)
|
try:
|
||||||
|
part.replace(target)
|
||||||
|
except PermissionError as exc:
|
||||||
|
# Windows refuses to replace a file something has open, and a
|
||||||
|
# running server holds its model and its binary open. The bytes
|
||||||
|
# are complete and verified: keeping the .part costs a retry,
|
||||||
|
# deleting it costs the whole download again.
|
||||||
|
raise LocalError(t("{name} downloaded, but the old file is held "
|
||||||
|
"open by the running server. Stop it and try "
|
||||||
|
"again.", name=item.name)) from exc
|
||||||
return True
|
return True
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
part.unlink(missing_ok=True)
|
part.unlink(missing_ok=True)
|
||||||
@@ -268,22 +281,23 @@ def _install_record(program):
|
|||||||
return BIN_DIR / program.name / "installed.json"
|
return BIN_DIR / program.name / "installed.json"
|
||||||
|
|
||||||
|
|
||||||
def installed_program(program):
|
def _read_record(program):
|
||||||
"""The binary Dikte downloaded, or "" when there is none that still runs."""
|
"""The install record, or {} however it fails to read."""
|
||||||
try:
|
try:
|
||||||
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
||||||
path = record.get("binary") or ""
|
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return ""
|
return {}
|
||||||
|
return record if isinstance(record, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def installed_program(program):
|
||||||
|
"""The binary Dikte downloaded, or "" when there is none that still runs."""
|
||||||
|
path = _read_record(program).get("binary") or ""
|
||||||
return path if os.path.isfile(path) and os.access(path, os.X_OK) else ""
|
return path if os.path.isfile(path) and os.access(path, os.X_OK) else ""
|
||||||
|
|
||||||
|
|
||||||
def installed_version(program):
|
def installed_version(program):
|
||||||
try:
|
return _read_record(program).get("tag") or ""
|
||||||
record = json.loads(_install_record(program).read_text(encoding="utf-8"))
|
|
||||||
return record.get("tag") or ""
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def program_path(program, custom=""):
|
def program_path(program, custom=""):
|
||||||
@@ -339,6 +353,19 @@ def _extract(archive, into):
|
|||||||
name=os.path.basename(str(archive)), error=exc)) from exc
|
name=os.path.basename(str(archive)), error=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _under(path, root):
|
||||||
|
"""Whether `path` lies inside `root`, symlinks and case resolved.
|
||||||
|
|
||||||
|
Resolved on both sides, because the same directory can be reached under
|
||||||
|
two spellings and this answer decides whether a server gets stopped.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pathlib.Path(path).resolve().relative_to(pathlib.Path(root).resolve())
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def install_program(program, tag="", on_progress=None, should_stop=None,
|
def install_program(program, tag="", on_progress=None, should_stop=None,
|
||||||
refresh=False):
|
refresh=False):
|
||||||
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
|
"""Fetch and unpack a release. The path to the binary, or "" when stopped.
|
||||||
@@ -374,17 +401,52 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
repo=program.repo, tag=tag))
|
repo=program.repo, tag=tag))
|
||||||
|
|
||||||
into = BIN_DIR / program.name / tag
|
into = BIN_DIR / program.name / tag
|
||||||
shutil.rmtree(into, ignore_errors=True)
|
fresh = into.with_name(tag + ".new")
|
||||||
archive = BIN_DIR / program.name / item.name
|
archive = BIN_DIR / program.name / item.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not download(item, archive, on_progress, should_stop):
|
if not download(item, archive, on_progress, should_stop):
|
||||||
return ""
|
return ""
|
||||||
_extract(archive, into)
|
try:
|
||||||
binary = _find_binary(into, _binary_file(program))
|
# Unpacked into a sibling and swapped in only once the binary is
|
||||||
if binary is None:
|
# known to be inside: a failure anywhere in here leaves the
|
||||||
raise LocalError(t("{name} was not in the download.",
|
# previous install, and its record, exactly as they were.
|
||||||
name=program.binary))
|
shutil.rmtree(fresh, ignore_errors=True)
|
||||||
binary.chmod(binary.stat().st_mode | 0o111)
|
_extract(archive, fresh)
|
||||||
|
binary = _find_binary(fresh, _binary_file(program))
|
||||||
|
if binary is None:
|
||||||
|
raise LocalError(t("{name} was not in the download.",
|
||||||
|
name=program.binary))
|
||||||
|
binary.chmod(binary.stat().st_mode | 0o111)
|
||||||
|
# A running server holds its binary open, and Windows will not
|
||||||
|
# delete an open file: whichever of our servers runs out of this
|
||||||
|
# program's directory is stopped here, after the download and the
|
||||||
|
# unpack are known good, so the outage is the swap and not the
|
||||||
|
# whole transfer.
|
||||||
|
for server in SERVERS:
|
||||||
|
current = program_path(server.program,
|
||||||
|
server.settings().get("binary", ""))
|
||||||
|
if current and _under(current, BIN_DIR / program.name):
|
||||||
|
server.stop()
|
||||||
|
if into.exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(into)
|
||||||
|
except OSError as exc:
|
||||||
|
# Not ignore_errors: silently losing this would rename the
|
||||||
|
# new version somewhere it can never land, and the user can
|
||||||
|
# actually fix it by closing whatever holds the directory.
|
||||||
|
raise LocalError(t(
|
||||||
|
"Could not replace {path}: a file in it is still "
|
||||||
|
"open: {error}", path=into, error=exc)) from exc
|
||||||
|
fresh.rename(into)
|
||||||
|
except BaseException:
|
||||||
|
# Half an unpacked sibling is not worth keeping, and the swap
|
||||||
|
# never ran, so the previous install is still whole.
|
||||||
|
shutil.rmtree(fresh, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
# Found under the sibling, run from the final directory.
|
||||||
|
binary = into / binary.relative_to(fresh)
|
||||||
|
# Written last, so the record never points at anything half-made.
|
||||||
_install_record(program).write_text(
|
_install_record(program).write_text(
|
||||||
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
|
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -404,8 +466,15 @@ def _drop_old_versions(program, keep):
|
|||||||
root = BIN_DIR / program.name
|
root = BIN_DIR / program.name
|
||||||
try:
|
try:
|
||||||
for path in root.iterdir():
|
for path in root.iterdir():
|
||||||
if path.is_dir() and path.name != keep:
|
if not path.is_dir() or path.name == keep:
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
continue
|
||||||
|
# A ".new" sibling belongs to an install mid-swap; housekeeping
|
||||||
|
# must not pull it out from under it.
|
||||||
|
if path.name.endswith(".new"):
|
||||||
|
continue
|
||||||
|
# ignore_errors on purpose: this is housekeeping, and a locked old
|
||||||
|
# version is a little wasted disk rather than a failed install.
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -538,7 +607,12 @@ def _tail(path, lines=3):
|
|||||||
|
|
||||||
|
|
||||||
def _win_image_name(pid):
|
def _win_image_name(pid):
|
||||||
"""The lower-cased file name of the process's executable, or ''."""
|
"""The full, lower-cased path of the process's executable, or ''.
|
||||||
|
|
||||||
|
The full path rather than the base name, because the name alone is anyone's
|
||||||
|
whisper-server.exe and this answer decides what gets killed. MAX_PATH is a
|
||||||
|
convention rather than a limit, so the buffer grows until the query fits.
|
||||||
|
"""
|
||||||
import ctypes
|
import ctypes
|
||||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
kernel32.OpenProcess.restype = ctypes.c_void_p
|
kernel32.OpenProcess.restype = ctypes.c_void_p
|
||||||
@@ -548,11 +622,18 @@ def _win_image_name(pid):
|
|||||||
if not handle:
|
if not handle:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
buffer = ctypes.create_unicode_buffer(260)
|
length = 260
|
||||||
size = ctypes.c_uint32(len(buffer))
|
while length <= 32768:
|
||||||
ok = kernel32.QueryFullProcessImageNameW(
|
buffer = ctypes.create_unicode_buffer(length)
|
||||||
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
|
size = ctypes.c_uint32(len(buffer))
|
||||||
return os.path.basename(buffer.value).lower() if ok else ""
|
ok = kernel32.QueryFullProcessImageNameW(
|
||||||
|
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
|
||||||
|
if ok:
|
||||||
|
return buffer.value.lower()
|
||||||
|
if ctypes.get_last_error() != 122: # ERROR_INSUFFICIENT_BUFFER
|
||||||
|
return ""
|
||||||
|
length *= 2
|
||||||
|
return ""
|
||||||
finally:
|
finally:
|
||||||
kernel32.CloseHandle(handle)
|
kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
@@ -578,6 +659,9 @@ class Server:
|
|||||||
self._port = 0
|
self._port = 0
|
||||||
self._log = ""
|
self._log = ""
|
||||||
self._key = None
|
self._key = None
|
||||||
|
# The pid this instance last wrote to its pid file, so _forget never
|
||||||
|
# removes a file some other Dikte wrote after us.
|
||||||
|
self._pid = 0
|
||||||
|
|
||||||
# ---- settings --------------------------------------------------------
|
# ---- settings --------------------------------------------------------
|
||||||
|
|
||||||
@@ -626,7 +710,9 @@ class Server:
|
|||||||
ready = self._current_url()
|
ready = self._current_url()
|
||||||
if ready:
|
if ready:
|
||||||
return ready
|
return ready
|
||||||
self.stop()
|
# _stop_now rather than stop(): this thread already holds
|
||||||
|
# _starting, and the public stop() waits for it.
|
||||||
|
self._stop_now()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
settings, key = dict(self._settings), self._settings_key()
|
settings, key = dict(self._settings), self._settings_key()
|
||||||
proc, port, log = self._launch(settings)
|
proc, port, log = self._launch(settings)
|
||||||
@@ -659,7 +745,7 @@ class Server:
|
|||||||
stdout=sink, stderr=subprocess.STDOUT,
|
stdout=sink, stderr=subprocess.STDOUT,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
# No console window of its own on Windows.
|
# No console window of its own on Windows.
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=paths.NO_WINDOW,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise LocalError(t("Could not start {name}: {error}",
|
raise LocalError(t("Could not start {name}: {error}",
|
||||||
@@ -668,8 +754,9 @@ class Server:
|
|||||||
# Written before it is ready rather than after, so that a kill
|
# Written before it is ready rather than after, so that a kill
|
||||||
# during the model load leaves something for the sweep to find.
|
# during the model load leaves something for the sweep to find.
|
||||||
self._remember(proc.pid)
|
self._remember(proc.pid)
|
||||||
|
began = time.monotonic()
|
||||||
try:
|
try:
|
||||||
ready = self._wait_ready(proc, port)
|
reason, listened = self._wait_ready(proc, port)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# Whatever went wrong while waiting, the process is ours and
|
# Whatever went wrong while waiting, the process is ours and
|
||||||
# nothing else is left holding a reference to it. Leaving it
|
# nothing else is left holding a reference to it. Leaving it
|
||||||
@@ -679,31 +766,52 @@ class Server:
|
|||||||
self._kill(proc)
|
self._kill(proc)
|
||||||
self._forget()
|
self._forget()
|
||||||
raise
|
raise
|
||||||
if ready:
|
if reason == "ready":
|
||||||
return proc, port, str(log)
|
return proc, port, str(log)
|
||||||
last = _tail(log)
|
last = _tail(log)
|
||||||
self._forget()
|
self._forget()
|
||||||
# A port taken between the probe and the bind is the one failure
|
# Losing the port between the probe and the bind is the one
|
||||||
# worth another go; anything else will fail the same way again.
|
# failure another port fixes, and it has a shape rather than a
|
||||||
if "address" not in last.lower() and "bind" not in last.lower():
|
# message: the child died at once without the port ever having
|
||||||
|
# answered as its own. Grepping the log for "bind" would tie this
|
||||||
|
# to one program's wording in one language.
|
||||||
|
early = time.monotonic() - began < EARLY_EXIT_WINDOW
|
||||||
|
if reason != "exited" or listened or not early:
|
||||||
break
|
break
|
||||||
raise LocalError(t("{name} did not start: {error}",
|
raise LocalError(t("{name} did not start: {error}",
|
||||||
name=self.program.binary, error=last or t("no output")))
|
name=self.program.binary, error=last or t("no output")))
|
||||||
|
|
||||||
def _wait_ready(self, proc, port):
|
def _wait_ready(self, proc, port):
|
||||||
|
"""("ready" | "exited" | "timeout", whether the port answered as ours).
|
||||||
|
|
||||||
|
whisper binds after the model is loaded, so the open port is the
|
||||||
|
answer; llama binds first and answers /health with 503 until it is
|
||||||
|
ready. For the health-less case the open port alone is not proof: a
|
||||||
|
child that lost the bind race exits at once while the winner keeps the
|
||||||
|
port open, so "ready" also wants our child alive a beat after the port
|
||||||
|
was first seen open.
|
||||||
|
"""
|
||||||
deadline = time.monotonic() + STARTUP_TIMEOUT
|
deadline = time.monotonic() + STARTUP_TIMEOUT
|
||||||
|
seen_open = False
|
||||||
|
listened = False
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
return False
|
return "exited", listened
|
||||||
|
if seen_open:
|
||||||
|
# Port open on the last pass and our child still alive now:
|
||||||
|
# an imposter's port would have left our child dead by here.
|
||||||
|
return "ready", True
|
||||||
if _listening(port):
|
if _listening(port):
|
||||||
# whisper binds after the model is loaded, so the open port is
|
if self.program.health:
|
||||||
# the answer. llama binds first and answers /health with 503
|
# llama did the binding itself, so the port is its.
|
||||||
# until it is ready.
|
listened = True
|
||||||
if not self.program.health or _healthy(port, self.program.health):
|
if _healthy(port, self.program.health):
|
||||||
return True
|
return "ready", True
|
||||||
|
else:
|
||||||
|
seen_open = True
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
self._kill(proc)
|
self._kill(proc)
|
||||||
return False
|
return "timeout", listened
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _kill(proc, gently=False):
|
def _kill(proc, gently=False):
|
||||||
@@ -724,6 +832,14 @@ class Server:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
# Taking _starting means a stop cannot slide past a launch in flight:
|
||||||
|
# serve() finishes registering its child first, and the child is then
|
||||||
|
# killed here rather than surviving the shutdown unowned.
|
||||||
|
with self._starting:
|
||||||
|
self._stop_now()
|
||||||
|
|
||||||
|
def _stop_now(self):
|
||||||
|
"""stop() for a thread that already holds _starting."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
proc, self._proc = self._proc, None
|
proc, self._proc = self._proc, None
|
||||||
self._port, self._log, self._key = 0, "", None
|
self._port, self._log, self._key = 0, "", None
|
||||||
@@ -737,6 +853,8 @@ class Server:
|
|||||||
return DATA_DIR / f"{self.program.name}-server.pid"
|
return DATA_DIR / f"{self.program.name}-server.pid"
|
||||||
|
|
||||||
def _remember(self, pid):
|
def _remember(self, pid):
|
||||||
|
with self._lock:
|
||||||
|
self._pid = pid
|
||||||
try:
|
try:
|
||||||
path = self._pid_file()
|
path = self._pid_file()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -744,28 +862,62 @@ class Server:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass # the sweep is a safety net, not something to fail a run over
|
pass # the sweep is a safety net, not something to fail a run over
|
||||||
|
|
||||||
def _forget(self):
|
def _forget(self, pid=None):
|
||||||
|
"""Remove the pid file, but only while it still holds our own pid.
|
||||||
|
|
||||||
|
Another Dikte started after us writes its pid over ours, and removing
|
||||||
|
that file would hide its server from every future sweep.
|
||||||
|
"""
|
||||||
|
if pid is None:
|
||||||
|
with self._lock:
|
||||||
|
pid = self._pid
|
||||||
try:
|
try:
|
||||||
self._pid_file().unlink()
|
if int(self._pid_file().read_text().strip()) == pid:
|
||||||
except OSError:
|
self._pid_file().unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _is_ours(self, pid):
|
def _is_ours(self, pid):
|
||||||
"""Whether that pid is still the server this Dikte started.
|
"""True: still our server. False: definitely not. None: cannot tell now.
|
||||||
|
|
||||||
Asked because pids are handed out again: by the time anyone looks, the
|
Asked because pids are handed out again: by the time anyone looks, the
|
||||||
number could belong to something else entirely, and killing it would be
|
number could belong to something else entirely, and killing it would be
|
||||||
a good deal worse than the leak being cleaned up. The program name alone
|
a good deal worse than the leak being cleaned up. The tri-state matters
|
||||||
could be somebody else's copy; the name together with Dikte's own data
|
for the pid file: a definitive "not ours" means the file is stale and
|
||||||
directory on the command line could not. Windows offers no command line
|
safe to drop, while "cannot tell" means it has to stay so a later start
|
||||||
to read, so the executable's name is the whole of the answer there.
|
can ask again.
|
||||||
|
|
||||||
|
On Linux the program name alone could be somebody else's copy; the name
|
||||||
|
together with Dikte's own data directory on the command line could not.
|
||||||
|
Windows offers no command line to read, so the executable's full path
|
||||||
|
is the answer there: under our bin directory, or exactly the binary the
|
||||||
|
settings point this server at. Never the base name alone, which is
|
||||||
|
anyone's whisper-server.exe.
|
||||||
"""
|
"""
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
return _win_image_name(pid) == _binary_file(self.program).lower()
|
path = _win_image_name(pid)
|
||||||
|
if not path:
|
||||||
|
# OpenProcess said nothing: the process may be gone or merely
|
||||||
|
# unreadable from here, and the difference decides whether the
|
||||||
|
# pid file may be dropped, so no verdict rather than a wrong one.
|
||||||
|
return None
|
||||||
|
path = os.path.normcase(path)
|
||||||
|
if path.startswith(os.path.normcase(str(BIN_DIR)) + os.sep):
|
||||||
|
return True
|
||||||
|
with self._lock:
|
||||||
|
custom = self._settings.get("binary", "")
|
||||||
|
configured = program_path(self.program, custom)
|
||||||
|
if configured:
|
||||||
|
resolved = os.path.normcase(str(pathlib.Path(configured).resolve()))
|
||||||
|
if path == resolved:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
|
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||||
|
except (FileNotFoundError, ProcessLookupError):
|
||||||
|
return False # the process is definitively gone
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return None # /proc would not answer just now
|
||||||
return (self.program.binary.encode() in blob
|
return (self.program.binary.encode() in blob
|
||||||
and str(DATA_DIR).encode() in blob)
|
and str(DATA_DIR).encode() in blob)
|
||||||
|
|
||||||
@@ -781,13 +933,21 @@ class Server:
|
|||||||
pid = int(self._pid_file().read_text().strip())
|
pid = int(self._pid_file().read_text().strip())
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return False
|
return False
|
||||||
self._forget()
|
owned = self._is_ours(pid)
|
||||||
if not self._is_ours(pid):
|
if owned is None:
|
||||||
|
# Could not be verified rather than known stale: the file stays,
|
||||||
|
# so the next start asks again instead of losing track of a server
|
||||||
|
# that may still be holding a model.
|
||||||
|
return False
|
||||||
|
if not owned:
|
||||||
|
self._forget(pid)
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
self._forget(pid)
|
||||||
return False
|
return False
|
||||||
|
self._forget(pid)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -970,9 +970,7 @@ def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
|
|||||||
if "=" not in line or desktop_id in section:
|
if "=" not in line or desktop_id in section:
|
||||||
continue
|
continue
|
||||||
key, _, value = line.partition("=")
|
key, _, value = line.partition("=")
|
||||||
if shortcut.lower() in value.lower().split(","):
|
if any(shortcut.lower() == part.strip().lower()
|
||||||
hits.append(f"{section} → {key}")
|
for part in re.split(r"[,\t]", value)):
|
||||||
elif any(shortcut.lower() == part.strip().lower()
|
|
||||||
for part in re.split(r"[,\t]", value)):
|
|
||||||
hits.append(f"{section} → {key}")
|
hits.append(f"{section} → {key}")
|
||||||
return hits
|
return hits
|
||||||
|
|||||||
+20
-6
@@ -12,19 +12,18 @@ means one for every whisper.cpp release; both of those are somebody else's news,
|
|||||||
not Dikte's. Answers are cached for a few hours, and a cache that has gone stale
|
not Dikte's. Answers are cached for a few hours, and a cache that has gone stale
|
||||||
is still a better answer than none when the network is down.
|
is still a better answer than none when the network is down.
|
||||||
|
|
||||||
Nothing here imports the rest of Dikte apart from the string table: this module
|
Nothing here imports the rest of Dikte apart from two leaves, the string table
|
||||||
knows two websites and nothing about dictation.
|
and the path map: this module knows two websites and nothing about dictation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
from . import paths
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
GITHUB_API = "https://api.github.com"
|
GITHUB_API = "https://api.github.com"
|
||||||
@@ -32,8 +31,7 @@ HF_API = "https://huggingface.co/api"
|
|||||||
HF_FILES = "https://huggingface.co"
|
HF_FILES = "https://huggingface.co"
|
||||||
USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)"
|
USER_AGENT = "dikte/1.0 (+https://github.com/yusufipk/dikte)"
|
||||||
|
|
||||||
CACHE_DIR = (pathlib.Path(os.environ.get("XDG_CACHE_HOME")
|
CACHE_DIR = paths.cache_dir()
|
||||||
or os.path.expanduser("~/.cache")) / "dikte")
|
|
||||||
# Long enough that opening the settings window twice in an evening asks nobody
|
# Long enough that opening the settings window twice in an evening asks nobody
|
||||||
# anything, short enough that a model published this morning is offered today.
|
# anything, short enough that a model published this morning is offered today.
|
||||||
CACHE_TTL = 6 * 3600
|
CACHE_TTL = 6 * 3600
|
||||||
@@ -136,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.
|
||||||
|
|
||||||
|
|||||||
+159
-2
@@ -69,6 +69,7 @@ TR = {
|
|||||||
|
|
||||||
# --- overlay / pipeline -------------------------------------------
|
# --- overlay / pipeline -------------------------------------------
|
||||||
"Transcribing…": "Yazıya çevriliyor…",
|
"Transcribing…": "Yazıya çevriliyor…",
|
||||||
|
"Waiting for the one before it…": "Öncekinin bitmesi bekleniyor…",
|
||||||
"Cleaning up…": "Temizleniyor…",
|
"Cleaning up…": "Temizleniyor…",
|
||||||
"Pasting…": "Yapıştırılıyor…",
|
"Pasting…": "Yapıştırılıyor…",
|
||||||
"Pasted": "Yapıştırıldı",
|
"Pasted": "Yapıştırıldı",
|
||||||
@@ -162,8 +163,6 @@ TR = {
|
|||||||
"Automatic (system)": "Otomatik (sistem)",
|
"Automatic (system)": "Otomatik (sistem)",
|
||||||
"Turkish": "Türkçe",
|
"Turkish": "Türkçe",
|
||||||
"English": "İngilizce",
|
"English": "İngilizce",
|
||||||
"Restart Dikte for the language change to reach every window.":
|
|
||||||
"Dil değişikliğinin her pencereye işlemesi için Dikte'yi yeniden başlat.",
|
|
||||||
"Microphone": "Mikrofon",
|
"Microphone": "Mikrofon",
|
||||||
"Default microphone": "Varsayılan mikrofon",
|
"Default microphone": "Varsayılan mikrofon",
|
||||||
"Speech language": "Konuşma dili",
|
"Speech language": "Konuşma dili",
|
||||||
@@ -192,6 +191,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 +315,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}",
|
||||||
@@ -533,6 +555,10 @@ TR = {
|
|||||||
"“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha "
|
"“sonnet” gibi bir ad her zaman o serinin en yenisini seçer. Opus daha "
|
||||||
"çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, "
|
"çok düşünür ve daha geç cevaplar; bu da en çok burada hissedilir, "
|
||||||
"çünkü ekranın başında bekliyorsun.",
|
"çünkü ekranın başında bekliyorsun.",
|
||||||
|
"The list is a starting point, not a fence: any model name {name} accepts "
|
||||||
|
"can be typed straight in.":
|
||||||
|
"Liste başlangıç için, sınır değil: {name} hangi model adını kabul "
|
||||||
|
"ediyorsa buraya elle yazılabilir.",
|
||||||
"Permissions": "İzinler",
|
"Permissions": "İzinler",
|
||||||
"Decide on its own, with the safety checks on":
|
"Decide on its own, with the safety checks on":
|
||||||
"Kendi karar versin, güvenlik denetimleri açık",
|
"Kendi karar versin, güvenlik denetimleri açık",
|
||||||
@@ -725,4 +751,135 @@ TR = {
|
|||||||
"This one is being written up right now.": "Bunun tutanağı şu anda çıkarılıyor.",
|
"This one is being written up right now.": "Bunun tutanağı şu anda çıkarılıyor.",
|
||||||
"Delete this meeting, its minutes and its recording?":
|
"Delete this meeting, its minutes and its recording?":
|
||||||
"Bu toplantı, tutanağı ve ses kaydı silinsin mi?",
|
"Bu toplantı, tutanağı ve ses kaydı silinsin mi?",
|
||||||
|
|
||||||
|
# --- local models and downloads ------------------------------------
|
||||||
|
# The whole box was born after the last translation pass, which left the
|
||||||
|
# first-run screen half English on a Turkish machine.
|
||||||
|
"Download": "İndir",
|
||||||
|
"Delete": "Sil",
|
||||||
|
"Program": "Program",
|
||||||
|
"Publisher": "Yayıncı",
|
||||||
|
"Automatic": "Otomatik",
|
||||||
|
"Threads": "İş parçacığı",
|
||||||
|
"On this machine": "Bu makinede",
|
||||||
|
"Use the graphics card": "Ekran kartını kullan",
|
||||||
|
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
||||||
|
"Local whisper": "Yerel whisper",
|
||||||
|
"Local model": "Yerel model",
|
||||||
|
"Not installed.": "Kurulu değil.",
|
||||||
|
"Installed on the system: {path}": "Sistemde kurulu: {path}",
|
||||||
|
"Downloaded, version {version}.": "İndirildi, sürüm {version}.",
|
||||||
|
"Fetching the model list…": "Model listesi çekiliyor…",
|
||||||
|
"Downloading…": "İndiriliyor…",
|
||||||
|
"Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}",
|
||||||
|
"Download stopped.": "İndirme durduruldu.",
|
||||||
|
"Ready: {name}.": "Hazır: {name}.",
|
||||||
|
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
||||||
|
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
|
||||||
|
"downloaded": "indirildi",
|
||||||
|
"not downloaded": "indirilmedi",
|
||||||
|
"Delete model": "Modeli sil",
|
||||||
|
"Delete {name} from this machine?": "{name} bu makineden silinsin mi?",
|
||||||
|
"Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.",
|
||||||
|
"A Hugging Face repository of GGUF files. The list is fetched; any other "
|
||||||
|
"one can be typed in.":
|
||||||
|
"GGUF dosyaları içeren bir Hugging Face deposu. Liste internetten "
|
||||||
|
"çekilir; başka bir depo da yazılabilir.",
|
||||||
|
"A large model takes a second or two to load. Loading it up front spends "
|
||||||
|
"that once instead of on the first dictation, at the cost of the memory "
|
||||||
|
"it sits in.":
|
||||||
|
"Büyük bir modelin yüklenmesi bir iki saniye sürer. Baştan yüklemek bu "
|
||||||
|
"bedeli ilk diktede değil bir kez öder; karşılığı, modelin oturduğu "
|
||||||
|
"bellektir.",
|
||||||
|
"An LLM is slower to load than a whisper model and sits in more memory. "
|
||||||
|
"Off means it is loaded on the first cleanup instead.":
|
||||||
|
"Bir LLM, whisper modelinden daha geç yüklenir ve daha çok bellekte "
|
||||||
|
"oturur. Kapalı, ilk temizlemede yüklenmesi demektir.",
|
||||||
|
"A model trained to think will think unless it is told not to, and "
|
||||||
|
"spending 300 tokens of reasoning on a comma is 300 tokens of waiting. "
|
||||||
|
"Off is what cleanup wants.":
|
||||||
|
"Düşünmeye eğitilmiş bir model, aksi söylenmedikçe düşünür; bir virgül "
|
||||||
|
"için 300 token akıl yürütmek 300 token'lık bekleyiştir. Temizleme için "
|
||||||
|
"doğrusu Kapalı.",
|
||||||
|
"OpenRouter is the quickest and the only one that needs nothing "
|
||||||
|
"installed. llama.cpp runs here, on a model downloaded below. Claude Code "
|
||||||
|
"and Codex clean up on the subscription you already have, without a "
|
||||||
|
"second key, and take a few seconds longer because each one opens a "
|
||||||
|
"session to do it.":
|
||||||
|
"OpenRouter en hızlısıdır ve kurulum istemeyen tek seçenektir. "
|
||||||
|
"llama.cpp burada, aşağıda indirilen bir modelle çalışır. Claude Code "
|
||||||
|
"ve Codex, ikinci bir anahtar olmadan zaten sahip olduğun abonelikle "
|
||||||
|
"temizler; her biri bunun için bir oturum açtığından birkaç saniye "
|
||||||
|
"daha sürer.",
|
||||||
|
"whisper.cpp reaches the card through CUDA, ROCm or Vulkan when the build "
|
||||||
|
"it is running was made with one. A build without any of them runs on the "
|
||||||
|
"processor whatever this says.":
|
||||||
|
"whisper.cpp karta CUDA, ROCm ya da Vulkan üzerinden ulaşır; koştuğu "
|
||||||
|
"derleme bunlardan biriyle yapılmışsa. Hiçbiri olmadan derlenmiş bir "
|
||||||
|
"kopya, bu ne derse desin işlemcide çalışır.",
|
||||||
|
"whisper.cpp is not installed. Settings → API and models → Download.":
|
||||||
|
"whisper.cpp kurulu değil. Ayarlar → API ve modeller → İndir.",
|
||||||
|
"llama.cpp is not installed. Settings → API and models → Download.":
|
||||||
|
"llama.cpp kurulu değil. Ayarlar → API ve modeller → İndir.",
|
||||||
|
"No whisper model has been downloaded yet. Settings → API and models → "
|
||||||
|
"Download.":
|
||||||
|
"Henüz whisper modeli indirilmedi. Ayarlar → API ve modeller → İndir.",
|
||||||
|
"No local cleanup model has been downloaded yet. Settings → API and "
|
||||||
|
"models → Download.":
|
||||||
|
"Henüz yerel temizleme modeli indirilmedi. Ayarlar → API ve modeller "
|
||||||
|
"→ İndir.",
|
||||||
|
"Hugging Face did not return a model list.":
|
||||||
|
"Hugging Face model listesi döndürmedi.",
|
||||||
|
"{repo} did not return a file list.": "{repo} dosya listesi döndürmedi.",
|
||||||
|
"{repo} has no downloadable release.":
|
||||||
|
"{repo} deposunun indirilebilir bir sürümü yok.",
|
||||||
|
"{repo} {tag} has no build for this machine.":
|
||||||
|
"{repo} {tag} bu makine için derleme içermiyor.",
|
||||||
|
"{url} answered HTTP {code}.": "{url} HTTP {code} yanıtı verdi.",
|
||||||
|
"Could not reach {url}: {error}": "{url} adresine ulaşılamadı: {error}",
|
||||||
|
"Could not read the answer from {url}: {error}":
|
||||||
|
"{url} yanıtı okunamadı: {error}",
|
||||||
|
"Could not create {path}: {error}": "{path} oluşturulamadı: {error}",
|
||||||
|
"Could not download {name}: HTTP {code}":
|
||||||
|
"{name} indirilemedi: HTTP {code}",
|
||||||
|
"Could not download {name}: {error}": "{name} indirilemedi: {error}",
|
||||||
|
"Could not write {name}: {error}": "{name} yazılamadı: {error}",
|
||||||
|
"Could not unpack {name}: {error}": "{name} açılamadı: {error}",
|
||||||
|
"Could not install {name}: {error}": "{name} kurulamadı: {error}",
|
||||||
|
"Could not start {name}: {error}": "{name} başlatılamadı: {error}",
|
||||||
|
"Could not delete the model: {error}": "Model silinemedi: {error}",
|
||||||
|
"Could not replace {path}: a file in it is still open: {error}":
|
||||||
|
"{path} değiştirilemedi: içindeki bir dosya hâlâ açık: {error}",
|
||||||
|
"{name} did not start: {error}": "{name} başlamadı: {error}",
|
||||||
|
"no output": "çıktı yok",
|
||||||
|
"The download stopped early ({done} of {total}).":
|
||||||
|
"İndirme erken kesildi ({done} / {total}).",
|
||||||
|
"{name} is longer than it said it would be.":
|
||||||
|
"{name} bildirdiğinden daha uzun çıktı.",
|
||||||
|
"{name} does not match its published checksum. Nothing was installed.":
|
||||||
|
"{name} yayımlanan sağlama toplamıyla uyuşmuyor. Hiçbir şey kurulmadı.",
|
||||||
|
"{name} is published without a checksum, so there is no way to tell what "
|
||||||
|
"arrived. Nothing was installed.":
|
||||||
|
"{name} sağlama toplamı olmadan yayımlanmış; gelenin ne olduğu "
|
||||||
|
"doğrulanamaz. Hiçbir şey kurulmadı.",
|
||||||
|
"{name} was not in the download.": "{name} indirilenin içinde yoktu.",
|
||||||
|
"{name} downloaded, but the old file is held open by the running server. "
|
||||||
|
"Stop it and try again.":
|
||||||
|
"{name} indirildi ama eski dosyayı çalışan sunucu açık tutuyor. "
|
||||||
|
"Sunucuyu durdurup yeniden dene.",
|
||||||
|
"The cleanup model spent its whole reply on thinking. Set Thinking to "
|
||||||
|
"“Off”.":
|
||||||
|
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
|
||||||
|
"“Kapalı” yap.",
|
||||||
|
|
||||||
|
# --- this pass's new messages ---------------------------------------
|
||||||
|
"Audio recorder stopped before receiving sound":
|
||||||
|
"Ses kayıt aracı veri alamadan kapandı",
|
||||||
|
"Could not write the recording: {error}": "Kayıt dosyası yazılamadı: {error}",
|
||||||
|
"Copied, but pasting failed: {error}":
|
||||||
|
"Kopyalandı ama yapıştırma başarısız: {error}",
|
||||||
|
"The recording was kept: {path}": "Kayıt saklandı: {path}",
|
||||||
|
"The recording stopped on its own; transcribing what was captured.":
|
||||||
|
"Kayıt kendi kendine durdu; yakalanan kısım yazıya dökülüyor.",
|
||||||
|
"Could not save the settings: {error}": "Ayarlar kaydedilemedi: {error}",
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-11
@@ -56,6 +56,17 @@ def packaged():
|
|||||||
return bool(getattr(sys, "frozen", False))
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def windowed_executable(executable=None):
|
||||||
|
"""The windowed executable installed beside this one, or None.
|
||||||
|
|
||||||
|
Beside rather than at a known place, because the setup program lays the two
|
||||||
|
executables into the same directory wherever that directory was put: asking
|
||||||
|
from either of them finds the other without knowing where the install is.
|
||||||
|
"""
|
||||||
|
windowed = pathlib.Path(executable or sys.executable).with_name(WINDOWS_APP)
|
||||||
|
return windowed if windowed.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
def target():
|
def target():
|
||||||
"""The file a launcher has to name to start this build again.
|
"""The file a launcher has to name to start this build again.
|
||||||
|
|
||||||
@@ -74,8 +85,8 @@ def target():
|
|||||||
# The windowed executable, whichever of the two is running: the console
|
# The windowed executable, whichever of the two is running: the console
|
||||||
# one is what the `dikte` command names, and a sign-in that started
|
# one is what the `dikte` command names, and a sign-in that started
|
||||||
# that one would open a console window nobody asked for.
|
# that one would open a console window nobody asked for.
|
||||||
windowed = executable.with_name(WINDOWS_APP)
|
windowed = windowed_executable(executable)
|
||||||
if windowed.is_file():
|
if windowed is not None:
|
||||||
return windowed
|
return windowed
|
||||||
return executable
|
return executable
|
||||||
|
|
||||||
@@ -572,24 +583,60 @@ def _run_entry_name():
|
|||||||
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
|
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_target(value):
|
||||||
|
"""The executable a Run value names, out of the quoting the setup wrote.
|
||||||
|
|
||||||
|
Only the first word matters here: it is the file whose existence says
|
||||||
|
whether the entry still starts anything.
|
||||||
|
"""
|
||||||
|
if value.startswith('"'):
|
||||||
|
closing = value.find('"', 1)
|
||||||
|
return value[1:closing] if closing > 0 else ""
|
||||||
|
return value.split(" ", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _startup_shortcut():
|
||||||
|
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
|
||||||
|
appdata = os.environ.get("APPDATA")
|
||||||
|
if not appdata:
|
||||||
|
return None
|
||||||
|
return (pathlib.Path(appdata) / "Microsoft" / "Windows" / "Start Menu"
|
||||||
|
/ "Programs" / "Startup" / "Dikte.lnk")
|
||||||
|
|
||||||
|
|
||||||
def _windows_install(app, force=False):
|
def _windows_install(app, force=False):
|
||||||
"""Point the autostart entry at this build. What changed.
|
"""Point the autostart entry at this build. What changed.
|
||||||
|
|
||||||
Only `force`, which is what typing `dikte integrate` means, creates one.
|
Only `force`, which is what typing `dikte integrate` means, creates one.
|
||||||
The call on every start repairs an entry that is already there and names an
|
The call on every start repairs an entry that is already there and names an
|
||||||
executable somewhere else, which is what an installation moved to another
|
executable that is gone, which is what an installation moved to another
|
||||||
drive or reinstalled into another directory leaves behind; somebody who
|
drive or reinstalled into another directory leaves behind. An entry naming
|
||||||
unticked the box in the wizard, or turned it off since, is not asked again
|
an executable that still exists is another installation that still works,
|
||||||
by every start.
|
and is stood aside for the way the Linux half stands aside for another
|
||||||
|
menu entry; somebody who unticked the box in the wizard, or turned it off
|
||||||
|
since, is not asked again by every start either.
|
||||||
"""
|
"""
|
||||||
command = f'"{app}"'
|
command = f'"{app}"'
|
||||||
current = _run_entry()
|
current = _run_entry()
|
||||||
|
changed = []
|
||||||
|
if force:
|
||||||
|
# install.ps1 -Autostart wrote this for a checkout. The Run value
|
||||||
|
# written below replaces it, and both left in place would be two
|
||||||
|
# Diktes at every sign-in. Only on force: the silent call on every
|
||||||
|
# start has not been asked to move the machine off its checkout.
|
||||||
|
shortcut = _startup_shortcut()
|
||||||
|
if shortcut is not None and shortcut.is_file():
|
||||||
|
shortcut.unlink()
|
||||||
|
changed.append(shortcut)
|
||||||
if not current and not force:
|
if not current and not force:
|
||||||
return []
|
return changed
|
||||||
if current == command:
|
if current != command:
|
||||||
return []
|
theirs = _run_target(current) if current else ""
|
||||||
_write_run_entry(command)
|
if not force and theirs and theirs != str(app) and os.path.exists(theirs):
|
||||||
return [_run_entry_name()]
|
return changed
|
||||||
|
_write_run_entry(command)
|
||||||
|
changed.append(_run_entry_name())
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def _windows_remove():
|
def _windows_remove():
|
||||||
|
|||||||
+60
-4
@@ -11,11 +11,14 @@ shortcut may still send.
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QLockFile
|
||||||
from PyQt6.QtNetwork import QLocalSocket
|
from PyQt6.QtNetwork import QLocalSocket
|
||||||
|
|
||||||
from . import integrate
|
from . import integrate
|
||||||
|
from . import paths
|
||||||
|
|
||||||
SERVER_NAME = "dikte-" + (
|
SERVER_NAME = "dikte-" + (
|
||||||
str(os.getuid()) if hasattr(os, "getuid")
|
str(os.getuid()) if hasattr(os, "getuid")
|
||||||
@@ -54,10 +57,9 @@ def launcher():
|
|||||||
if not getattr(sys, "frozen", False):
|
if not getattr(sys, "frozen", False):
|
||||||
return [sys.executable, script_path()]
|
return [sys.executable, script_path()]
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
windowed = os.path.join(os.path.dirname(sys.executable),
|
windowed = integrate.windowed_executable()
|
||||||
integrate.WINDOWS_APP)
|
if windowed is not None:
|
||||||
if os.path.isfile(windowed):
|
return [str(windowed)]
|
||||||
return [windowed]
|
|
||||||
return [os.environ.get("APPIMAGE") or sys.executable]
|
return [os.environ.get("APPIMAGE") or sys.executable]
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +74,60 @@ def command_for(verb):
|
|||||||
return shlex.join(launcher() + ([verb] if verb else []))
|
return shlex.join(launcher() + ([verb] if verb else []))
|
||||||
|
|
||||||
|
|
||||||
|
def already_serving():
|
||||||
|
"""Whether a running instance answers on this user's name.
|
||||||
|
|
||||||
|
Asked before an instance opens a server of its own, because listen() is
|
||||||
|
not the check: a Windows named pipe takes a second server on the same name
|
||||||
|
rather than refusing it, and everywhere else removeServer() would first
|
||||||
|
take the live socket away from the instance holding it. Either way two
|
||||||
|
whole Diktes then run, and the newer one's sweep() kills the whisper the
|
||||||
|
older one is answering dictations with. The probe is "status" and nothing
|
||||||
|
else: a verb with a side effect here would fire it during the relaunch a
|
||||||
|
slow-to-answer instance provokes, on top of the verb being forwarded.
|
||||||
|
"""
|
||||||
|
return send("status") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def instance_lock():
|
||||||
|
"""This user's one-Dikte lock, taken before anything else is built.
|
||||||
|
|
||||||
|
The probe above has a hole: two copies started in the same moment both ask
|
||||||
|
before either listens, and both come up. A lock file closes it, and
|
||||||
|
QLockFile writes the holder's pid into it, so a lock a killed instance
|
||||||
|
left behind identifies itself as stale and clears. None when the data
|
||||||
|
directory cannot be made, which a start should survive: the probe still
|
||||||
|
stands guard, just without the simultaneous-start case.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
lock = QLockFile(str(paths.DATA_DIR / "dikte.lock"))
|
||||||
|
# Never presume a lock is stale by age alone; the pid check is the truth.
|
||||||
|
lock.setStaleLockTime(0)
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def respawn(arguments):
|
||||||
|
"""Start this installation again with `arguments`, leaving this process.
|
||||||
|
|
||||||
|
execv everywhere it works the way it says: the new process takes this
|
||||||
|
pid and nothing is left behind. On Windows execv mangles arguments with
|
||||||
|
spaces and leaves the two processes sharing a console, so the replacement
|
||||||
|
is started detached instead and the caller exits on its own.
|
||||||
|
"""
|
||||||
|
args = launcher() + list(arguments)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# By value where the names are missing, so the Windows half of this is
|
||||||
|
# testable from the suite's other platforms too.
|
||||||
|
detached = (getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||||
|
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200))
|
||||||
|
subprocess.Popen(args, creationflags=detached, close_fds=True)
|
||||||
|
return
|
||||||
|
os.execv(args[0], args)
|
||||||
|
|
||||||
|
|
||||||
def send(cmd, wait=False, timeout=0, **args):
|
def send(cmd, wait=False, timeout=0, **args):
|
||||||
"""Send one request; the reply, or None when no instance is running.
|
"""Send one request; the reply, or None when no instance is running.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+30
-5
@@ -13,10 +13,18 @@ platform as an argument so that a test can stand on the other one.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# The one other platform constant every subprocess site needs, kept in this
|
||||||
|
# leaf so no caller has to pull the audio stack in for it: console programs
|
||||||
|
# started from a windowless process would otherwise each open a console window
|
||||||
|
# of their own on Windows.
|
||||||
|
NO_WINDOW = (getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
if sys.platform == "win32" else 0)
|
||||||
|
|
||||||
def _env(var, default):
|
|
||||||
|
def env_path(var, default):
|
||||||
"""The directory a variable names, or the one it stands in for."""
|
"""The directory a variable names, or the one it stands in for."""
|
||||||
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
|
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
|
||||||
|
|
||||||
@@ -34,11 +42,28 @@ def directories(platform=None):
|
|||||||
support = pathlib.Path.home() / "Library/Application Support/Dikte"
|
support = pathlib.Path.home() / "Library/Application Support/Dikte"
|
||||||
return support, support
|
return support, support
|
||||||
if here == "win32":
|
if here == "win32":
|
||||||
roaming = _env("APPDATA", "~/AppData/Roaming")
|
roaming = env_path("APPDATA", "~/AppData/Roaming")
|
||||||
local = _env("LOCALAPPDATA", "~/AppData/Local")
|
local = env_path("LOCALAPPDATA", "~/AppData/Local")
|
||||||
return roaming / "Dikte", local / "Dikte"
|
return roaming / "Dikte", local / "Dikte"
|
||||||
return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte",
|
return (env_path("XDG_CONFIG_HOME", "~/.config") / "dikte",
|
||||||
_env("XDG_DATA_HOME", "~/.local/share") / "dikte")
|
env_path("XDG_DATA_HOME", "~/.local/share") / "dikte")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir(platform=None):
|
||||||
|
"""The directory for answers worth keeping but never worth backing up.
|
||||||
|
|
||||||
|
A third place because a cache is neither settings nor data: losing it costs
|
||||||
|
a network request, not a model or a preference, and every system sets aside
|
||||||
|
a directory for exactly that kind of file, one that backups skip and
|
||||||
|
cleanup tools may empty. Storing it with the data would ask a backup to
|
||||||
|
carry files whose whole point is that they can be thrown away.
|
||||||
|
"""
|
||||||
|
here = platform or sys.platform
|
||||||
|
if here == "darwin":
|
||||||
|
return pathlib.Path.home() / "Library/Caches/Dikte"
|
||||||
|
if here == "win32":
|
||||||
|
return env_path("LOCALAPPDATA", "~/AppData/Local") / "Dikte" / "cache"
|
||||||
|
return env_path("XDG_CACHE_HOME", "~/.cache") / "dikte"
|
||||||
|
|
||||||
|
|
||||||
CONFIG_DIR, DATA_DIR = directories()
|
CONFIG_DIR, DATA_DIR = directories()
|
||||||
|
|||||||
+238
-37
@@ -1,7 +1,9 @@
|
|||||||
"""Settings window."""
|
"""Settings window."""
|
||||||
|
|
||||||
|
import functools
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
|
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
|
||||||
@@ -13,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
|
||||||
@@ -21,9 +24,12 @@ 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 i18n
|
||||||
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
|
||||||
|
|
||||||
@@ -76,7 +82,11 @@ ASSISTANT_PROVIDERS = [
|
|||||||
# Aliases resolve to the newest model of that name, so they age better than an
|
# Aliases resolve to the newest model of that name, so they age better than an
|
||||||
# id does; a full id can be typed in when a particular one is wanted.
|
# id does; a full id can be typed in when a particular one is wanted.
|
||||||
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"]
|
ASSISTANT_MODELS = ["sonnet", "opus", "haiku", "fable"]
|
||||||
CODEX_MODELS = ["gpt-5.4-codex", "gpt-5.4", "o4-mini"]
|
# What the Codex boxes offer before Codex itself has answered, and everything
|
||||||
|
# they offer when it cannot: the real list comes from `codex debug models` when
|
||||||
|
# the window opens, so this only has to be roughly right.
|
||||||
|
CODEX_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
|
||||||
|
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
|
||||||
# Starting points only; the box is editable and OpenRouter has hundreds.
|
# Starting points only; the box is editable and OpenRouter has hundreds.
|
||||||
ASSISTANT_OR_MODELS = [
|
ASSISTANT_OR_MODELS = [
|
||||||
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
|
"google/gemini-3.5-flash", "anthropic/claude-sonnet-5", "openai/gpt-5.4",
|
||||||
@@ -89,6 +99,12 @@ PERMISSION_MODES = [
|
|||||||
("Allow everything", "bypassPermissions"),
|
("Allow everything", "bypassPermissions"),
|
||||||
("Only what needs no permission", "manual"),
|
("Only what needs no permission", "manual"),
|
||||||
]
|
]
|
||||||
|
def _typed_model_note(name):
|
||||||
|
"""Every model box takes a typed name too; the tooltip that says so."""
|
||||||
|
return t("The list is a starting point, not a fence: any model name "
|
||||||
|
"{name} accepts can be typed straight in.", name=name)
|
||||||
|
|
||||||
|
|
||||||
# Codex confines the commands it runs instead of asking about them.
|
# Codex confines the commands it runs instead of asking about them.
|
||||||
CODEX_SANDBOXES = [
|
CODEX_SANDBOXES = [
|
||||||
("Read anything, write in the working directory", "workspace-write"),
|
("Read anything, write in the working directory", "workspace-write"),
|
||||||
@@ -190,10 +206,12 @@ class LocalModelBox(QGroupBox):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_listed = pyqtSignal(list, str)
|
_listed = pyqtSignal(list, str)
|
||||||
_quants = pyqtSignal(list, str)
|
|
||||||
# qint64 rather than int, which is C++'s 32-bit one: a 2.3 GB model is more
|
# qint64 rather than int, which is C++'s 32-bit one: a 2.3 GB model is more
|
||||||
# than fits in it, and the count comes out the far side negative.
|
# than fits in it, and the count comes out the far side negative.
|
||||||
_progress = pyqtSignal("qint64", "qint64")
|
# The tag says which label the numbers belong to: the program download and
|
||||||
|
# a model download can run at once, and routing by a flag read when the
|
||||||
|
# queued event lands put one job's bytes in the other's label.
|
||||||
|
_progress = pyqtSignal(str, "qint64", "qint64")
|
||||||
_finished = pyqtSignal(str, str)
|
_finished = pyqtSignal(str, str)
|
||||||
_installed = pyqtSignal(str, str)
|
_installed = pyqtSignal(str, str)
|
||||||
|
|
||||||
@@ -241,7 +259,6 @@ class LocalModelBox(QGroupBox):
|
|||||||
form.addRow(self.status)
|
form.addRow(self.status)
|
||||||
|
|
||||||
self._listed.connect(self._on_listed)
|
self._listed.connect(self._on_listed)
|
||||||
self._quants.connect(self._on_listed)
|
|
||||||
self._progress.connect(self._on_progress)
|
self._progress.connect(self._on_progress)
|
||||||
self._finished.connect(self._on_finished)
|
self._finished.connect(self._on_finished)
|
||||||
self._installed.connect(self._on_installed)
|
self._installed.connect(self._on_installed)
|
||||||
@@ -344,9 +361,9 @@ class LocalModelBox(QGroupBox):
|
|||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
found = self._models(repo) if self._repos is not None else self._models()
|
found = self._models(repo) if self._repos is not None else self._models()
|
||||||
self._quants.emit([("models", found)], "")
|
self._listed.emit([("models", found)], "")
|
||||||
except ggml.LocalError as exc:
|
except ggml.LocalError as exc:
|
||||||
self._quants.emit([], str(exc))
|
self._listed.emit([], str(exc))
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
@@ -408,7 +425,9 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
ggml.install_program(self.program, on_progress=self._report)
|
ggml.install_program(
|
||||||
|
self.program,
|
||||||
|
on_progress=functools.partial(self._report, "program"))
|
||||||
self._installed.emit("", "")
|
self._installed.emit("", "")
|
||||||
except ggml.LocalError as exc:
|
except ggml.LocalError as exc:
|
||||||
self._installed.emit("", str(exc))
|
self._installed.emit("", str(exc))
|
||||||
@@ -437,8 +456,17 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
landed = ggml.download(item, self._model_path(item.name),
|
target_path = self._model_path(item.name)
|
||||||
on_progress=self._report,
|
# A model the running server holds open cannot be replaced on
|
||||||
|
# Windows, and the finished download would be thrown away over
|
||||||
|
# it; a re-download lets go of the server first, and the next
|
||||||
|
# run starts it again on the fresh file.
|
||||||
|
if target_path.exists():
|
||||||
|
(ggml.whisper if self.program is ggml.WHISPER
|
||||||
|
else ggml.llm).stop()
|
||||||
|
landed = ggml.download(item, target_path,
|
||||||
|
on_progress=functools.partial(
|
||||||
|
self._report, "model"),
|
||||||
should_stop=lambda: self._stop)
|
should_stop=lambda: self._stop)
|
||||||
self._finished.emit(item.name if landed else "", "")
|
self._finished.emit(item.name if landed else "", "")
|
||||||
except ggml.LocalError as exc:
|
except ggml.LocalError as exc:
|
||||||
@@ -446,15 +474,15 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _report(self, done, total):
|
def _report(self, job, done, total):
|
||||||
self._progress.emit(done, total)
|
self._progress.emit(job, done, total)
|
||||||
|
|
||||||
def _on_progress(self, done, total):
|
def _on_progress(self, job, done, total):
|
||||||
share = f" ({done * 100 // total}%)" if total else ""
|
share = f" ({done * 100 // total}%)" if total else ""
|
||||||
text = t("Downloading: {done} of {total}{share}",
|
text = t("Downloading: {done} of {total}{share}",
|
||||||
done=ggml.human_size(done), total=ggml.human_size(total or done),
|
done=ggml.human_size(done), total=ggml.human_size(total or done),
|
||||||
share=share)
|
share=share)
|
||||||
if self._downloading:
|
if job == "model":
|
||||||
self.status.setText(text)
|
self.status.setText(text)
|
||||||
else:
|
else:
|
||||||
self.program_label.setText(text)
|
self.program_label.setText(text)
|
||||||
@@ -512,11 +540,30 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
class SettingsWindow(QDialog):
|
class SettingsWindow(QDialog):
|
||||||
applied = pyqtSignal()
|
applied = pyqtSignal()
|
||||||
|
# A save changed the interface language. Every label below is translated
|
||||||
|
# as the window is built, so the change cannot reach this window: the
|
||||||
|
# owner replaces it with a fresh one instead.
|
||||||
|
language_changed = pyqtSignal()
|
||||||
|
# A newer release this window's own check found, so that the tray icon
|
||||||
|
# hears about it from here rather than waiting for its own next check.
|
||||||
|
update_found = pyqtSignal(object)
|
||||||
|
|
||||||
|
def _sources_once(self):
|
||||||
|
"""One device listing per window build, shared by every combo.
|
||||||
|
|
||||||
|
Three listings at open were three subprocess runs on the main thread,
|
||||||
|
which on a slow ffmpeg was most of the wait for the window."""
|
||||||
|
if not hasattr(self, "_sources"):
|
||||||
|
self._sources = audio.list_sources()
|
||||||
|
return self._sources
|
||||||
|
|
||||||
_models_loaded = pyqtSignal(list, str)
|
_models_loaded = pyqtSignal(list, str)
|
||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
|
_codex_models_loaded = pyqtSignal(list)
|
||||||
# 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)
|
||||||
@@ -533,6 +580,11 @@ class SettingsWindow(QDialog):
|
|||||||
self._key_fields = {}
|
self._key_fields = {}
|
||||||
self._testers = {}
|
self._testers = {}
|
||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
|
# What _save compares against to know a rebuild is due.
|
||||||
|
self._built_language = i18n.language()
|
||||||
|
# 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"))
|
||||||
|
|
||||||
@@ -566,7 +618,9 @@ 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._codex_models_loaded.connect(self._on_codex_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)
|
||||||
@@ -575,6 +629,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.meetings.finished.connect(self._on_minutes_finished)
|
self.meetings.finished.connect(self._on_minutes_finished)
|
||||||
self.meetings.failed.connect(self._on_minutes_failed)
|
self.meetings.failed.connect(self._on_minutes_failed)
|
||||||
self._load()
|
self._load()
|
||||||
|
self._load_codex_models()
|
||||||
# Connected after the load, so that filling the boxes in is not taken
|
# Connected after the load, so that filling the boxes in is not taken
|
||||||
# for the user ticking them.
|
# for the user ticking them.
|
||||||
self.file_timestamps.toggled.connect(self._remember_file_choices)
|
self.file_timestamps.toggled.connect(self._remember_file_choices)
|
||||||
@@ -627,14 +682,11 @@ class SettingsWindow(QDialog):
|
|||||||
self.ui_language = QComboBox()
|
self.ui_language = QComboBox()
|
||||||
for label, code in UI_LANGUAGES:
|
for label, code in UI_LANGUAGES:
|
||||||
self.ui_language.addItem(t(label), code)
|
self.ui_language.addItem(t(label), code)
|
||||||
self.ui_language.setToolTip(
|
|
||||||
t("Restart Dikte for the language change to reach every window.")
|
|
||||||
)
|
|
||||||
form.addRow(t("Interface language"), self.ui_language)
|
form.addRow(t("Interface language"), self.ui_language)
|
||||||
|
|
||||||
self.mic = QComboBox()
|
self.mic = QComboBox()
|
||||||
self.mic.addItem(t("Default microphone"), "")
|
self.mic.addItem(t("Default microphone"), "")
|
||||||
for name, desc in audio.list_sources():
|
for name, desc in self._sources_once():
|
||||||
self.mic.addItem(desc, name)
|
self.mic.addItem(desc, name)
|
||||||
form.addRow(t("Microphone"), self.mic)
|
form.addRow(t("Microphone"), self.mic)
|
||||||
|
|
||||||
@@ -698,6 +750,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):
|
||||||
@@ -812,11 +881,13 @@ class SettingsWindow(QDialog):
|
|||||||
self.cleanup_claude_model = QComboBox()
|
self.cleanup_claude_model = QComboBox()
|
||||||
self.cleanup_claude_model.setEditable(True)
|
self.cleanup_claude_model.setEditable(True)
|
||||||
self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
|
self.cleanup_claude_model.addItems(CLEANUP_CLAUDE_MODELS)
|
||||||
|
self.cleanup_claude_model.setToolTip(_typed_model_note("Claude Code"))
|
||||||
orr_form.addRow(t("Model"), self.cleanup_claude_model)
|
orr_form.addRow(t("Model"), self.cleanup_claude_model)
|
||||||
|
|
||||||
self.cleanup_codex_model = QComboBox()
|
self.cleanup_codex_model = QComboBox()
|
||||||
self.cleanup_codex_model.setEditable(True)
|
self.cleanup_codex_model.setEditable(True)
|
||||||
self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
|
self.cleanup_codex_model.addItems([t("Codex's own default")] + CODEX_MODELS)
|
||||||
|
self.cleanup_codex_model.setToolTip(_typed_model_note("Codex"))
|
||||||
orr_form.addRow(t("Model"), self.cleanup_codex_model)
|
orr_form.addRow(t("Model"), self.cleanup_codex_model)
|
||||||
|
|
||||||
self.cleanup_reasoning = QComboBox()
|
self.cleanup_reasoning = QComboBox()
|
||||||
@@ -976,7 +1047,7 @@ class SettingsWindow(QDialog):
|
|||||||
"A name like “sonnet” always means the newest model of that line. "
|
"A name like “sonnet” always means the newest model of that line. "
|
||||||
"Opus thinks harder and answers slower, which is felt here more "
|
"Opus thinks harder and answers slower, which is felt here more "
|
||||||
"than anywhere else: you are standing in front of the screen."
|
"than anywhere else: you are standing in front of the screen."
|
||||||
))
|
) + " " + _typed_model_note("Claude Code"))
|
||||||
claude_form.addRow(t("Model"), self.assistant_model)
|
claude_form.addRow(t("Model"), self.assistant_model)
|
||||||
self.assistant_permission = QComboBox()
|
self.assistant_permission = QComboBox()
|
||||||
for label, value in PERMISSION_MODES:
|
for label, value in PERMISSION_MODES:
|
||||||
@@ -991,6 +1062,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.assistant_codex_model.addItem(t("Codex's own default"), "")
|
self.assistant_codex_model.addItem(t("Codex's own default"), "")
|
||||||
for name in CODEX_MODELS:
|
for name in CODEX_MODELS:
|
||||||
self.assistant_codex_model.addItem(name, name)
|
self.assistant_codex_model.addItem(name, name)
|
||||||
|
self.assistant_codex_model.setToolTip(_typed_model_note("Codex"))
|
||||||
codex_form.addRow(t("Model"), self.assistant_codex_model)
|
codex_form.addRow(t("Model"), self.assistant_codex_model)
|
||||||
self.assistant_codex_sandbox = QComboBox()
|
self.assistant_codex_sandbox = QComboBox()
|
||||||
for label, value in CODEX_SANDBOXES:
|
for label, value in CODEX_SANDBOXES:
|
||||||
@@ -1082,7 +1154,7 @@ class SettingsWindow(QDialog):
|
|||||||
sources_form = QFormLayout(sources)
|
sources_form = QFormLayout(sources)
|
||||||
self.meeting_mic = QComboBox()
|
self.meeting_mic = QComboBox()
|
||||||
self.meeting_mic.addItem(t("Same as dictation"), "")
|
self.meeting_mic.addItem(t("Same as dictation"), "")
|
||||||
for name, desc in audio.list_sources():
|
for name, desc in self._sources_once():
|
||||||
self.meeting_mic.addItem(desc, name)
|
self.meeting_mic.addItem(desc, name)
|
||||||
sources_form.addRow(t("Microphone"), self.meeting_mic)
|
sources_form.addRow(t("Microphone"), self.meeting_mic)
|
||||||
|
|
||||||
@@ -1568,6 +1640,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])
|
||||||
@@ -1593,9 +1667,20 @@ class SettingsWindow(QDialog):
|
|||||||
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
||||||
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
||||||
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
||||||
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
|
# The defaults as they read NOW, kept for the save comparison: after a
|
||||||
|
# language switch the boxes still hold the old language's default, and
|
||||||
|
# comparing against the new one would store that text as a custom
|
||||||
|
# prompt shadowing every future improvement.
|
||||||
|
self._loaded_defaults = {
|
||||||
|
"cleanup": cfg.default_cleanup_prompt(),
|
||||||
|
"file": cfg.default_file_cleanup_prompt(),
|
||||||
|
"assistant": cfg.default_assistant_prompt(),
|
||||||
|
"meeting": cfg.default_meeting_prompt(),
|
||||||
|
}
|
||||||
|
self.cleanup_prompt.setPlainText(
|
||||||
|
conf["cleanup_prompt"] or self._loaded_defaults["cleanup"])
|
||||||
self.file_cleanup_prompt.setPlainText(
|
self.file_cleanup_prompt.setPlainText(
|
||||||
conf["file_cleanup_prompt"] or cfg.default_file_cleanup_prompt()
|
conf["file_cleanup_prompt"] or self._loaded_defaults["file"]
|
||||||
)
|
)
|
||||||
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
|
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
|
||||||
|
|
||||||
@@ -1613,7 +1698,7 @@ class SettingsWindow(QDialog):
|
|||||||
self.assistant_paste.setChecked(conf["assistant_paste"])
|
self.assistant_paste.setChecked(conf["assistant_paste"])
|
||||||
self.assistant_cleanup.setChecked(conf["assistant_cleanup"])
|
self.assistant_cleanup.setChecked(conf["assistant_cleanup"])
|
||||||
self.assistant_prompt.setPlainText(
|
self.assistant_prompt.setPlainText(
|
||||||
conf["assistant_prompt"] or cfg.default_assistant_prompt()
|
conf["assistant_prompt"] or self._loaded_defaults["assistant"]
|
||||||
)
|
)
|
||||||
|
|
||||||
self._select_data(self.meeting_mic, conf["meeting_mic_target"])
|
self._select_data(self.meeting_mic, conf["meeting_mic_target"])
|
||||||
@@ -1625,10 +1710,11 @@ class SettingsWindow(QDialog):
|
|||||||
self._select_data(self.meeting_reasoning, conf["meeting_reasoning"])
|
self._select_data(self.meeting_reasoning, conf["meeting_reasoning"])
|
||||||
self._select_data(self.meeting_language, conf["meeting_language"])
|
self._select_data(self.meeting_language, conf["meeting_language"])
|
||||||
self.meeting_cleanup.setChecked(conf["meeting_cleanup"])
|
self.meeting_cleanup.setChecked(conf["meeting_cleanup"])
|
||||||
self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60))
|
self._meeting_max_loaded = int(conf["meeting_max_seconds"])
|
||||||
|
self.meeting_max_minutes.setValue(max(5, self._meeting_max_loaded // 60))
|
||||||
self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"])
|
self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"])
|
||||||
self.meeting_prompt.setPlainText(
|
self.meeting_prompt.setPlainText(
|
||||||
conf["meeting_prompt"] or cfg.default_meeting_prompt()
|
conf["meeting_prompt"] or self._loaded_defaults["meeting"]
|
||||||
)
|
)
|
||||||
|
|
||||||
self.file_timestamps.setChecked(conf["file_timestamps"])
|
self.file_timestamps.setChecked(conf["file_timestamps"])
|
||||||
@@ -1661,6 +1747,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:
|
||||||
@@ -1690,13 +1777,18 @@ class SettingsWindow(QDialog):
|
|||||||
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
||||||
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
||||||
|
|
||||||
# Store an empty prompt when it matches the default, so switching the
|
# Store an empty prompt when it matches a default: the one it was
|
||||||
# interface language also switches the prompt language.
|
# loaded with, or today's (a Reset click in a session that switched
|
||||||
|
# languages fills in the latter). Both count, so switching the
|
||||||
|
# interface language keeps switching the prompt language.
|
||||||
prompt = self.cleanup_prompt.toPlainText().strip()
|
prompt = self.cleanup_prompt.toPlainText().strip()
|
||||||
conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt
|
conf["cleanup_prompt"] = ("" if prompt in (
|
||||||
|
self._loaded_defaults["cleanup"], cfg.default_cleanup_prompt())
|
||||||
|
else prompt)
|
||||||
file_prompt = self.file_cleanup_prompt.toPlainText().strip()
|
file_prompt = self.file_cleanup_prompt.toPlainText().strip()
|
||||||
conf["file_cleanup_prompt"] = ("" if file_prompt == cfg.default_file_cleanup_prompt()
|
conf["file_cleanup_prompt"] = ("" if file_prompt in (
|
||||||
else file_prompt)
|
self._loaded_defaults["file"], cfg.default_file_cleanup_prompt())
|
||||||
|
else file_prompt)
|
||||||
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
|
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
|
||||||
|
|
||||||
conf["assistant_provider"] = self.assistant_provider.currentData() or "claude"
|
conf["assistant_provider"] = self.assistant_provider.currentData() or "claude"
|
||||||
@@ -1723,8 +1815,9 @@ class SettingsWindow(QDialog):
|
|||||||
conf["assistant_paste"] = self.assistant_paste.isChecked()
|
conf["assistant_paste"] = self.assistant_paste.isChecked()
|
||||||
conf["assistant_cleanup"] = self.assistant_cleanup.isChecked()
|
conf["assistant_cleanup"] = self.assistant_cleanup.isChecked()
|
||||||
assistant_prompt = self.assistant_prompt.toPlainText().strip()
|
assistant_prompt = self.assistant_prompt.toPlainText().strip()
|
||||||
conf["assistant_prompt"] = ("" if assistant_prompt == cfg.default_assistant_prompt()
|
conf["assistant_prompt"] = ("" if assistant_prompt in (
|
||||||
else assistant_prompt)
|
self._loaded_defaults["assistant"], cfg.default_assistant_prompt())
|
||||||
|
else assistant_prompt)
|
||||||
|
|
||||||
conf["meeting_mic_target"] = self.meeting_mic.currentData() or ""
|
conf["meeting_mic_target"] = self.meeting_mic.currentData() or ""
|
||||||
conf["meeting_system_target"] = self.meeting_system.currentData() or ""
|
conf["meeting_system_target"] = self.meeting_system.currentData() or ""
|
||||||
@@ -1736,11 +1829,16 @@ class SettingsWindow(QDialog):
|
|||||||
conf["meeting_reasoning"] = self.meeting_reasoning.currentData() or ""
|
conf["meeting_reasoning"] = self.meeting_reasoning.currentData() or ""
|
||||||
conf["meeting_language"] = self.meeting_language.currentData() or ""
|
conf["meeting_language"] = self.meeting_language.currentData() or ""
|
||||||
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
|
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
|
||||||
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
|
# Only when the dial was actually turned: the box speaks whole minutes
|
||||||
|
# with a floor, and an unrelated Save must not rewrite a value the
|
||||||
|
# command line set in seconds.
|
||||||
|
if self.meeting_max_minutes.value() != max(5, self._meeting_max_loaded // 60):
|
||||||
|
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
|
||||||
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
|
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
|
||||||
meeting_prompt = self.meeting_prompt.toPlainText().strip()
|
meeting_prompt = self.meeting_prompt.toPlainText().strip()
|
||||||
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt()
|
conf["meeting_prompt"] = ("" if meeting_prompt in (
|
||||||
else meeting_prompt)
|
self._loaded_defaults["meeting"], cfg.default_meeting_prompt())
|
||||||
|
else meeting_prompt)
|
||||||
|
|
||||||
conf["file_timestamps"] = self.file_timestamps.isChecked()
|
conf["file_timestamps"] = self.file_timestamps.isChecked()
|
||||||
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
conf["file_cleanup"] = self.file_cleanup.isChecked()
|
||||||
@@ -1754,7 +1852,16 @@ class SettingsWindow(QDialog):
|
|||||||
or hotkey.default_combo(which))
|
or hotkey.default_combo(which))
|
||||||
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
|
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
|
||||||
conf["history_limit"] = self.history_limit.value()
|
conf["history_limit"] = self.history_limit.value()
|
||||||
conf.save()
|
try:
|
||||||
|
conf.save()
|
||||||
|
except OSError as exc:
|
||||||
|
# An antivirus or a sync tool holding the file for a beat is a
|
||||||
|
# message, not an exit: an exception out of a Qt slot takes the
|
||||||
|
# whole application down.
|
||||||
|
QMessageBox.warning(self, "Dikte",
|
||||||
|
t("Could not save the settings: {error}",
|
||||||
|
error=exc))
|
||||||
|
return
|
||||||
# A lowered limit should bite now, not on the next dictation.
|
# A lowered limit should bite now, not on the next dictation.
|
||||||
try:
|
try:
|
||||||
cfg.trim_history(conf["history_limit"])
|
cfg.trim_history(conf["history_limit"])
|
||||||
@@ -1762,7 +1869,27 @@ class SettingsWindow(QDialog):
|
|||||||
print(f"dikte: could not trim the history ({exc})")
|
print(f"dikte: could not trim the history ({exc})")
|
||||||
self._load_history() # the trim may just have dropped rows from the list
|
self._load_history() # the trim may just have dropped rows from the list
|
||||||
self.applied.emit()
|
self.applied.emit()
|
||||||
|
# conf.save() has switched the language t() speaks, so the message box
|
||||||
|
# already answers in the new one; the labels around it were translated
|
||||||
|
# when the window was built and stay behind. Asking for the rebuild
|
||||||
|
# waits until the box is dismissed, so the window is not pulled out
|
||||||
|
# from under a dialog it is holding up.
|
||||||
QMessageBox.information(self, t("Dikte Settings"), t("Saved successfully."))
|
QMessageBox.information(self, t("Dikte Settings"), t("Saved successfully."))
|
||||||
|
if i18n.language() != self._built_language and not self._work_in_flight():
|
||||||
|
self.language_changed.emit()
|
||||||
|
|
||||||
|
def _work_in_flight(self):
|
||||||
|
"""A daemon thread of this window's is still running.
|
||||||
|
|
||||||
|
Replacing the window now would let it be collected, taking the C++
|
||||||
|
side of the model boxes down with it, and the thread's next progress
|
||||||
|
report would land on a deleted object. The stale labels stand until a
|
||||||
|
later save finds the window quiet; _built_language keeps the old
|
||||||
|
language, so that save asks for the rebuild by itself.
|
||||||
|
"""
|
||||||
|
return (self.transcriber.busy
|
||||||
|
or self.local_whisper._downloading
|
||||||
|
or self.local_llm._downloading)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _select_data(combo, value):
|
def _select_data(combo, value):
|
||||||
@@ -1844,6 +1971,33 @@ class SettingsWindow(QDialog):
|
|||||||
combo.setCurrentText(current)
|
combo.setCurrentText(current)
|
||||||
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
self.models_label.setText(t("{count} models loaded.", count=len(models)))
|
||||||
|
|
||||||
|
def _load_codex_models(self):
|
||||||
|
"""Ask Codex which models it offers, off the interface thread.
|
||||||
|
|
||||||
|
No button and no network of ours: the CLI answers from its own cache in
|
||||||
|
well under a second. Skipped when Codex is not installed, which is also
|
||||||
|
when the built-in list stays on screen and nobody is running Codex
|
||||||
|
anyway.
|
||||||
|
"""
|
||||||
|
if not shutil.which("codex"):
|
||||||
|
return
|
||||||
|
|
||||||
|
def work():
|
||||||
|
found = assistant.codex_models()
|
||||||
|
if found:
|
||||||
|
self._codex_models_loaded.emit(found)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_codex_models_loaded(self, models):
|
||||||
|
for combo in (self.cleanup_codex_model, self.assistant_codex_model):
|
||||||
|
current = combo.currentText()
|
||||||
|
combo.clear()
|
||||||
|
combo.addItem(t("Codex's own default"), "")
|
||||||
|
for name in models:
|
||||||
|
combo.addItem(name, name)
|
||||||
|
combo.setCurrentText(current)
|
||||||
|
|
||||||
def _test_openai(self):
|
def _test_openai(self):
|
||||||
key, base = self._typed_key("openai")
|
key, base = self._typed_key("openai")
|
||||||
self._test_key("openai", lambda: t(
|
self._test_key("openai", lambda: t(
|
||||||
@@ -1891,6 +2045,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):
|
||||||
@@ -1917,7 +2110,10 @@ class SettingsWindow(QDialog):
|
|||||||
"""
|
"""
|
||||||
self.conf["file_timestamps"] = self.file_timestamps.isChecked()
|
self.conf["file_timestamps"] = self.file_timestamps.isChecked()
|
||||||
self.conf["file_cleanup"] = self.file_cleanup.isChecked()
|
self.conf["file_cleanup"] = self.file_cleanup.isChecked()
|
||||||
self.conf.save()
|
try:
|
||||||
|
self.conf.save()
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"dikte: could not save the settings: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
def _run_file(self):
|
def _run_file(self):
|
||||||
if not getattr(self, "file_path", "") or self.transcriber.busy:
|
if not getattr(self, "file_path", "") or self.transcriber.busy:
|
||||||
@@ -2018,7 +2214,12 @@ class SettingsWindow(QDialog):
|
|||||||
QMessageBox.information(self, t("Shortcut"), message)
|
QMessageBox.information(self, t("Shortcut"), message)
|
||||||
if ok:
|
if ok:
|
||||||
self.conf[spec.setting] = combo
|
self.conf[spec.setting] = combo
|
||||||
self.conf.save()
|
try:
|
||||||
|
self.conf.save()
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(self, "Dikte",
|
||||||
|
t("Could not save the settings: {error}",
|
||||||
|
error=exc))
|
||||||
self._refresh_shortcut_status(which)
|
self._refresh_shortcut_status(which)
|
||||||
|
|
||||||
def _remove_shortcut(self, which):
|
def _remove_shortcut(self, which):
|
||||||
|
|||||||
+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)
|
||||||
+3
-1
@@ -17,13 +17,15 @@ import unicodedata
|
|||||||
|
|
||||||
# Stock phrases the models produce when handed silence. Kept deliberately
|
# Stock phrases the models produce when handed silence. Kept deliberately
|
||||||
# narrow: only sentences nobody dictates on purpose in a two-second clip.
|
# narrow: only sentences nobody dictates on purpose in a two-second clip.
|
||||||
|
# Whisper does invent "you" and "bye" too, but people dictate both as whole
|
||||||
|
# answers, so a single word never belongs here.
|
||||||
HALLUCINATIONS = {
|
HALLUCINATIONS = {
|
||||||
"altyazi mk", "altyazi m k", "altyazi", "altyazilar",
|
"altyazi mk", "altyazi m k", "altyazi", "altyazilar",
|
||||||
"abone olmayi unutmayin", "izlediginiz icin tesekkurler",
|
"abone olmayi unutmayin", "izlediginiz icin tesekkurler",
|
||||||
"izlediginiz icin tesekkur ederim", "izlediginiz icin tesekkur ederiz",
|
"izlediginiz icin tesekkur ederim", "izlediginiz icin tesekkur ederiz",
|
||||||
"kanalima abone olmayi unutmayin", "altyazi mk altyazi mk",
|
"kanalima abone olmayi unutmayin", "altyazi mk altyazi mk",
|
||||||
"thanks for watching", "thank you for watching", "thanks for watching!",
|
"thanks for watching", "thank you for watching", "thanks for watching!",
|
||||||
"please subscribe", "subscribe to my channel", "you", "bye",
|
"please subscribe", "subscribe to my channel",
|
||||||
"mbc masr", "sous titres realises par la communaute damara org",
|
"mbc masr", "sous titres realises par la communaute damara org",
|
||||||
"amara org community", "sous titrage st 501",
|
"amara org community", "sous titrage st 501",
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-36
@@ -6,6 +6,7 @@ whatever came of it: an answer to a question, or a sentence saying what was
|
|||||||
done.
|
done.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import collections
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -45,25 +46,50 @@ class Pipeline(QObject):
|
|||||||
self.conf = conf
|
self.conf = conf
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
|
# Recordings waiting their turn, and whether a thread is working them
|
||||||
|
# off. The flag rather than the thread's own liveness, because a thread
|
||||||
|
# stays alive for a moment after deciding it is done, and a job arriving
|
||||||
|
# in that moment would be left in the queue with nobody coming back.
|
||||||
|
self._jobs = collections.deque()
|
||||||
|
self._draining = False
|
||||||
|
self._jobs_lock = threading.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
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.
|
||||||
if self.busy:
|
|
||||||
return
|
`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.
|
||||||
|
|
||||||
|
A run started while one is going waits its turn rather than being
|
||||||
|
dropped: the next dictation can be spoken while the last one is still
|
||||||
|
being cleaned up, and each one is finished, pasted and reported in the
|
||||||
|
order it was spoken."""
|
||||||
|
with self._jobs_lock:
|
||||||
|
self._jobs.append((wav_path, duration, list(rms_values), ask, paste,
|
||||||
|
focus))
|
||||||
|
if self._draining:
|
||||||
|
return
|
||||||
|
self._draining = True
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(target=self._drain, daemon=True)
|
||||||
target=self._work,
|
|
||||||
args=(wav_path, duration, list(rms_values), ask, paste),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
|
def _drain(self):
|
||||||
|
while True:
|
||||||
|
with self._jobs_lock:
|
||||||
|
if not self._jobs:
|
||||||
|
self._draining = False
|
||||||
|
return
|
||||||
|
job = self._jobs.popleft()
|
||||||
|
self._work(*job)
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
"""Give up on a job already under way.
|
"""Give up on a job already under way.
|
||||||
|
|
||||||
@@ -73,7 +99,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 = ""
|
||||||
@@ -107,11 +134,16 @@ class Pipeline(QObject):
|
|||||||
|
|
||||||
text = raw
|
text = raw
|
||||||
warning = ""
|
warning = ""
|
||||||
|
# Remembered rather than re-derived at the history write below: the
|
||||||
|
# ask path runs cleanup under a different setting, and the record
|
||||||
|
# should say what happened, not what one of the two gates implies.
|
||||||
|
cleaned = False
|
||||||
# Claude reads through “eee” and “hani” without help, so a dictation
|
# Claude reads through “eee” and “hani” without help, so a dictation
|
||||||
# on its way there is normally sent as it was heard, one API call and
|
# on its way there is normally sent as it was heard, one API call and
|
||||||
# a second or two lighter.
|
# a second or two lighter.
|
||||||
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
|
if (conf["assistant_cleanup"] if ask else conf["cleanup_enabled"]):
|
||||||
self.stage.emit(t("Cleaning up…"))
|
self.stage.emit(t("Cleaning up…"))
|
||||||
|
cleaned = True
|
||||||
try:
|
try:
|
||||||
text = cleanup.run(raw, conf, conf.cleanup_prompt())
|
text = cleanup.run(raw, conf, conf.cleanup_prompt())
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
@@ -137,62 +169,111 @@ class Pipeline(QObject):
|
|||||||
if paste_override is not None:
|
if paste_override is not None:
|
||||||
wants_paste = paste_override
|
wants_paste = paste_override
|
||||||
|
|
||||||
with _paste_lock:
|
# Into the history before the paste is attempted: the record says
|
||||||
previous = (paste.read_clipboard()
|
# what was dictated, not whether a key press landed, and a paste
|
||||||
if conf["restore_clipboard"] and wants_paste else None)
|
# that fails must not take the transcript down with it.
|
||||||
try:
|
record = {
|
||||||
paste.copy(text)
|
|
||||||
if wants_paste:
|
|
||||||
self.stage.emit(t("Pasting…"))
|
|
||||||
paste.press(conf["paste_shortcut"])
|
|
||||||
finally:
|
|
||||||
if previous is not None:
|
|
||||||
# Let the focused application consume the temporary
|
|
||||||
# transcription before putting every old clipboard type
|
|
||||||
# back. This also runs when key injection fails.
|
|
||||||
time.sleep(0.35)
|
|
||||||
paste.copy_bytes(previous)
|
|
||||||
|
|
||||||
cfg.append_history({
|
|
||||||
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"duration": round(duration, 1),
|
"duration": round(duration, 1),
|
||||||
"elapsed": round(time.monotonic() - started, 1),
|
"elapsed": round(time.monotonic() - started, 1),
|
||||||
"model": target.model,
|
"model": target.model,
|
||||||
"cleanup_model": cleanup.model(conf) if conf["cleanup_enabled"] else "",
|
"cleanup_model": cleanup.model(conf) if cleaned else "",
|
||||||
"cleanup_error": warning,
|
"cleanup_error": warning,
|
||||||
"mode": "ask" if ask else "",
|
"mode": "ask" if ask else "",
|
||||||
"question": question,
|
"question": question,
|
||||||
"assistant_model": conf["assistant_model"] if ask else "",
|
"assistant_model": conf["assistant_model"] if ask else "",
|
||||||
"raw": raw,
|
"raw": raw,
|
||||||
"text": text,
|
"text": text,
|
||||||
})
|
}
|
||||||
|
cfg.append_history(record)
|
||||||
try:
|
try:
|
||||||
cfg.trim_history(conf["history_limit"])
|
cfg.trim_history(conf["history_limit"])
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"dikte: could not trim the history: {exc}", file=sys.stderr)
|
print(f"dikte: could not trim the history: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
with _paste_lock:
|
||||||
|
previous = (paste.read_clipboard()
|
||||||
|
if conf["restore_clipboard"] and wants_paste else None)
|
||||||
|
paste.copy(text)
|
||||||
|
if wants_paste:
|
||||||
|
self.stage.emit(t("Pasting…"))
|
||||||
|
try:
|
||||||
|
paste.press(conf["paste_shortcut"], focus=focus)
|
||||||
|
except paste.PasteError as exc:
|
||||||
|
# The transcript is on the clipboard and in the history;
|
||||||
|
# a key press that would not land is a warning, not a
|
||||||
|
# failure, and the old clipboard is NOT put back over
|
||||||
|
# the text the user now has to paste by hand.
|
||||||
|
previous = None
|
||||||
|
warning = "\n".join(x for x in (
|
||||||
|
warning,
|
||||||
|
t("Copied, but pasting failed: {error}", error=exc),
|
||||||
|
) if x)
|
||||||
|
# The row above was written before the paste, so it
|
||||||
|
# has to be told what the paste then did.
|
||||||
|
record = cfg.amend_history(
|
||||||
|
record, cleanup_error=warning) or record
|
||||||
|
if previous is not None:
|
||||||
|
# Let the focused application consume the temporary
|
||||||
|
# transcription before putting every old clipboard type
|
||||||
|
# back.
|
||||||
|
time.sleep(0.35)
|
||||||
|
paste.copy_bytes(previous)
|
||||||
|
|
||||||
self.finished.emit(raw, text, warning)
|
self.finished.emit(raw, text, warning)
|
||||||
|
|
||||||
except assistant.Cancelled:
|
except assistant.Cancelled:
|
||||||
self.cancelled.emit()
|
self.cancelled.emit()
|
||||||
except (api.ApiError, paste.PasteError, assistant.AssistantError) as exc:
|
except (api.ApiError, paste.PasteError, assistant.AssistantError) as exc:
|
||||||
print(f"dikte: {exc}", file=sys.stderr)
|
print(f"dikte: {exc}", file=sys.stderr)
|
||||||
self.failed.emit(str(exc))
|
self.failed.emit(self._keeping(wav_path, str(exc)))
|
||||||
except Exception as exc: # never fail silently
|
except Exception as exc: # never fail silently
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
self.failed.emit(t("Unexpected error: {error}", error=exc))
|
self.failed.emit(self._keeping(wav_path, t("Unexpected error: {error}",
|
||||||
|
error=exc)))
|
||||||
finally:
|
finally:
|
||||||
self._discard(wav_path)
|
self._discard(wav_path)
|
||||||
|
|
||||||
|
def _keeping(self, wav_path, message):
|
||||||
|
"""Put the failed run's audio somewhere a retry can find it.
|
||||||
|
|
||||||
|
A dictation that died on the way to the model is speech the user cannot
|
||||||
|
say again from memory; deleting it because a server was down turns one
|
||||||
|
failure into two. Kept regardless of the keep_audio setting, which is
|
||||||
|
about the runs that succeeded.
|
||||||
|
"""
|
||||||
|
kept = self._keep(wav_path)
|
||||||
|
if not kept:
|
||||||
|
return message
|
||||||
|
return message + "\n" + t("The recording was kept: {path}", path=kept)
|
||||||
|
|
||||||
|
def _keep(self, wav_path):
|
||||||
|
"""Move the WAV into the recordings directory; its new path, or ''."""
|
||||||
|
try:
|
||||||
|
cfg.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
base = time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
# Two runs can finish inside the same second; the first one kept
|
||||||
|
# must not be overwritten by the second.
|
||||||
|
for suffix in ("",) + tuple(f"-{n}" for n in range(1, 100)):
|
||||||
|
target = cfg.RECORDINGS_DIR / f"{base}{suffix}.wav"
|
||||||
|
if not target.exists():
|
||||||
|
shutil.move(wav_path, target)
|
||||||
|
return str(target)
|
||||||
|
return ""
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"dikte: could not keep the audio: {exc}", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
|
||||||
def _discard(self, wav_path):
|
def _discard(self, wav_path):
|
||||||
if not os.path.exists(wav_path):
|
if not os.path.exists(wav_path):
|
||||||
return
|
return
|
||||||
if self.conf["keep_audio"]:
|
if self.conf["keep_audio"]:
|
||||||
try:
|
if self._keep(wav_path):
|
||||||
cfg.RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.move(wav_path, cfg.RECORDINGS_DIR / (time.strftime("%Y%m%d-%H%M%S") + ".wav"))
|
|
||||||
return
|
return
|
||||||
except OSError:
|
# The move failing is no reason to delete what the user asked to
|
||||||
pass
|
# keep: the temporary file stays where it is, named in the log.
|
||||||
|
print(f"dikte: the audio stays at {wav_path}", file=sys.stderr)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
os.unlink(wav_path)
|
os.unlink(wav_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
+21
-1
@@ -23,9 +23,20 @@ $autostartLink = Join-Path $startup "Dikte.lnk"
|
|||||||
$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd"
|
$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd"
|
||||||
|
|
||||||
if ($Uninstall) {
|
if ($Uninstall) {
|
||||||
foreach ($path in @($shortcut, $autostartLink, $cmdShim)) {
|
foreach ($path in @($shortcut, $autostartLink)) {
|
||||||
if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" }
|
if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" }
|
||||||
}
|
}
|
||||||
|
# The packaged install writes the same shim, naming its own dikte-cli.exe.
|
||||||
|
# Only the one naming this checkout is ours to delete; taking the other
|
||||||
|
# would break the `dikte` command of an install this script never made.
|
||||||
|
if (Test-Path $cmdShim) {
|
||||||
|
if ((Get-Content $cmdShim -Raw).Contains($entry)) {
|
||||||
|
Remove-Item $cmdShim -Force
|
||||||
|
Write-Host "removed: $cmdShim"
|
||||||
|
} else {
|
||||||
|
Write-Host "left alone: $cmdShim (it names another install, not this checkout)"
|
||||||
|
}
|
||||||
|
}
|
||||||
Write-Host "Dikte's shortcuts are gone. The repository and your settings are not."
|
Write-Host "Dikte's shortcuts are gone. The repository and your settings are not."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -65,6 +76,15 @@ foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else {
|
|||||||
Write-Host "shortcut: $path"
|
Write-Host "shortcut: $path"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($Autostart) {
|
||||||
|
# The packaged build keeps its sign-in entry in the registry. Left there
|
||||||
|
# beside the shortcut written above, both would start a Dikte at sign-in,
|
||||||
|
# and this install is the one being asked for.
|
||||||
|
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" `
|
||||||
|
-Name "Dikte" -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "autostart: the Startup shortcut replaces any registry Run entry a packaged install left"
|
||||||
|
}
|
||||||
|
|
||||||
# --- the dikte command ------------------------------------------------------
|
# --- the dikte command ------------------------------------------------------
|
||||||
# The interpreter by its full path rather than by name: the one checked above is
|
# The interpreter by its full path rather than by name: the one checked above is
|
||||||
# the one the command line should run, whatever a later PATH change puts first.
|
# the one the command line should run, whatever a later PATH change puts first.
|
||||||
|
|||||||
+15
-1
@@ -59,6 +59,13 @@ Name: "autostart"; Description: "Start Dikte when I sign in"
|
|||||||
[Files]
|
[Files]
|
||||||
Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
||||||
|
|
||||||
|
[InstallDelete]
|
||||||
|
; A checkout's install.ps1 -Autostart is a shortcut in the Startup folder. The
|
||||||
|
; registry entry this setup writes replaces it, and both left in place would be
|
||||||
|
; two Diktes at every sign-in. Only under the autostart task: somebody who
|
||||||
|
; unticked the box has not asked for their checkout's entry to go.
|
||||||
|
Type: files; Name: "{userstartup}\Dikte.lnk"; Tasks: autostart
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe"
|
Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe"
|
||||||
|
|
||||||
@@ -111,7 +118,14 @@ begin
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
var
|
||||||
|
Shim: AnsiString;
|
||||||
begin
|
begin
|
||||||
|
{ Only the shim this setup wrote, which is the one naming its dikte-cli.exe.
|
||||||
|
install.ps1 writes the same file for a checkout, naming that checkout's
|
||||||
|
Python, and a shim somebody else wrote is not this uninstaller's to take. }
|
||||||
if CurUninstallStep = usUninstall then
|
if CurUninstallStep = usUninstall then
|
||||||
DeleteFile(ShimPath());
|
if LoadStringFromFile(ShimPath(), Shim)
|
||||||
|
and (Pos(ExpandConstant('{app}\dikte-cli.exe'), Shim) > 0) then
|
||||||
|
DeleteFile(ShimPath());
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ analysis = Analysis( # noqa: F821
|
|||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Qt's xcb platform plugin uses libxkbcommon in two halves: the core library
|
||||||
|
# and libxkbcommon-x11, which allocates keymap objects and hands them to the
|
||||||
|
# core half to use and free, so the two must come from the same build. The
|
||||||
|
# build machine has only the core half installed, which had PyInstaller
|
||||||
|
# bundling that one while the other kept coming from the user's system, and a
|
||||||
|
# 22.04-era core freeing what a current x11 half allocated is the startup
|
||||||
|
# crash of issue #57. Ship neither: any desktop that can show a window
|
||||||
|
# carries both, from one build.
|
||||||
|
if not (MACOS or WINDOWS):
|
||||||
|
analysis.binaries = [entry for entry in analysis.binaries
|
||||||
|
if "libxkbcommon" not in entry[0]]
|
||||||
|
|
||||||
archive = PYZ(analysis.pure) # noqa: F821
|
archive = PYZ(analysis.pure) # noqa: F821
|
||||||
|
|
||||||
executable = EXE( # noqa: F821
|
executable = EXE( # noqa: F821
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
|
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
|
||||||
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
|
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
|
||||||
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data")
|
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data")
|
||||||
|
os.environ["XDG_CACHE_HOME"] = os.path.join(_SANDBOX, "cache")
|
||||||
# Home goes with them: the shortcut file, the applications directory and every
|
# Home goes with them: the shortcut file, the applications directory and every
|
||||||
# macOS path start from it rather than from an XDG variable, and a test run is
|
# macOS path start from it rather than from an XDG variable, and a test run is
|
||||||
# not allowed to touch the real one.
|
# not allowed to touch the real one.
|
||||||
|
|||||||
+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):
|
||||||
|
|||||||
+195
-19
@@ -10,21 +10,28 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from dikte import assistant
|
from dikte import assistant
|
||||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
from tests.support import (DikteTest, FakeCompleted, fake_urlopen,
|
||||||
|
only_these_tools)
|
||||||
|
|
||||||
|
|
||||||
class FakeCli:
|
class FakeCli:
|
||||||
"""A CLI that prints the given events and exits."""
|
"""A CLI that prints the given events and exits.
|
||||||
|
|
||||||
def __init__(self, events=(), code=0, stderr="", noise=()):
|
Its stderr is not modelled: _stream hands the process a temporary file for
|
||||||
|
that, and a mocked Popen leaves the file empty, which is what a quiet CLI
|
||||||
|
writes anyway.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, events=(), code=0, noise=()):
|
||||||
lines = list(noise) + [json.dumps(event) for event in events]
|
lines = list(noise) + [json.dumps(event) for event in events]
|
||||||
self.stdout = io.StringIO("\n".join(lines) + "\n")
|
self.stdout = io.StringIO("\n".join(lines) + "\n")
|
||||||
self.stderr = io.StringIO(stderr)
|
|
||||||
self.returncode = code
|
self.returncode = code
|
||||||
self.killed = False
|
self.killed = False
|
||||||
|
|
||||||
@@ -41,6 +48,38 @@ class FakeCli:
|
|||||||
self.killed = True
|
self.killed = True
|
||||||
|
|
||||||
|
|
||||||
|
class WedgedCli:
|
||||||
|
"""A CLI whose stdout produces nothing, the way a hung process's does.
|
||||||
|
|
||||||
|
Iterating its stdout blocks until the kill arrives, because that is what
|
||||||
|
reading a silent pipe does: only the process ending closes the stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.pid = 4242
|
||||||
|
self.returncode = None
|
||||||
|
self.released = threading.Event()
|
||||||
|
self.stdout = self
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __next__(self):
|
||||||
|
# The 5 second cap is a safety net for the test itself; the kill is
|
||||||
|
# what is supposed to end the wait.
|
||||||
|
self.released.wait(timeout=5)
|
||||||
|
raise StopIteration
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Provider(DikteTest):
|
class Provider(DikteTest):
|
||||||
def test_the_default(self):
|
def test_the_default(self):
|
||||||
self.assertEqual(assistant.provider(self.config()), "claude")
|
self.assertEqual(assistant.provider(self.config()), "claude")
|
||||||
@@ -221,19 +260,7 @@ class Denials(DikteTest):
|
|||||||
self.assertIn("Write", warning)
|
self.assertIn("Write", warning)
|
||||||
|
|
||||||
|
|
||||||
class SessionMissing(unittest.TestCase):
|
class LastLine(unittest.TestCase):
|
||||||
def test_a_session_that_is_gone(self):
|
|
||||||
for text in ("Error: session abc not found",
|
|
||||||
"No conversation with that id",
|
|
||||||
"unknown thread: abc"):
|
|
||||||
with self.subTest(text=text):
|
|
||||||
self.assertTrue(assistant._session_missing(text))
|
|
||||||
|
|
||||||
def test_an_unrelated_failure(self):
|
|
||||||
for text in ("", "network unreachable", "session limit exceeded"):
|
|
||||||
with self.subTest(text=text):
|
|
||||||
self.assertFalse(assistant._session_missing(text))
|
|
||||||
|
|
||||||
def test_the_last_line_is_the_one_worth_showing(self):
|
def test_the_last_line_is_the_one_worth_showing(self):
|
||||||
self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
|
self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
|
||||||
"real error")
|
"real error")
|
||||||
@@ -269,6 +296,28 @@ class Conclude(DikteTest):
|
|||||||
assistant._conclude(self.found(), 1, "session abc not found",
|
assistant._conclude(self.found(), 1, "session abc not found",
|
||||||
"abc", "Claude")
|
"abc", "Claude")
|
||||||
|
|
||||||
|
def test_the_recovery_no_longer_hangs_on_the_words_the_cli_chose(self):
|
||||||
|
# The complaint used to be matched by substring, which a CLI update or
|
||||||
|
# another language broke. A resumed run that died with nothing to show
|
||||||
|
# is now enough on its own.
|
||||||
|
for stderr in ("Oturum bulunamadı", "something else entirely", ""):
|
||||||
|
with self.subTest(stderr=stderr):
|
||||||
|
with self.assertRaises(assistant._SessionGone):
|
||||||
|
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||||
|
|
||||||
|
def test_api_trouble_on_a_resumed_run_is_not_blamed_on_the_session(self):
|
||||||
|
# A fresh session cannot cure a spent quota, a signed-out CLI or a dead
|
||||||
|
# network: the retry would fail the same way after a second wait, and
|
||||||
|
# the user would lose the conversation thread on top.
|
||||||
|
for stderr in ("Rate limit exceeded",
|
||||||
|
"You are not logged in. Please run /login.",
|
||||||
|
"API Error: 401 Unauthorized",
|
||||||
|
"fetch failed: ECONNREFUSED 127.0.0.1"):
|
||||||
|
with self.subTest(stderr=stderr):
|
||||||
|
with self.assertRaises(assistant.AssistantError) as caught:
|
||||||
|
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||||
|
self.assertIn(stderr, str(caught.exception))
|
||||||
|
|
||||||
def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self):
|
def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self):
|
||||||
with self.assertRaises(assistant.AssistantError):
|
with self.assertRaises(assistant.AssistantError):
|
||||||
assistant._conclude(self.found(), 1, "session abc not found",
|
assistant._conclude(self.found(), 1, "session abc not found",
|
||||||
@@ -279,6 +328,11 @@ class Conclude(DikteTest):
|
|||||||
"", "Claude")
|
"", "Claude")
|
||||||
self.assertEqual(answer, "done")
|
self.assertEqual(answer, "done")
|
||||||
|
|
||||||
|
def test_an_answer_on_a_resumed_session_is_kept_rather_than_retried(self):
|
||||||
|
answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise",
|
||||||
|
"abc", "Claude")
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
|
||||||
def test_a_reported_failure_with_no_answer(self):
|
def test_a_reported_failure_with_no_answer(self):
|
||||||
with self.assertRaises(assistant.AssistantError) as caught:
|
with self.assertRaises(assistant.AssistantError) as caught:
|
||||||
assistant._conclude(self.found(failure="the model refused"), 0, "",
|
assistant._conclude(self.found(failure="the model refused"), 0, "",
|
||||||
@@ -291,14 +345,53 @@ class Conclude(DikteTest):
|
|||||||
self.assertIn("Codex", str(caught.exception))
|
self.assertIn("Codex", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class Stream(DikteTest):
|
||||||
|
def test_a_cli_that_floods_stderr_still_finishes(self):
|
||||||
|
# A real subprocess, because the wedge being tested is real plumbing:
|
||||||
|
# with stderr on a pipe nobody drains, 200 KB fills the pipe's buffer,
|
||||||
|
# the child blocks writing it, and the run hangs until the watchdog
|
||||||
|
# timeout. With stderr on a file the run completes at once.
|
||||||
|
script = (
|
||||||
|
"import sys\n"
|
||||||
|
"sys.stderr.write('x' * 200000)\n"
|
||||||
|
"sys.stderr.flush()\n"
|
||||||
|
"print('{\"type\": \"result\", \"result\": \"done\"}')\n"
|
||||||
|
)
|
||||||
|
conf = self.config(assistant_timeout=15)
|
||||||
|
events = []
|
||||||
|
code, stderr = assistant._stream(
|
||||||
|
[sys.executable, "-c", script], conf, events.append, None)
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(len(stderr), 200000)
|
||||||
|
self.assertEqual(events[-1]["result"], "done")
|
||||||
|
|
||||||
|
def test_the_watchdog_takes_the_whole_tree_down_on_timeout(self):
|
||||||
|
proc = WedgedCli()
|
||||||
|
|
||||||
|
def killed(target):
|
||||||
|
# What the real kill does, as far as _stream can see: the process
|
||||||
|
# ends, and its closing stream releases the blocked read.
|
||||||
|
target.returncode = 1
|
||||||
|
target.released.set()
|
||||||
|
|
||||||
|
conf = self.config(assistant_timeout=0)
|
||||||
|
with mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||||
|
mock.patch.object(assistant, "kill_tree",
|
||||||
|
side_effect=killed) as kill:
|
||||||
|
with self.assertRaises(assistant.AssistantError) as caught:
|
||||||
|
assistant._stream(["claude"], conf, lambda event: None, None)
|
||||||
|
kill.assert_called_once_with(proc)
|
||||||
|
self.assertIn("did not finish", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
class AskClaude(DikteTest):
|
class AskClaude(DikteTest):
|
||||||
def run_ask(self, conf=None, events=None, code=0, stderr="", noise=(),
|
def run_ask(self, conf=None, events=None, code=0, noise=(),
|
||||||
session=""):
|
session=""):
|
||||||
conf = conf or self.config()
|
conf = conf or self.config()
|
||||||
proc = FakeCli(events or [
|
proc = FakeCli(events or [
|
||||||
{"type": "system", "subtype": "init", "session_id": "abc"},
|
{"type": "system", "subtype": "init", "session_id": "abc"},
|
||||||
{"type": "result", "session_id": "abc", "result": " done "},
|
{"type": "result", "session_id": "abc", "result": " done "},
|
||||||
], code=code, stderr=stderr, noise=noise)
|
], code=code, noise=noise)
|
||||||
stages = []
|
stages = []
|
||||||
with only_these_tools("claude", "codex"), \
|
with only_these_tools("claude", "codex"), \
|
||||||
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||||
@@ -533,6 +626,89 @@ class Ask(DikteTest):
|
|||||||
self.assertEqual(attempts, ["stale-id", ""])
|
self.assertEqual(attempts, ["stale-id", ""])
|
||||||
self.assertEqual(assistant.stored_provider(), "")
|
self.assertEqual(assistant.stored_provider(), "")
|
||||||
|
|
||||||
|
def test_a_resumed_run_that_dies_is_retried_without_the_session_flag(self):
|
||||||
|
# All the way through the stream this time: the first run exits 1 with
|
||||||
|
# no answer and whatever stderr it liked, and the recovery must not
|
||||||
|
# depend on those words.
|
||||||
|
conf = self.config()
|
||||||
|
assistant.write_session("claude", "stale-id")
|
||||||
|
procs = iter([
|
||||||
|
FakeCli(code=1),
|
||||||
|
FakeCli(events=[{"type": "result", "result": "done"}]),
|
||||||
|
])
|
||||||
|
cmds = []
|
||||||
|
|
||||||
|
def popen(cmd, **kwargs):
|
||||||
|
cmds.append(cmd)
|
||||||
|
return next(procs)
|
||||||
|
|
||||||
|
with only_these_tools("claude"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||||
|
answer, _ = assistant.ask("hi", conf)
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
self.assertEqual(cmds[0][cmds[0].index("--resume") + 1], "stale-id")
|
||||||
|
self.assertNotIn("--resume", cmds[1])
|
||||||
|
|
||||||
|
def test_a_run_that_dies_with_an_answer_in_hand_is_not_retried(self):
|
||||||
|
conf = self.config()
|
||||||
|
assistant.write_session("claude", "stale-id")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def popen(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return FakeCli(events=[{"type": "result", "result": "done"}], code=1)
|
||||||
|
|
||||||
|
with only_these_tools("claude"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||||
|
answer, _ = assistant.ask("hi", conf)
|
||||||
|
self.assertEqual(answer, "done")
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class CodexModels(DikteTest):
|
||||||
|
"""The model list read off `codex debug models`."""
|
||||||
|
|
||||||
|
CATALOG = {"models": [
|
||||||
|
{"slug": "gpt-6-mini", "visibility": "list", "priority": 9},
|
||||||
|
{"slug": "gpt-6", "visibility": "list", "priority": 1},
|
||||||
|
{"slug": "codex-auto-review", "visibility": "hide", "priority": 3},
|
||||||
|
]}
|
||||||
|
|
||||||
|
def models(self, reply, code=0):
|
||||||
|
with only_these_tools("codex"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
return_value=FakeCompleted(
|
||||||
|
returncode=code, stdout=reply)) as run:
|
||||||
|
found = assistant.codex_models()
|
||||||
|
self.run_call = run
|
||||||
|
return found
|
||||||
|
|
||||||
|
def test_the_catalog_arrives_best_first_without_the_hidden_ones(self):
|
||||||
|
found = self.models(json.dumps(self.CATALOG))
|
||||||
|
self.assertEqual(found, ["gpt-6", "gpt-6-mini"])
|
||||||
|
self.assertEqual(self.run_call.call_args.args[0],
|
||||||
|
["codex", "debug", "models"])
|
||||||
|
|
||||||
|
def test_a_codex_that_is_not_installed_is_not_run(self):
|
||||||
|
with only_these_tools(), \
|
||||||
|
mock.patch.object(subprocess, "run") as run:
|
||||||
|
self.assertEqual(assistant.codex_models(), [])
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_codex_too_old_to_have_the_command(self):
|
||||||
|
self.assertEqual(self.models("error: unknown subcommand", code=2), [])
|
||||||
|
|
||||||
|
def test_a_catalog_that_is_not_what_was_expected(self):
|
||||||
|
self.assertEqual(self.models(json.dumps(["gpt-6"])), [])
|
||||||
|
self.assertEqual(self.models(""), [])
|
||||||
|
|
||||||
|
def test_a_codex_that_hangs_is_given_up_on(self):
|
||||||
|
with only_these_tools("codex"), \
|
||||||
|
mock.patch.object(subprocess, "run",
|
||||||
|
side_effect=subprocess.TimeoutExpired(
|
||||||
|
["codex"], 30)):
|
||||||
|
self.assertEqual(assistant.codex_models(), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+203
-14
@@ -81,6 +81,14 @@ class ChunkLevels(unittest.TestCase):
|
|||||||
self.assertEqual(peak, 1.0)
|
self.assertEqual(peak, 1.0)
|
||||||
self.assertEqual(rms, 1.0)
|
self.assertEqual(rms, 1.0)
|
||||||
|
|
||||||
|
def test_the_fast_and_plain_rms_paths_agree(self):
|
||||||
|
"""sumprod is a speedup, not a different sum: on a 3.11 machine the
|
||||||
|
loop must land on the same integers."""
|
||||||
|
chunk = tone(0.1)
|
||||||
|
with mock.patch.object(audio, "sumprod", None):
|
||||||
|
plain = audio.chunk_levels(chunk)
|
||||||
|
self.assertEqual(audio.chunk_levels(chunk), plain)
|
||||||
|
|
||||||
|
|
||||||
class StereoLevels(unittest.TestCase):
|
class StereoLevels(unittest.TestCase):
|
||||||
def test_the_channels_are_read_apart(self):
|
def test_the_channels_are_read_apart(self):
|
||||||
@@ -280,6 +288,48 @@ class _StalledStream:
|
|||||||
self._released.set()
|
self._released.set()
|
||||||
|
|
||||||
|
|
||||||
|
class _DribblingStream:
|
||||||
|
"""A pipe that never fills a whole chunk in one read, the way an unbuffered
|
||||||
|
pipe hands data over under load."""
|
||||||
|
|
||||||
|
def __init__(self, data, piece):
|
||||||
|
self._data = io.BytesIO(data)
|
||||||
|
self._piece = piece
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
return self._data.read(min(size, self._piece))
|
||||||
|
|
||||||
|
|
||||||
|
class _RunSwappingStream:
|
||||||
|
"""A pipe whose recorder moves on mid-read, the way a new recording starts
|
||||||
|
while a stale pump is still draining the old one."""
|
||||||
|
|
||||||
|
def __init__(self, data, recorder, swap_at, new_proc):
|
||||||
|
self._data = io.BytesIO(data)
|
||||||
|
self._recorder = recorder
|
||||||
|
self._swap_at = swap_at
|
||||||
|
self._new_proc = new_proc
|
||||||
|
self.reads = 0
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
if self.reads == self._swap_at:
|
||||||
|
self._recorder._run = object()
|
||||||
|
self._recorder._proc = self._new_proc
|
||||||
|
self.reads += 1
|
||||||
|
return self._data.read(size)
|
||||||
|
|
||||||
|
|
||||||
|
class _SignalCrashingProcess(FakeProcess):
|
||||||
|
"""An ffmpeg that calls being interrupted a failure, the way ffmpeg does."""
|
||||||
|
|
||||||
|
def __init__(self, data, code=255):
|
||||||
|
super().__init__(data)
|
||||||
|
self._code = code
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return None if self._alive else self._code
|
||||||
|
|
||||||
|
|
||||||
class _HeldStream:
|
class _HeldStream:
|
||||||
"""A capture that is paused and taken up again partway through, the way a
|
"""A capture that is paused and taken up again partway through, the way a
|
||||||
key press lands in the middle of a recording rather than between two."""
|
key press lands in the middle of a recording rather than between two."""
|
||||||
@@ -307,7 +357,10 @@ class RecordingCommand(OnLinux, DikteTest):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
# Whether pw-record takes --raw is read off the installed binary, and
|
# Whether pw-record takes --raw is read off the installed binary, and
|
||||||
# what is being tested here is the command rather than the machine the
|
# what is being tested here is the command rather than the machine the
|
||||||
# test is running on. PwRecordRawOption covers the reading itself.
|
# test is running on. PwRecordRawOption covers the reading itself. The
|
||||||
|
# answer is remembered between calls, so it cannot be remembered
|
||||||
|
# between tests.
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
self.enterContext(mock.patch.object(
|
self.enterContext(mock.patch.object(
|
||||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||||
|
|
||||||
@@ -392,11 +445,30 @@ class PwRecordRawOption(DikteTest):
|
|||||||
self.assertEqual(["--raw"], self.option(return_value=FakeCompleted()))
|
self.assertEqual(["--raw"], self.option(return_value=FakeCompleted()))
|
||||||
|
|
||||||
|
|
||||||
|
class PwRawMemo(OnLinux, DikteTest):
|
||||||
|
"""The --raw probe runs once per process, not once per key press."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
|
|
||||||
|
def test_the_probe_is_asked_once_and_remembered(self):
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(audio, "_pw_record_raw_option",
|
||||||
|
return_value=["--raw"]) as probe:
|
||||||
|
first = audio.recording_command()
|
||||||
|
second = audio.recording_command()
|
||||||
|
probe.assert_called_once_with()
|
||||||
|
self.assertIn("--raw", first)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
|
||||||
class RecorderChain(OnLinux, DikteTest):
|
class RecorderChain(OnLinux, DikteTest):
|
||||||
"""Start to WAV, with pw-record faked out."""
|
"""Start to WAV, with pw-record faked out."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
self.enterContext(mock.patch.object(
|
self.enterContext(mock.patch.object(
|
||||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||||
|
|
||||||
@@ -476,42 +548,121 @@ class RecorderChain(OnLinux, DikteTest):
|
|||||||
self.assertEqual(len(failures), 1)
|
self.assertEqual(len(failures), 1)
|
||||||
self.assertIn("pulseaudio-utils", failures[0])
|
self.assertIn("pulseaudio-utils", failures[0])
|
||||||
|
|
||||||
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False):
|
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False,
|
||||||
|
alive=False):
|
||||||
"""Run the pump in this thread, where a queued signal would need an
|
"""Run the pump in this thread, where a queued signal would need an
|
||||||
event loop nobody is running here."""
|
event loop nobody is running here."""
|
||||||
recorder = audio.Recorder()
|
recorder = audio.Recorder()
|
||||||
failures = []
|
failures = []
|
||||||
|
deaths = []
|
||||||
recorder.failed.connect(failures.append)
|
recorder.failed.connect(failures.append)
|
||||||
|
recorder.died.connect(lambda: deaths.append(True))
|
||||||
proc = FakeProcess(data)
|
proc = FakeProcess(data)
|
||||||
proc.stderr = io.BytesIO(stderr)
|
proc._alive = alive
|
||||||
proc._alive = False
|
|
||||||
recorder._proc = proc
|
recorder._proc = proc
|
||||||
recorder._max_bytes = 10 ** 9
|
recorder._log = io.BytesIO(stderr)
|
||||||
recorder._stopping = stopping
|
recorder._stopping = stopping
|
||||||
recorder._cancelled = cancelled
|
recorder._cancelled = cancelled
|
||||||
recorder._pump()
|
recorder._run = run = object()
|
||||||
return failures
|
recorder._pump(run, proc, proc.stdout, recorder._buffer,
|
||||||
|
recorder._rms, 10 ** 9)
|
||||||
|
return failures, deaths
|
||||||
|
|
||||||
def test_a_recorder_that_died_on_its_own_says_so(self):
|
def test_a_recorder_that_died_on_its_own_says_so(self):
|
||||||
"""parec refused the device, or the sound server went away."""
|
"""parec refused the device, or the sound server went away."""
|
||||||
failures = self.pump(stderr=b"connection refused\n")
|
failures, _ = self.pump(stderr=b"connection refused\n")
|
||||||
self.assertEqual(len(failures), 1)
|
self.assertEqual(len(failures), 1)
|
||||||
self.assertIn("connection refused", failures[0])
|
self.assertIn("connection refused", failures[0])
|
||||||
|
|
||||||
def test_a_death_with_nothing_on_stderr_still_names_the_exit_code(self):
|
def test_a_death_with_nothing_on_stderr_still_names_the_exit_code(self):
|
||||||
failures = self.pump()
|
failures, _ = self.pump()
|
||||||
self.assertIn("exit code", failures[0])
|
self.assertIn("exit code", failures[0])
|
||||||
|
|
||||||
|
def test_a_death_that_left_no_exit_code_is_not_named_none(self):
|
||||||
|
"""A process nobody managed to reap has no code to show, and "exit
|
||||||
|
code None" would only puzzle the person reading it."""
|
||||||
|
failures, _ = self.pump(alive=True)
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertNotIn("None", failures[0])
|
||||||
|
|
||||||
def test_a_recording_we_ended_ourselves_is_not_a_death(self):
|
def test_a_recording_we_ended_ourselves_is_not_a_death(self):
|
||||||
"""Otherwise a stray keypress produces two errors, and the first one
|
"""Otherwise a stray keypress produces two errors, and the first one
|
||||||
sends the user looking for a broken sound server."""
|
sends the user looking for a broken sound server."""
|
||||||
self.assertEqual(self.pump(stopping=True), [])
|
self.assertEqual(self.pump(stopping=True), ([], []))
|
||||||
|
|
||||||
def test_a_cancelled_recording_is_not_a_death(self):
|
def test_a_cancelled_recording_is_not_a_death(self):
|
||||||
self.assertEqual(self.pump(cancelled=True), [])
|
self.assertEqual(self.pump(cancelled=True), ([], []))
|
||||||
|
|
||||||
def test_a_recorder_that_captured_something_first_is_not_a_death(self):
|
def test_a_capture_that_ends_mid_recording_dies_rather_than_fails(self):
|
||||||
self.assertEqual(self.pump(data=silence(0.5)), [])
|
"""Sound had already arrived, so this is not a broken installation:
|
||||||
|
the app is told the recording died and can rescue what there is."""
|
||||||
|
failures, deaths = self.pump(data=silence(0.5))
|
||||||
|
self.assertEqual(failures, [])
|
||||||
|
self.assertEqual(deaths, [True])
|
||||||
|
|
||||||
|
def test_short_pipe_reads_are_gathered_into_whole_chunks(self):
|
||||||
|
"""Every RMS entry must stand for one full chunk, or the silence check
|
||||||
|
weighs a half-filled read as its own stretch of room tone."""
|
||||||
|
half = audio.CHUNK_BYTES // 2
|
||||||
|
data = pcm([1000] * (3 * half // 2)) # three half-chunk reads
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
proc = FakeProcess(b"")
|
||||||
|
proc.stdout = _DribblingStream(data, half)
|
||||||
|
proc._alive = False
|
||||||
|
recorder._proc = proc
|
||||||
|
recorder._log = io.BytesIO(b"")
|
||||||
|
recorder._run = run = object()
|
||||||
|
buffer, rms = bytearray(), []
|
||||||
|
recorder._pump(run, proc, proc.stdout, buffer, rms, 10 ** 9)
|
||||||
|
self.assertEqual(len(buffer), len(data))
|
||||||
|
self.assertEqual(len(rms), 2) # one whole chunk, then the tail
|
||||||
|
|
||||||
|
def test_a_stale_pump_cannot_touch_the_recording_that_replaced_it(self):
|
||||||
|
"""A pump that outlives its join must not meter the next run, stop its
|
||||||
|
process, push audio into its buffer, or speak on its behalf."""
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
levels, failures, deaths = [], [], []
|
||||||
|
recorder.level.connect(levels.append)
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
recorder.died.connect(lambda: deaths.append(True))
|
||||||
|
new_proc = FakeProcess(b"")
|
||||||
|
old_proc = FakeProcess(b"")
|
||||||
|
old_proc.stdout = _RunSwappingStream(tone(0.192), recorder,
|
||||||
|
swap_at=1, new_proc=new_proc)
|
||||||
|
recorder._proc = old_proc
|
||||||
|
recorder._log = io.BytesIO(b"")
|
||||||
|
old_run = object()
|
||||||
|
recorder._run = old_run
|
||||||
|
recorder._buffer = bytearray() # the next recording's buffer
|
||||||
|
old_buffer, old_rms = bytearray(), []
|
||||||
|
recorder._pump(old_run, old_proc, old_proc.stdout, old_buffer, old_rms,
|
||||||
|
2 * audio.CHUNK_BYTES)
|
||||||
|
# Metered once, then the new run took over: the over-length cutoff hit
|
||||||
|
# on the next chunk and had to stand down instead of stopping a
|
||||||
|
# process that was never its own.
|
||||||
|
self.assertEqual(len(levels), 1)
|
||||||
|
self.assertEqual(len(old_buffer), 2 * audio.CHUNK_BYTES)
|
||||||
|
self.assertEqual(new_proc.signals, [])
|
||||||
|
self.assertEqual(old_proc.signals, [])
|
||||||
|
self.assertEqual(recorder._buffer, bytearray())
|
||||||
|
self.assertEqual((failures, deaths), ([], []))
|
||||||
|
|
||||||
|
def test_a_wav_that_cannot_be_written_is_reported_not_raised(self):
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
results, failures = [], []
|
||||||
|
recorder.stopped.connect(lambda *args: results.append(args))
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
proc = FakeProcess(tone(1.0))
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||||
|
mock.patch.object(audio, "write_wav",
|
||||||
|
side_effect=OSError("disk full")):
|
||||||
|
recorder.start()
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertIn("disk full", failures[0])
|
||||||
|
|
||||||
def test_a_short_recording_reports_only_that(self):
|
def test_a_short_recording_reports_only_that(self):
|
||||||
_, results, failures, _ = self.record(silence(0.1))
|
_, results, failures, _ = self.record(silence(0.1))
|
||||||
@@ -722,6 +873,42 @@ class MacMeetingRecorder(OnMacOS, DikteTest):
|
|||||||
_, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5))
|
_, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5))
|
||||||
self.assertTrue(all(process.signals for process in processes))
|
self.assertTrue(all(process.signals for process in processes))
|
||||||
|
|
||||||
|
def test_a_stop_we_asked_for_is_not_reported_as_an_ffmpeg_failure(self):
|
||||||
|
"""ffmpeg exits 255 when interrupted, and the interruption was our own
|
||||||
|
stop: a meeting ended at once must say "too short", not "ffmpeg → 255"."""
|
||||||
|
path = str(self.path("meeting.wav"))
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
failed = []
|
||||||
|
recorder.failed.connect(failed.append)
|
||||||
|
processes = [_SignalCrashingProcess(tone(0.1)),
|
||||||
|
_SignalCrashingProcess(tone(0.1))]
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||||
|
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
self.assertEqual(len(failed), 1)
|
||||||
|
self.assertIn("0.3", failed[0])
|
||||||
|
self.assertNotIn("255", failed[0])
|
||||||
|
|
||||||
|
def test_an_ffmpeg_that_died_on_its_own_keeps_its_exit_code(self):
|
||||||
|
"""A process nobody interrupted has a story to tell, and its code is
|
||||||
|
the only lead the user gets."""
|
||||||
|
path = str(self.path("meeting.wav"))
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
failed = []
|
||||||
|
recorder.failed.connect(failed.append)
|
||||||
|
dead = _SignalCrashingProcess(tone(0.1))
|
||||||
|
dead._alive = False # it fell over before stop() reached it
|
||||||
|
processes = [dead, FakeProcess(tone(0.1))]
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||||
|
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
self.assertEqual(len(failed), 1)
|
||||||
|
self.assertIn("255", failed[0])
|
||||||
|
|
||||||
def test_a_legacy_numeric_target_fails_before_recording(self):
|
def test_a_legacy_numeric_target_fails_before_recording(self):
|
||||||
recorder = audio.MeetingRecorder()
|
recorder = audio.MeetingRecorder()
|
||||||
failed = []
|
failed = []
|
||||||
@@ -953,9 +1140,11 @@ class WindowsDevices(OnWindows, DikteTest):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
# The listing is remembered between calls, so that a dictation does not
|
# The listing is remembered between calls, so that a dictation does not
|
||||||
# run ffmpeg of its own. It cannot be remembered between tests.
|
# run ffmpeg of its own. It cannot be remembered between tests, and
|
||||||
|
# neither can the pw-record probe's answer.
|
||||||
audio._DSHOW_SEEN.clear()
|
audio._DSHOW_SEEN.clear()
|
||||||
self.addCleanup(audio._DSHOW_SEEN.clear)
|
self.addCleanup(audio._DSHOW_SEEN.clear)
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def listing(self, stderr=None, tools=("ffmpeg",)):
|
def listing(self, stderr=None, tools=("ffmpeg",)):
|
||||||
|
|||||||
+31
-18
@@ -1,9 +1,9 @@
|
|||||||
"""Who cleans the transcript up, and what they are asked.
|
"""Who cleans the transcript up, and what they are asked.
|
||||||
|
|
||||||
The CLIs are faked at subprocess.run: what the tests read is the argument list
|
The CLIs are faked at subprocess.Popen: what the tests read is the argument
|
||||||
each one is given, where the answer is picked up from, and what happens to the
|
list each one is given, where the answer is picked up from, and what happens to
|
||||||
chain when the program is missing, slow or unhappy. The OpenRouter path is the
|
the chain when the program is missing, slow or unhappy. The OpenRouter path is
|
||||||
one that was always there and is checked here only for still being taken.
|
the one that was always there and is checked here only for still being taken.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -18,18 +18,26 @@ from tests.support import DikteTest, fake_urlopen, sent_json, url_error
|
|||||||
from tests.test_api import FakeServer, chat_reply
|
from tests.test_api import FakeServer, chat_reply
|
||||||
|
|
||||||
|
|
||||||
def fake_run(stdout="", code=0, stderr="", last_message=""):
|
def fake_cli(stdout="", code=0, stderr="", last_message=""):
|
||||||
"""Stand in for subprocess.run, writing the file Codex would have written."""
|
"""Stand in for subprocess.Popen.
|
||||||
|
|
||||||
|
_output hands the process a temporary file for each stream, so the fake
|
||||||
|
writes into those, plus the file Codex would have written on its way out.
|
||||||
|
"""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def run(cmd, **kwargs):
|
def popen(cmd, **kwargs):
|
||||||
calls.append(cmd)
|
calls.append(cmd)
|
||||||
|
kwargs["stdout"].write(stdout.encode("utf-8"))
|
||||||
|
kwargs["stderr"].write(stderr.encode("utf-8"))
|
||||||
if last_message and "-o" in cmd:
|
if last_message and "-o" in cmd:
|
||||||
with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh:
|
with open(cmd[cmd.index("-o") + 1], "w", encoding="utf-8") as fh:
|
||||||
fh.write(last_message)
|
fh.write(last_message)
|
||||||
return subprocess.CompletedProcess(cmd, code, stdout, stderr)
|
proc = mock.Mock()
|
||||||
|
proc.returncode = code
|
||||||
|
return proc
|
||||||
|
|
||||||
return mock.patch.object(subprocess, "run", side_effect=run), calls
|
return mock.patch.object(subprocess, "Popen", side_effect=popen), calls
|
||||||
|
|
||||||
|
|
||||||
class Provider(DikteTest):
|
class Provider(DikteTest):
|
||||||
@@ -80,7 +88,7 @@ class OpenRouter(DikteTest):
|
|||||||
|
|
||||||
def test_no_cli_is_started_for_it(self):
|
def test_no_cli_is_started_for_it(self):
|
||||||
conf = self.config(openrouter_api_key="sk-or-test")
|
conf = self.config(openrouter_api_key="sk-or-test")
|
||||||
patcher, calls = fake_run(stdout="never")
|
patcher, calls = fake_cli(stdout="never")
|
||||||
with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
|
with patcher, mock.patch.object(api, "cleanup", return_value="Done."):
|
||||||
cleanup.run("uh, done", conf, "the rules")
|
cleanup.run("uh, done", conf, "the rules")
|
||||||
self.assertEqual(calls, [])
|
self.assertEqual(calls, [])
|
||||||
@@ -93,7 +101,7 @@ class ClaudeCode(DikteTest):
|
|||||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
|
||||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||||
patcher, calls = fake_run(**kwargs)
|
patcher, calls = fake_cli(**kwargs)
|
||||||
with patcher:
|
with patcher:
|
||||||
answer = cleanup.run(text, self.conf, "the rules")
|
answer = cleanup.run(text, self.conf, "the rules")
|
||||||
return answer, calls[0]
|
return answer, calls[0]
|
||||||
@@ -146,14 +154,18 @@ class ClaudeCode(DikteTest):
|
|||||||
self.run_cleanup(stdout="Book it.")
|
self.run_cleanup(stdout="Book it.")
|
||||||
self.assertIn("claude", str(caught.exception))
|
self.assertIn("claude", str(caught.exception))
|
||||||
|
|
||||||
def test_a_run_that_never_ends(self):
|
def test_a_run_that_never_ends_is_killed_with_its_whole_tree(self):
|
||||||
def run(cmd, **kwargs):
|
def popen(cmd, **kwargs):
|
||||||
raise subprocess.TimeoutExpired(cmd, 180)
|
proc = mock.Mock()
|
||||||
|
proc.wait.side_effect = subprocess.TimeoutExpired(cmd, 180)
|
||||||
|
return proc
|
||||||
|
|
||||||
with mock.patch.object(subprocess, "run", side_effect=run):
|
with mock.patch.object(subprocess, "Popen", side_effect=popen), \
|
||||||
|
mock.patch.object(cleanup.assistant, "kill_tree") as kill:
|
||||||
with self.assertRaises(cleanup.CleanupError) as caught:
|
with self.assertRaises(cleanup.CleanupError) as caught:
|
||||||
cleanup.run("uh, book it", self.conf, "the rules")
|
cleanup.run("uh, book it", self.conf, "the rules")
|
||||||
self.assertIn("180", str(caught.exception))
|
self.assertIn("180", str(caught.exception))
|
||||||
|
kill.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class Codex(DikteTest):
|
class Codex(DikteTest):
|
||||||
@@ -163,7 +175,7 @@ class Codex(DikteTest):
|
|||||||
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
self.patch_attr(cleanup.shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
|
||||||
def run_cleanup(self, text="uh, book it", **kwargs):
|
def run_cleanup(self, text="uh, book it", **kwargs):
|
||||||
patcher, calls = fake_run(**kwargs)
|
patcher, calls = fake_cli(**kwargs)
|
||||||
with patcher:
|
with patcher:
|
||||||
answer = cleanup.run(text, self.conf, "the rules")
|
answer = cleanup.run(text, self.conf, "the rules")
|
||||||
return answer, calls[0]
|
return answer, calls[0]
|
||||||
@@ -280,7 +292,8 @@ class Here(DikteTest):
|
|||||||
self.assertIn("out of memory", str(caught.exception))
|
self.assertIn("out of memory", str(caught.exception))
|
||||||
|
|
||||||
def test_no_cli_is_started_for_it(self):
|
def test_no_cli_is_started_for_it(self):
|
||||||
patcher, calls = fake_run(stdout="never")
|
patcher, calls = fake_cli(stdout="never")
|
||||||
with patcher, fake_urlopen(chat_reply("Done.")):
|
with patcher, fake_urlopen(chat_reply("Done.")):
|
||||||
cleanup.run("uh, done", self.conf, "the rules")
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
self.assertEqual(calls, [])
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
|||||||
+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."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ config and now shadows the default.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -44,6 +47,21 @@ class Loading(DikteTest):
|
|||||||
conf = cfg.Config()
|
conf = cfg.Config()
|
||||||
self.assertEqual(conf["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
self.assertEqual(conf["cleanup_model"], cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
|
def test_a_config_that_is_not_json_is_set_aside_as_evidence(self):
|
||||||
|
"""Left in place it would be overwritten by the very next save."""
|
||||||
|
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cfg.CONFIG_FILE.write_text("{not json", encoding="utf-8")
|
||||||
|
broken = cfg.CONFIG_FILE.with_suffix(".json.broken")
|
||||||
|
with mock.patch("builtins.print") as told:
|
||||||
|
conf = cfg.Config()
|
||||||
|
self.assertEqual(broken.read_text(encoding="utf-8"), "{not json")
|
||||||
|
self.assertFalse(cfg.CONFIG_FILE.exists())
|
||||||
|
self.assertIn(str(broken), told.call_args[0][0])
|
||||||
|
conf.save()
|
||||||
|
self.assertEqual(broken.read_text(encoding="utf-8"), "{not json")
|
||||||
|
self.assertEqual(self.read_config_file()["cleanup_model"],
|
||||||
|
cfg.DEFAULTS["cleanup_model"])
|
||||||
|
|
||||||
def test_a_config_that_is_json_but_not_an_object(self):
|
def test_a_config_that_is_json_but_not_an_object(self):
|
||||||
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
cfg.CONFIG_FILE.write_text("[1, 2]", encoding="utf-8")
|
cfg.CONFIG_FILE.write_text("[1, 2]", encoding="utf-8")
|
||||||
@@ -113,6 +131,41 @@ class Saving(DikteTest):
|
|||||||
conf.save()
|
conf.save()
|
||||||
self.assertEqual(i18n.language(), "tr")
|
self.assertEqual(i18n.language(), "tr")
|
||||||
|
|
||||||
|
def test_the_settings_hit_the_disk_before_the_swap(self):
|
||||||
|
"""Renaming a file still in the page cache into place makes a power
|
||||||
|
cut a settings wipe, which is what the atomic replace exists to stop."""
|
||||||
|
with mock.patch("os.fsync") as fsync:
|
||||||
|
cfg.Config().save()
|
||||||
|
fsync.assert_called_once()
|
||||||
|
|
||||||
|
def test_a_file_held_briefly_by_a_scanner_does_not_fail_the_save(self):
|
||||||
|
"""Antivirus and sync tools on Windows hold a fresh file for a moment,
|
||||||
|
and the rename over it fails until they let go."""
|
||||||
|
attempts = []
|
||||||
|
real_replace = pathlib.Path.replace
|
||||||
|
|
||||||
|
def flaky(path, target):
|
||||||
|
attempts.append(str(target))
|
||||||
|
if len(attempts) < 3:
|
||||||
|
raise PermissionError("held by a scanner")
|
||||||
|
return real_replace(path, target)
|
||||||
|
|
||||||
|
with mock.patch.object(pathlib.Path, "replace", flaky), \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
cfg.Config().save()
|
||||||
|
self.assertEqual(len(attempts), 3)
|
||||||
|
self.assertEqual(self.read_config_file()["language"],
|
||||||
|
cfg.DEFAULTS["language"])
|
||||||
|
|
||||||
|
def test_a_file_held_for_good_still_raises(self):
|
||||||
|
def held(path, target):
|
||||||
|
raise PermissionError("never let go")
|
||||||
|
|
||||||
|
with mock.patch.object(pathlib.Path, "replace", held), \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
cfg.Config().save()
|
||||||
|
|
||||||
|
|
||||||
class Keys(DikteTest):
|
class Keys(DikteTest):
|
||||||
def test_a_stored_key_is_used(self):
|
def test_a_stored_key_is_used(self):
|
||||||
@@ -350,6 +403,23 @@ class History(DikteTest):
|
|||||||
cfg.delete_history([])
|
cfg.delete_history([])
|
||||||
self.assertEqual(len(cfg.read_history()), 1)
|
self.assertEqual(len(cfg.read_history()), 1)
|
||||||
|
|
||||||
|
def test_amending_matches_on_content_and_patches_in_place(self):
|
||||||
|
rows = [self.entry("a"), self.entry("b")]
|
||||||
|
for row in rows:
|
||||||
|
cfg.append_history(row)
|
||||||
|
patched = cfg.amend_history(rows[0], cleanup_error="could not paste")
|
||||||
|
self.assertEqual(patched["cleanup_error"], "could not paste")
|
||||||
|
kept = cfg.read_history()
|
||||||
|
self.assertEqual([row["text"] for row in kept], ["a", "b"])
|
||||||
|
self.assertEqual(kept[0]["cleanup_error"], "could not paste")
|
||||||
|
|
||||||
|
def test_amending_a_row_a_trim_took_away_is_a_no_op(self):
|
||||||
|
row = self.entry("gone")
|
||||||
|
cfg.append_history(row)
|
||||||
|
cfg.clear_history()
|
||||||
|
self.assertIsNone(cfg.amend_history(row, cleanup_error="x"))
|
||||||
|
self.assertEqual(cfg.read_history(), [])
|
||||||
|
|
||||||
def test_clearing(self):
|
def test_clearing(self):
|
||||||
cfg.append_history(self.entry("a"))
|
cfg.append_history(self.entry("a"))
|
||||||
cfg.clear_history()
|
cfg.clear_history()
|
||||||
@@ -358,6 +428,40 @@ class History(DikteTest):
|
|||||||
def test_clearing_a_history_that_is_not_there(self):
|
def test_clearing_a_history_that_is_not_there(self):
|
||||||
cfg.clear_history() # must not raise
|
cfg.clear_history() # must not raise
|
||||||
|
|
||||||
|
def test_an_append_during_a_trim_is_not_lost(self):
|
||||||
|
"""Trim is read, cut, rewrite; a dictation appended between the read
|
||||||
|
and the rewrite must wait rather than be erased by a rewrite that
|
||||||
|
never saw it. The rewrite is slowed down to hold the race open."""
|
||||||
|
for index in range(10):
|
||||||
|
cfg.append_history(self.entry(str(index)))
|
||||||
|
real_write = cfg._write_history
|
||||||
|
rewriting = threading.Event()
|
||||||
|
|
||||||
|
def slow_write(lines):
|
||||||
|
rewriting.set()
|
||||||
|
time.sleep(0.1)
|
||||||
|
real_write(lines)
|
||||||
|
|
||||||
|
with mock.patch.object(cfg, "_write_history", slow_write):
|
||||||
|
trimmer = threading.Thread(target=cfg.trim_history, args=(3,))
|
||||||
|
trimmer.start()
|
||||||
|
# The trim now holds the lock inside its read-cut-rewrite window.
|
||||||
|
self.assertTrue(rewriting.wait(5))
|
||||||
|
appender = threading.Thread(target=cfg.append_history,
|
||||||
|
args=(self.entry("late"),))
|
||||||
|
appender.start()
|
||||||
|
trimmer.join()
|
||||||
|
appender.join()
|
||||||
|
self.assertEqual([row["text"] for row in cfg.read_history()],
|
||||||
|
["7", "8", "9", "late"])
|
||||||
|
|
||||||
|
def test_the_rewrite_hits_the_disk_before_the_swap(self):
|
||||||
|
for index in range(5):
|
||||||
|
cfg.append_history(self.entry(str(index)))
|
||||||
|
with mock.patch("os.fsync") as fsync:
|
||||||
|
cfg.trim_history(2)
|
||||||
|
fsync.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class Meetings(DikteTest):
|
class Meetings(DikteTest):
|
||||||
def entry(self, base, **changes):
|
def entry(self, base, **changes):
|
||||||
@@ -430,6 +534,27 @@ class Meetings(DikteTest):
|
|||||||
cfg.delete_meetings([])
|
cfg.delete_meetings([])
|
||||||
self.assertEqual(len(cfg.read_meetings()), 1)
|
self.assertEqual(len(cfg.read_meetings()), 1)
|
||||||
|
|
||||||
|
def test_the_index_hits_the_disk_before_the_swap(self):
|
||||||
|
with mock.patch("os.fsync") as fsync:
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
fsync.assert_called_once()
|
||||||
|
|
||||||
|
def test_an_index_held_briefly_by_a_scanner_is_still_written(self):
|
||||||
|
real_replace = pathlib.Path.replace
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def flaky(path, target):
|
||||||
|
attempts.append(str(target))
|
||||||
|
if len(attempts) < 3:
|
||||||
|
raise PermissionError("held by a scanner")
|
||||||
|
return real_replace(path, target)
|
||||||
|
|
||||||
|
with mock.patch.object(pathlib.Path, "replace", flaky), \
|
||||||
|
mock.patch("time.sleep"):
|
||||||
|
cfg.save_meeting(self.entry("a"))
|
||||||
|
self.assertEqual(len(attempts), 3)
|
||||||
|
self.assertEqual([row["base"] for row in cfg.read_meetings()], ["a"])
|
||||||
|
|
||||||
|
|
||||||
class Defaults(unittest.TestCase):
|
class Defaults(unittest.TestCase):
|
||||||
"""The table itself, which every command line and settings tab reads."""
|
"""The table itself, which every command line and settings tab reads."""
|
||||||
|
|||||||
@@ -214,15 +214,49 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
class Ffmpeg(DikteTest):
|
||||||
|
"""How the converter process is started."""
|
||||||
|
|
||||||
|
def test_its_output_is_read_as_utf8_whatever_the_locale_says(self):
|
||||||
|
"""ffmpeg writes UTF-8; read as the locale codepage its messages
|
||||||
|
mojibake, and a byte the codepage cannot place raises from inside
|
||||||
|
communicate itself."""
|
||||||
|
out = str(self.path("out.wav"))
|
||||||
|
with open(out, "wb") as fh:
|
||||||
|
fh.write(b"\x00")
|
||||||
|
proc = mock.Mock()
|
||||||
|
proc.communicate.return_value = ("", "")
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
with mock.patch.object(ft.subprocess, "Popen", return_value=proc) as popen:
|
||||||
|
ft._ffmpeg(["-i", "in.mp4", out], out)
|
||||||
|
kwargs = popen.call_args.kwargs
|
||||||
|
self.assertTrue(kwargs["text"])
|
||||||
|
self.assertEqual(kwargs["encoding"], "utf-8")
|
||||||
|
self.assertEqual(kwargs["errors"], "replace")
|
||||||
|
|
||||||
|
|
||||||
class Chunks(DikteTest):
|
class Chunks(DikteTest):
|
||||||
"""What each provider is handed, and in how many pieces."""
|
"""What each provider is handed, and in how many pieces."""
|
||||||
|
|
||||||
@@ -378,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)
|
||||||
|
|||||||
+242
-2
@@ -9,6 +9,7 @@ import contextlib
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
@@ -179,6 +180,21 @@ class Download(Local):
|
|||||||
ggml.download(item("m.bin", b"x"), target)
|
ggml.download(item("m.bin", b"x"), target)
|
||||||
self.assertFalse(target.exists())
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
def test_a_target_held_open_keeps_the_finished_download(self):
|
||||||
|
# Windows refuses to replace a file a running server holds open. The
|
||||||
|
# bytes are complete and verified by then, so the .part must survive
|
||||||
|
# the failure rather than being deleted with everything else.
|
||||||
|
data = b"a finished, verified download"
|
||||||
|
target = self.path("data", "models", "m.bin")
|
||||||
|
with fake_urlopen(body(data)):
|
||||||
|
with mock.patch.object(pathlib.Path, "replace",
|
||||||
|
side_effect=PermissionError(13, "in use")):
|
||||||
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
|
ggml.download(item("m.bin", data), target)
|
||||||
|
self.assertIn("held open", str(caught.exception))
|
||||||
|
self.assertEqual(target.with_name("m.bin.part").read_bytes(), data)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
|
||||||
# --- installing a program -------------------------------------------------
|
# --- installing a program -------------------------------------------------
|
||||||
|
|
||||||
@@ -275,6 +291,70 @@ class InstallProgram(Local):
|
|||||||
self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty)
|
self.install("whisper-bin-ubuntu-x64.tar.gz", archive=empty)
|
||||||
self.assertIn("whisper-server", str(caught.exception))
|
self.assertIn("whisper-server", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_failed_update_leaves_the_working_install_alone(self):
|
||||||
|
# The old install used to be deleted before the new bytes had even
|
||||||
|
# arrived, so a bad download left no local server at all.
|
||||||
|
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz",
|
||||||
|
archive=b"not an archive at all")
|
||||||
|
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||||
|
self.assertTrue(os.path.isfile(path))
|
||||||
|
self.assertEqual(ggml.installed_version(ggml.WHISPER), "v1.9.1")
|
||||||
|
# And the half-made sibling did not linger either.
|
||||||
|
left = list(self.path("data", "bin", "whisper").glob("*.new"))
|
||||||
|
self.assertEqual(left, [])
|
||||||
|
|
||||||
|
def test_an_update_that_never_downloaded_leaves_the_install_alone(self):
|
||||||
|
path, _ = self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
with serving(listing, self.archive):
|
||||||
|
with mock.patch.object(ggml, "download",
|
||||||
|
side_effect=ggml.LocalError("no route")):
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
ggml.install_program(ggml.WHISPER)
|
||||||
|
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
|
||||||
|
self.assertTrue(os.path.isfile(path))
|
||||||
|
|
||||||
|
class StubServer:
|
||||||
|
"""Owns the installed binary, remembers when it was told to stop."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.program = ggml.WHISPER
|
||||||
|
self.stops = 0
|
||||||
|
self.new_version_was_ready = False
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return {"binary": ""}
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stops += 1
|
||||||
|
self.new_version_was_ready = any(
|
||||||
|
(ggml.BIN_DIR / "whisper").glob("*.new"))
|
||||||
|
|
||||||
|
def test_the_running_server_is_stopped_only_for_the_swap(self):
|
||||||
|
# The outage is the swap, not the transfer: the server keeps answering
|
||||||
|
# through a download that can take minutes, and is stopped only once
|
||||||
|
# the replacement is unpacked next door and known to be whole.
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
server = self.StubServer()
|
||||||
|
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
self.assertEqual(server.stops, 1)
|
||||||
|
self.assertTrue(server.new_version_was_ready)
|
||||||
|
|
||||||
|
def test_a_download_that_fails_never_stops_the_server(self):
|
||||||
|
self.install("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
server = self.StubServer()
|
||||||
|
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
|
||||||
|
with mock.patch.object(ggml, "SERVERS", (server,)):
|
||||||
|
with serving(listing, self.archive):
|
||||||
|
with mock.patch.object(ggml, "download",
|
||||||
|
side_effect=ggml.LocalError("no route")):
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
ggml.install_program(ggml.WHISPER)
|
||||||
|
self.assertEqual(server.stops, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_a_release_without_a_published_checksum_is_refused(self):
|
def test_a_release_without_a_published_checksum_is_refused(self):
|
||||||
# GitHub did not always publish one, and whisper.cpp v1.8.0 and older
|
# GitHub did not always publish one, and whisper.cpp v1.8.0 and older
|
||||||
@@ -456,12 +536,14 @@ STAND_IN = textwrap.dedent("""
|
|||||||
def opt(name, default=""):
|
def opt(name, default=""):
|
||||||
return args[args.index(name) + 1] if name in args else default
|
return args[args.index(name) + 1] if name in args else default
|
||||||
|
|
||||||
|
time.sleep(float(opt("--wait", "0")))
|
||||||
|
|
||||||
|
# After the sleep, so that --wait plus --die is a program that runs for a
|
||||||
|
# while and then crashes, the way a bad model dies mid-load.
|
||||||
if "--die" in args:
|
if "--die" in args:
|
||||||
print("could not load model: no such file")
|
print("could not load model: no such file")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
time.sleep(float(opt("--wait", "0")))
|
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
healthy_after = float(opt("--healthy-after", "0"))
|
healthy_after = float(opt("--healthy-after", "0"))
|
||||||
|
|
||||||
@@ -539,6 +621,14 @@ class Servers(Local):
|
|||||||
self.assertTrue(server.running)
|
self.assertTrue(server.running)
|
||||||
self.assertTrue(second)
|
self.assertTrue(second)
|
||||||
|
|
||||||
|
def count_ports(self):
|
||||||
|
"""Record every port handed to a launch, one per attempt."""
|
||||||
|
ports = []
|
||||||
|
real = ggml._free_port
|
||||||
|
self.patch_attr(ggml, "_free_port",
|
||||||
|
lambda: ports.append(real()) or ports[-1])
|
||||||
|
return ports
|
||||||
|
|
||||||
def test_a_program_that_dies_reports_what_it_printed(self):
|
def test_a_program_that_dies_reports_what_it_printed(self):
|
||||||
server = self.server(extra=["--die"])
|
server = self.server(extra=["--die"])
|
||||||
with self.assertRaises(ggml.LocalError) as caught:
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
@@ -546,6 +636,46 @@ class Servers(Local):
|
|||||||
self.assertIn("no such file", str(caught.exception))
|
self.assertIn("no such file", str(caught.exception))
|
||||||
self.assertFalse(server.running)
|
self.assertFalse(server.running)
|
||||||
|
|
||||||
|
def test_an_early_death_that_never_listened_is_retried(self):
|
||||||
|
# A child that loses the bind race fails and exits at once, and which
|
||||||
|
# port was lost cannot be told from the log: the shape of the failure,
|
||||||
|
# not its wording, is what earns another port.
|
||||||
|
ports = self.count_ports()
|
||||||
|
server = self.server(extra=["--die"])
|
||||||
|
with self.assertRaises(ggml.LocalError):
|
||||||
|
server.serve()
|
||||||
|
self.assertEqual(len(ports), 3)
|
||||||
|
|
||||||
|
def test_a_late_crash_is_not_retried(self):
|
||||||
|
# A program that ran for a while before dying was not a bind race: it
|
||||||
|
# would die the same way on any port.
|
||||||
|
self.patch_attr(ggml, "EARLY_EXIT_WINDOW", 0.2)
|
||||||
|
ports = self.count_ports()
|
||||||
|
server = self.server(extra=["--wait", "0.5", "--die"])
|
||||||
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
|
server.serve()
|
||||||
|
self.assertEqual(len(ports), 1)
|
||||||
|
self.assertIn("no such file", str(caught.exception))
|
||||||
|
|
||||||
|
def test_an_open_port_with_a_dead_child_is_not_ready(self):
|
||||||
|
# Another process winning the bind race leaves the port open while our
|
||||||
|
# child exits: the open port alone must not be read as ready.
|
||||||
|
polls = iter([None, 2])
|
||||||
|
proc = mock.Mock()
|
||||||
|
proc.poll = lambda: next(polls)
|
||||||
|
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||||
|
reason, listened = self.server()._wait_ready(proc, 1)
|
||||||
|
self.assertEqual(reason, "exited")
|
||||||
|
self.assertFalse(listened)
|
||||||
|
|
||||||
|
def test_an_open_port_with_a_child_that_outlives_it_a_beat_is_ready(self):
|
||||||
|
proc = mock.Mock()
|
||||||
|
proc.poll = lambda: None
|
||||||
|
self.patch_attr(ggml, "_listening", lambda port: True)
|
||||||
|
reason, listened = self.server()._wait_ready(proc, 1)
|
||||||
|
self.assertEqual(reason, "ready")
|
||||||
|
self.assertTrue(listened)
|
||||||
|
|
||||||
def test_a_model_that_is_still_loading_is_not_ready_yet(self):
|
def test_a_model_that_is_still_loading_is_not_ready_yet(self):
|
||||||
# llama binds its port first and answers /health with 503 until the
|
# llama binds its port first and answers /health with 503 until the
|
||||||
# model is in memory, so the open port on its own is not the signal.
|
# model is in memory, so the open port on its own is not the signal.
|
||||||
@@ -614,6 +744,72 @@ class Servers(Local):
|
|||||||
def test_no_pid_file_is_nothing_to_sweep(self):
|
def test_no_pid_file_is_nothing_to_sweep(self):
|
||||||
self.assertFalse(self.server().sweep())
|
self.assertFalse(self.server().sweep())
|
||||||
|
|
||||||
|
def test_an_unverifiable_pid_is_kept_for_a_later_sweep(self):
|
||||||
|
# Could not be checked is not the same as known stale: dropping the
|
||||||
|
# file here would lose track of a server that may still hold a model.
|
||||||
|
server = self.server()
|
||||||
|
server._remember(4242)
|
||||||
|
with mock.patch.object(server, "_is_ours", return_value=None):
|
||||||
|
self.assertFalse(server.sweep())
|
||||||
|
self.assertTrue(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_a_pid_known_stale_is_forgotten(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(4242)
|
||||||
|
with mock.patch.object(server, "_is_ours", return_value=False):
|
||||||
|
self.assertFalse(server.sweep())
|
||||||
|
self.assertFalse(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_the_pid_file_goes_once_the_kill_was_attempted(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(4242)
|
||||||
|
with mock.patch.object(server, "_is_ours", return_value=True):
|
||||||
|
with mock.patch.object(ggml.os, "kill") as kill:
|
||||||
|
self.assertTrue(server.sweep())
|
||||||
|
kill.assert_called_once_with(4242, signal.SIGTERM)
|
||||||
|
self.assertFalse(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_forget_leaves_a_pid_file_that_is_no_longer_ours(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(111)
|
||||||
|
# A Dikte started after us wrote its own server's pid over ours;
|
||||||
|
# removing the file would hide that server from every future sweep.
|
||||||
|
server._pid_file().write_text("222")
|
||||||
|
server._forget()
|
||||||
|
self.assertEqual(server._pid_file().read_text(), "222")
|
||||||
|
|
||||||
|
def test_forget_removes_the_pid_it_remembered(self):
|
||||||
|
server = self.server()
|
||||||
|
server._remember(111)
|
||||||
|
server._forget()
|
||||||
|
self.assertFalse(server._pid_file().exists())
|
||||||
|
|
||||||
|
def test_stop_waits_out_a_start_in_flight_and_kills_it(self):
|
||||||
|
# stop_all on quit must not slide past a launch that is mid-load: the
|
||||||
|
# child would then survive Dikte with nothing left that knows its pid.
|
||||||
|
children = []
|
||||||
|
real = ggml.subprocess.Popen
|
||||||
|
|
||||||
|
def popen(*args, **kwargs):
|
||||||
|
proc = real(*args, **kwargs)
|
||||||
|
children.append(proc)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
self.patch_attr(ggml.subprocess, "Popen", popen)
|
||||||
|
server = self.server(extra=["--wait", "0.5"])
|
||||||
|
thread = threading.Thread(target=server.serve)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not children and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01) # until the launch is truly in flight
|
||||||
|
server.stop()
|
||||||
|
finally:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
self.assertEqual(len(children), 1)
|
||||||
|
self.assertIsNotNone(children[0].poll())
|
||||||
|
self.assertFalse(server.running)
|
||||||
|
|
||||||
def test_a_start_that_goes_wrong_takes_its_process_with_it(self):
|
def test_a_start_that_goes_wrong_takes_its_process_with_it(self):
|
||||||
started = []
|
started = []
|
||||||
|
|
||||||
@@ -752,6 +948,10 @@ class InstallOnWindows(Local):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
self.patch_attr(sys, "platform", "win32")
|
self.patch_attr(sys, "platform", "win32")
|
||||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
|
# shutil.which cannot be allowed through to the real one: standing on
|
||||||
|
# win32 from another system, Python 3.12's Windows branch of which()
|
||||||
|
# reaches for the nt module that is not there.
|
||||||
|
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||||
self.archive = zipball({
|
self.archive = zipball({
|
||||||
"Release/whisper-server.exe": b"MZ not really a program",
|
"Release/whisper-server.exe": b"MZ not really a program",
|
||||||
"Release/whisper.dll": b"not really a library",
|
"Release/whisper.dll": b"not really a library",
|
||||||
@@ -785,3 +985,43 @@ class InstallOnWindows(Local):
|
|||||||
with self.assertRaises(ggml.LocalError) as caught:
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
ggml.install_program(ggml.WHISPER)
|
ggml.install_program(ggml.WHISPER)
|
||||||
self.assertIn("this machine", str(caught.exception))
|
self.assertIn("this machine", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsOwnership(Local):
|
||||||
|
"""Sweeping on Windows goes by the executable's full path, never its name:
|
||||||
|
the base name alone is anyone's whisper-server.exe, and this answer decides
|
||||||
|
what gets killed."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.patch_attr(sys, "platform", "win32")
|
||||||
|
# See InstallOnWindows: the real which() on win32 wants the nt module.
|
||||||
|
self.enterContext(mock.patch("shutil.which", return_value=None))
|
||||||
|
self.made = ggml.Server(ggml.WHISPER, lambda values: [], {"binary": ""})
|
||||||
|
|
||||||
|
def image(self, path):
|
||||||
|
self.patch_attr(ggml, "_win_image_name", lambda pid: path)
|
||||||
|
|
||||||
|
def test_a_binary_under_our_bin_directory_is_ours(self):
|
||||||
|
self.image(str(ggml.BIN_DIR / "whisper" / "v1.9.1" / "whisper-server.exe"))
|
||||||
|
self.assertIs(self.made._is_ours(1234), True)
|
||||||
|
|
||||||
|
def test_the_configured_binary_is_ours_wherever_it_lives(self):
|
||||||
|
mine = self.path("elsewhere", "whisper-server.exe")
|
||||||
|
mine.parent.mkdir(parents=True)
|
||||||
|
mine.write_bytes(b"MZ")
|
||||||
|
mine.chmod(0o755)
|
||||||
|
self.made._settings["binary"] = str(mine)
|
||||||
|
self.image(str(mine.resolve()))
|
||||||
|
self.assertIs(self.made._is_ours(1234), True)
|
||||||
|
|
||||||
|
def test_the_same_name_somewhere_else_is_not_ours(self):
|
||||||
|
self.image(str(self.path("theirs", "whisper-server.exe")))
|
||||||
|
with mock.patch("shutil.which", return_value=None):
|
||||||
|
self.assertIs(self.made._is_ours(1234), False)
|
||||||
|
|
||||||
|
def test_a_process_that_cannot_be_read_is_no_verdict(self):
|
||||||
|
# OpenProcess answering nothing covers both "gone" and "not readable
|
||||||
|
# from here", and only one of those makes the pid file safe to drop.
|
||||||
|
self.image("")
|
||||||
|
self.assertIsNone(self.made._is_ours(1234))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from dikte import hub
|
from dikte import hub
|
||||||
|
from dikte import paths
|
||||||
from tests.support import DikteTest, fake_urlopen, http_error, url_error
|
from tests.support import DikteTest, fake_urlopen, http_error, url_error
|
||||||
|
|
||||||
RELEASE = {
|
RELEASE = {
|
||||||
@@ -165,6 +166,15 @@ def os_utime(path):
|
|||||||
os.utime(path, (old, old))
|
os.utime(path, (old, old))
|
||||||
|
|
||||||
|
|
||||||
|
class CacheLocation(DikteTest):
|
||||||
|
"""Resolved at import, like every other path constant."""
|
||||||
|
|
||||||
|
def test_the_cache_lives_in_the_system_cache_directory(self):
|
||||||
|
# One answer for both, the same way ggml and config share DATA_DIR:
|
||||||
|
# hub asked paths once, at import, and kept what it was told.
|
||||||
|
self.assertEqual(hub.CACHE_DIR, paths.cache_dir())
|
||||||
|
|
||||||
|
|
||||||
class CacheOnDisk(DikteTest):
|
class CacheOnDisk(DikteTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|||||||
@@ -475,6 +475,17 @@ class Windows(unittest.TestCase):
|
|||||||
self.installed = pathlib.Path(self.tmp.name).resolve()
|
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||||
self.app = self.installed / "Dikte.exe"
|
self.app = self.installed / "Dikte.exe"
|
||||||
self.app.write_text("")
|
self.app.write_text("")
|
||||||
|
# APPDATA pointed into the sandbox, so that the Startup folder these
|
||||||
|
# tests delete from is never the machine's own.
|
||||||
|
appdata = mock.patch.dict(os.environ,
|
||||||
|
{"APPDATA": str(self.installed / "Roaming")})
|
||||||
|
appdata.start()
|
||||||
|
self.addCleanup(appdata.stop)
|
||||||
|
|
||||||
|
def startup_shortcut(self):
|
||||||
|
"""Where install.ps1 -Autostart puts a checkout's sign-in entry."""
|
||||||
|
return (self.installed / "Roaming" / "Microsoft" / "Windows"
|
||||||
|
/ "Start Menu" / "Programs" / "Startup" / "Dikte.lnk")
|
||||||
|
|
||||||
def _write(self, command):
|
def _write(self, command):
|
||||||
self.value = command
|
self.value = command
|
||||||
@@ -510,6 +521,45 @@ class Windows(unittest.TestCase):
|
|||||||
self.assertEqual(len(self.install()), 1)
|
self.assertEqual(len(self.install()), 1)
|
||||||
self.assertEqual(self.value, f'"{self.app}"')
|
self.assertEqual(self.value, f'"{self.app}"')
|
||||||
|
|
||||||
|
def test_an_entry_for_another_working_install_is_left_alone(self):
|
||||||
|
"""The same courtesy the Linux half pays another menu entry: an entry
|
||||||
|
naming an executable that still exists is an installation that still
|
||||||
|
works, and a start of this one has no business redirecting it."""
|
||||||
|
other = self.installed / "Elsewhere" / "Dikte.exe"
|
||||||
|
other.parent.mkdir()
|
||||||
|
other.write_text("")
|
||||||
|
self.value = f'"{other}"'
|
||||||
|
self.assertEqual(self.install(), [])
|
||||||
|
self.assertEqual(self.value, f'"{other}"')
|
||||||
|
|
||||||
|
def test_asking_outright_overrules_a_working_other_install(self):
|
||||||
|
other = self.installed / "Elsewhere" / "Dikte.exe"
|
||||||
|
other.parent.mkdir()
|
||||||
|
other.write_text("")
|
||||||
|
self.value = f'"{other}"'
|
||||||
|
self.assertEqual(self.install(force=True), [integrate._run_entry_name()])
|
||||||
|
self.assertEqual(self.value, f'"{self.app}"')
|
||||||
|
|
||||||
|
def test_typing_it_sweeps_away_a_checkout_startup_shortcut(self):
|
||||||
|
"""install.ps1 -Autostart writes it, the Run value replaces it, and
|
||||||
|
both left in place would be two Diktes at every sign-in."""
|
||||||
|
shortcut = self.startup_shortcut()
|
||||||
|
shortcut.parent.mkdir(parents=True)
|
||||||
|
shortcut.write_text("")
|
||||||
|
changed = self.install(force=True)
|
||||||
|
self.assertIn(shortcut, changed)
|
||||||
|
self.assertFalse(shortcut.exists())
|
||||||
|
|
||||||
|
def test_a_start_leaves_a_checkout_startup_shortcut_alone(self):
|
||||||
|
"""The silent call on every start has not been asked to move the
|
||||||
|
machine off its checkout."""
|
||||||
|
shortcut = self.startup_shortcut()
|
||||||
|
shortcut.parent.mkdir(parents=True)
|
||||||
|
shortcut.write_text("")
|
||||||
|
self.value = f'"{self.app}"'
|
||||||
|
self.assertEqual(self.install(), [])
|
||||||
|
self.assertTrue(shortcut.exists())
|
||||||
|
|
||||||
def test_running_it_again_changes_nothing(self):
|
def test_running_it_again_changes_nothing(self):
|
||||||
self.install(force=True)
|
self.install(force=True)
|
||||||
self.assertEqual(self.install(), [])
|
self.assertEqual(self.install(), [])
|
||||||
@@ -521,6 +571,31 @@ class Windows(unittest.TestCase):
|
|||||||
self.assertEqual(self.remove(), [])
|
self.assertEqual(self.remove(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class WindowedExecutable(unittest.TestCase):
|
||||||
|
"""The windowed executable, looked up beside whichever one is running.
|
||||||
|
|
||||||
|
Beside rather than at a known place: the setup lays both executables into
|
||||||
|
one directory wherever that directory was put, so either can find the
|
||||||
|
other without knowing where the install is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||||
|
|
||||||
|
def test_found_beside_the_named_executable(self):
|
||||||
|
windowed = self.installed / "Dikte.exe"
|
||||||
|
windowed.write_text("")
|
||||||
|
self.assertEqual(
|
||||||
|
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")),
|
||||||
|
windowed)
|
||||||
|
|
||||||
|
def test_none_when_no_setup_installed_one(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
integrate.windowed_executable(str(self.installed / "dikte-cli.exe")))
|
||||||
|
|
||||||
|
|
||||||
class WindowsExecutableNames(unittest.TestCase):
|
class WindowsExecutableNames(unittest.TestCase):
|
||||||
"""The two Windows executables, read out of the files that name them.
|
"""The two Windows executables, read out of the files that name them.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import unittest
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from dikte import ipc
|
from dikte import ipc
|
||||||
|
from tests.support import DikteTest
|
||||||
|
|
||||||
|
|
||||||
class FakeSocket:
|
class FakeSocket:
|
||||||
@@ -185,5 +186,74 @@ class Send(unittest.TestCase):
|
|||||||
self.assertTrue(sock.disconnected)
|
self.assertTrue(sock.disconnected)
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyServing(unittest.TestCase):
|
||||||
|
"""The single-instance check, which listen() cannot be: a Windows pipe
|
||||||
|
takes a second server on the same name rather than refusing it."""
|
||||||
|
|
||||||
|
def probe(self, socket):
|
||||||
|
with mock.patch.object(ipc, "QLocalSocket", return_value=socket):
|
||||||
|
return ipc.already_serving()
|
||||||
|
|
||||||
|
def test_nothing_running_means_go_ahead(self):
|
||||||
|
self.assertFalse(self.probe(FakeSocket(connected=False)))
|
||||||
|
|
||||||
|
def test_an_answer_means_yield(self):
|
||||||
|
self.assertTrue(self.probe(FakeSocket(reply=b'{"ok": true}\n')))
|
||||||
|
|
||||||
|
def test_the_probe_has_no_side_effect(self):
|
||||||
|
"""A probe that opened a window would open it during the relaunch a
|
||||||
|
slow instance provokes, on top of the verb being forwarded."""
|
||||||
|
sock = FakeSocket(reply=b'{"ok": true}\n')
|
||||||
|
self.probe(sock)
|
||||||
|
self.assertEqual(sock.written.decode("utf-8").strip(), "status")
|
||||||
|
|
||||||
|
def test_an_instance_too_old_to_answer_still_counts_as_running(self):
|
||||||
|
self.assertTrue(self.probe(FakeSocket(reply=b"")))
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceLock(DikteTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# The lock derives its home from paths, which DikteTest's cfg patches
|
||||||
|
# do not cover; without this the test would write into the real one.
|
||||||
|
from dikte import paths
|
||||||
|
self.patch_attr(paths, "DATA_DIR", self.path("data"))
|
||||||
|
|
||||||
|
def test_one_holder_at_a_time(self):
|
||||||
|
first = ipc.instance_lock()
|
||||||
|
self.assertIsNotNone(first)
|
||||||
|
self.assertTrue(first.tryLock(0))
|
||||||
|
second = ipc.instance_lock()
|
||||||
|
self.assertFalse(second.tryLock(0))
|
||||||
|
first.unlock()
|
||||||
|
self.assertTrue(second.tryLock(0))
|
||||||
|
second.unlock()
|
||||||
|
|
||||||
|
def test_the_lock_lives_in_the_data_directory(self):
|
||||||
|
from dikte import paths
|
||||||
|
lock = ipc.instance_lock()
|
||||||
|
self.assertTrue(lock.tryLock(0))
|
||||||
|
self.assertTrue((paths.DATA_DIR / "dikte.lock").exists())
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
|
||||||
|
class Respawn(unittest.TestCase):
|
||||||
|
def test_windows_starts_a_detached_process_and_returns(self):
|
||||||
|
with mock.patch.object(sys, "platform", "win32"), \
|
||||||
|
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
|
||||||
|
mock.patch.object(ipc.subprocess, "Popen") as popen:
|
||||||
|
ipc.respawn(["--gui"])
|
||||||
|
self.assertEqual(popen.call_args.args[0], ["py", "x", "--gui"])
|
||||||
|
self.assertEqual(popen.call_args.kwargs["creationflags"],
|
||||||
|
0x00000008 | 0x00000200)
|
||||||
|
|
||||||
|
def test_everywhere_else_the_process_is_replaced(self):
|
||||||
|
with mock.patch.object(sys, "platform", "linux"), \
|
||||||
|
mock.patch.object(ipc, "launcher", return_value=["py", "x"]), \
|
||||||
|
mock.patch.object(ipc.os, "execv") as execv:
|
||||||
|
ipc.respawn(["toggle", "--gui"])
|
||||||
|
execv.assert_called_once_with("py", ["py", "x", "toggle", "--gui"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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-")
|
||||||
|
|||||||
@@ -61,6 +61,34 @@ class Directories(unittest.TestCase):
|
|||||||
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte"))
|
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte"))
|
||||||
|
|
||||||
|
|
||||||
|
class CacheDir(unittest.TestCase):
|
||||||
|
"""The third place: files whose whole point is that they can be lost."""
|
||||||
|
|
||||||
|
def test_linux_follows_xdg(self):
|
||||||
|
with mock.patch.dict(os.environ, {"XDG_CACHE_HOME": "/k"}):
|
||||||
|
self.assertEqual(paths.cache_dir("linux").as_posix(), "/k/dikte")
|
||||||
|
|
||||||
|
def test_linux_without_the_variable_set(self):
|
||||||
|
with mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
self.assertTrue(paths.cache_dir("linux").as_posix()
|
||||||
|
.endswith("/.cache/dikte"))
|
||||||
|
|
||||||
|
def test_a_mac_caches_under_library_caches(self):
|
||||||
|
"""Where Time Machine already knows not to look."""
|
||||||
|
self.assertTrue(paths.cache_dir("darwin").as_posix()
|
||||||
|
.endswith("/Library/Caches/Dikte"))
|
||||||
|
|
||||||
|
def test_windows_caches_outside_the_roaming_profile(self):
|
||||||
|
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "C:/local"}):
|
||||||
|
self.assertEqual(paths.cache_dir("win32").as_posix(),
|
||||||
|
"C:/local/Dikte/cache")
|
||||||
|
|
||||||
|
def test_windows_without_the_variable_set(self):
|
||||||
|
with mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
self.assertTrue(paths.cache_dir("win32").as_posix()
|
||||||
|
.endswith("/AppData/Local/Dikte/cache"))
|
||||||
|
|
||||||
|
|
||||||
class OnePlace(unittest.TestCase):
|
class OnePlace(unittest.TestCase):
|
||||||
"""The programs and the models go where everything else goes.
|
"""The programs and the models go where everything else goes.
|
||||||
|
|
||||||
|
|||||||
+389
-3
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +133,8 @@ class Settings(DikteTest):
|
|||||||
"_load_models"))
|
"_load_models"))
|
||||||
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_transcribe_models"))
|
"_load_transcribe_models"))
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_codex_models"))
|
||||||
# The local model boxes fetch their own list the moment they are shown,
|
# The local model boxes fetch their own list the moment they are shown,
|
||||||
# from a thread, which is nobody's test failing but a real request.
|
# from a thread, which is nobody's test failing but a real request.
|
||||||
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
self.enterContext(mock.patch.object(settings_ui.LocalModelBox,
|
||||||
@@ -238,6 +242,21 @@ class Settings(DikteTest):
|
|||||||
with self.subTest(key=key):
|
with self.subTest(key=key):
|
||||||
self.assertEqual(stored[key], value)
|
self.assertEqual(stored[key], value)
|
||||||
|
|
||||||
|
def test_only_a_save_that_changed_the_language_says_so(self):
|
||||||
|
# The owner answers language_changed by replacing the window, so a
|
||||||
|
# save that left the language alone must keep quiet.
|
||||||
|
i18n = settings_ui.i18n
|
||||||
|
self.addCleanup(i18n.set_language, i18n.language())
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
heard = []
|
||||||
|
window.language_changed.connect(lambda: heard.append(True))
|
||||||
|
window._save()
|
||||||
|
self.assertEqual(heard, [])
|
||||||
|
other = "en" if i18n.language() == "tr" else "tr"
|
||||||
|
window._select_data(window.ui_language, other)
|
||||||
|
window._save()
|
||||||
|
self.assertEqual(heard, [True])
|
||||||
|
|
||||||
def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self):
|
def test_the_model_box_on_screen_belongs_to_whoever_cleans_up(self):
|
||||||
"""An OpenRouter id and a Claude alias are not the same field."""
|
"""An OpenRouter id and a Claude alias are not the same field."""
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
@@ -252,6 +271,44 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(shown, [provider])
|
self.assertEqual(shown, [provider])
|
||||||
self.assertFalse(box.isHidden())
|
self.assertFalse(box.isHidden())
|
||||||
|
|
||||||
|
def test_codex_answering_refills_both_of_its_boxes(self):
|
||||||
|
"""The list Codex gave replaces the built-in one, in both places, and
|
||||||
|
neither loses what was already picked."""
|
||||||
|
conf = self.config(cleanup_codex_model="my-own-model")
|
||||||
|
window = self.window(conf)
|
||||||
|
window._on_codex_models_loaded(["gpt-6", "gpt-6-mini"])
|
||||||
|
for combo in (window.cleanup_codex_model, window.assistant_codex_model):
|
||||||
|
with self.subTest(combo=combo.objectName() or "combo"):
|
||||||
|
offered = [combo.itemText(i) for i in range(combo.count())]
|
||||||
|
self.assertEqual(offered[1:], ["gpt-6", "gpt-6-mini"])
|
||||||
|
self.assertEqual(window.cleanup_codex_model.currentText(),
|
||||||
|
"my-own-model")
|
||||||
|
|
||||||
|
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 +548,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)
|
||||||
@@ -627,7 +999,9 @@ class MeetingSources(DikteTest):
|
|||||||
only_these_tools(), \
|
only_these_tools(), \
|
||||||
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
|
||||||
mock.patch.object(settings_ui.SettingsWindow,
|
mock.patch.object(settings_ui.SettingsWindow,
|
||||||
"_load_transcribe_models"):
|
"_load_transcribe_models"), \
|
||||||
|
mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_codex_models"):
|
||||||
window = settings_ui.SettingsWindow(cfg.Config())
|
window = settings_ui.SettingsWindow(cfg.Config())
|
||||||
self.addCleanup(window.deleteLater)
|
self.addCleanup(window.deleteLater)
|
||||||
self.addCleanup(window.close)
|
self.addCleanup(window.close)
|
||||||
@@ -656,6 +1030,9 @@ class LocalModels(DikteTest):
|
|||||||
# "nothing can transcribe" question from its real binary and model.
|
# "nothing can transcribe" question from its real binary and model.
|
||||||
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
|
||||||
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
|
||||||
|
# And one with Codex on it would ask it for its model list.
|
||||||
|
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
|
||||||
|
"_load_codex_models"))
|
||||||
|
|
||||||
def window(self, conf):
|
def window(self, conf):
|
||||||
window = settings_ui.SettingsWindow(conf)
|
window = settings_ui.SettingsWindow(conf)
|
||||||
@@ -692,12 +1069,21 @@ class LocalModels(DikteTest):
|
|||||||
# Qt's int is C++'s 32-bit one, and a 2.3 GB model is more than fits in
|
# Qt's int is C++'s 32-bit one, and a 2.3 GB model is more than fits in
|
||||||
# it: the count came out the far side negative, at "-1%".
|
# it: the count came out the far side negative, at "-1%".
|
||||||
box = self.window(cfg.Config()).local_llm
|
box = self.window(cfg.Config()).local_llm
|
||||||
box._downloading = True
|
box._report("model", 1_048_576, 2_489_757_856)
|
||||||
box._report(1_048_576, 2_489_757_856)
|
|
||||||
_app.processEvents()
|
_app.processEvents()
|
||||||
self.assertIn("2.3 GB", box.status.text())
|
self.assertIn("2.3 GB", box.status.text())
|
||||||
self.assertNotIn("-", box.status.text())
|
self.assertNotIn("-", box.status.text())
|
||||||
|
|
||||||
|
def test_each_download_reports_into_its_own_label(self):
|
||||||
|
"""The two can run at once; the tag, not a flag read later, says
|
||||||
|
which label the bytes belong to."""
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box._report("program", 10, 100)
|
||||||
|
box._report("model", 20, 100)
|
||||||
|
_app.processEvents()
|
||||||
|
self.assertIn("10", box.program_label.text())
|
||||||
|
self.assertIn("20", box.status.text())
|
||||||
|
|
||||||
def test_a_long_model_name_is_not_cut_in_half(self):
|
def test_a_long_model_name_is_not_cut_in_half(self):
|
||||||
# The list under a combo box takes the box's width and elides what does
|
# The list under a combo box takes the box's width and elides what does
|
||||||
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
||||||
|
|||||||
@@ -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)
|
||||||
+8
-2
@@ -128,6 +128,12 @@ class Hallucinations(DikteTest):
|
|||||||
self.assertFalse(vad.looks_like_hallucination("Bugün toplantı var.", 2.0))
|
self.assertFalse(vad.looks_like_hallucination("Bugün toplantı var.", 2.0))
|
||||||
self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 2.0))
|
self.assertFalse(vad.looks_like_hallucination("Send it on Thursday.", 2.0))
|
||||||
|
|
||||||
|
def test_a_one_word_answer_is_believed(self):
|
||||||
|
# Whisper invents both over silence, but people dictate both as whole
|
||||||
|
# answers, and losing a real answer costs more than passing a fake one.
|
||||||
|
self.assertFalse(vad.looks_like_hallucination("You.", 1.5))
|
||||||
|
self.assertFalse(vad.looks_like_hallucination("Bye.", 1.5))
|
||||||
|
|
||||||
def test_an_empty_transcript_counts_as_invented(self):
|
def test_an_empty_transcript_counts_as_invented(self):
|
||||||
self.assertTrue(vad.looks_like_hallucination(" ", 2.0))
|
self.assertTrue(vad.looks_like_hallucination(" ", 2.0))
|
||||||
self.assertTrue(vad.looks_like_hallucination("...", 2.0))
|
self.assertTrue(vad.looks_like_hallucination("...", 2.0))
|
||||||
@@ -138,8 +144,8 @@ class Hallucinations(DikteTest):
|
|||||||
self.assertTrue(vad.looks_like_hallucination(text, 2.0))
|
self.assertTrue(vad.looks_like_hallucination(text, 2.0))
|
||||||
|
|
||||||
def test_the_boundary_is_the_max_duration(self):
|
def test_the_boundary_is_the_max_duration(self):
|
||||||
self.assertTrue(vad.looks_like_hallucination("you", 6.0))
|
self.assertTrue(vad.looks_like_hallucination("thanks for watching", 6.0))
|
||||||
self.assertFalse(vad.looks_like_hallucination("you", 6.1))
|
self.assertFalse(vad.looks_like_hallucination("thanks for watching", 6.1))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+102
-13
@@ -8,6 +8,7 @@ afterwards. A pull request that reorders any of it shows up here.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -30,9 +31,11 @@ class Chain(DikteTest):
|
|||||||
|
|
||||||
def run_chain(self, ask=False, paste_override=None, duration=2.0,
|
def run_chain(self, ask=False, paste_override=None, duration=2.0,
|
||||||
transcript="uh, book it for Thursday",
|
transcript="uh, book it for Thursday",
|
||||||
|
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))
|
||||||
@@ -46,7 +49,10 @@ class Chain(DikteTest):
|
|||||||
# The chain reports its own failures on stderr, which a test run has no
|
# The chain reports its own failures on stderr, which a test run has no
|
||||||
# use for.
|
# use for.
|
||||||
with contextlib.redirect_stderr(io.StringIO()), \
|
with contextlib.redirect_stderr(io.StringIO()), \
|
||||||
mock.patch.object(api, "transcribe", return_value=transcript) as tr, \
|
mock.patch.object(
|
||||||
|
api, "transcribe",
|
||||||
|
**({"side_effect": transcribe_error} if transcribe_error
|
||||||
|
else {"return_value": transcript})) as tr, \
|
||||||
mock.patch.object(api, "cleanup", cleanup), \
|
mock.patch.object(api, "cleanup", cleanup), \
|
||||||
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
|
mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \
|
||||||
mock.patch.object(paste, "copy") as copy, \
|
mock.patch.object(paste, "copy") as copy, \
|
||||||
@@ -60,7 +66,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}
|
||||||
|
|
||||||
@@ -72,7 +79,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()
|
||||||
@@ -108,11 +123,57 @@ class Chain(DikteTest):
|
|||||||
run = self.run_chain()
|
run = self.run_chain()
|
||||||
run["copy_bytes"].assert_not_called()
|
run["copy_bytes"].assert_not_called()
|
||||||
|
|
||||||
def test_the_clipboard_is_put_back_when_the_keypress_fails(self):
|
def test_a_failed_keypress_leaves_the_transcript_on_the_clipboard(self):
|
||||||
|
"""The press failing is a warning, not a lost dictation: restoring the
|
||||||
|
old clipboard over the text would leave nothing to paste by hand."""
|
||||||
self.conf["restore_clipboard"] = True
|
self.conf["restore_clipboard"] = True
|
||||||
run = self.run_chain(paste_error=paste.PasteError("not trusted"))
|
run = self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||||
self.assertIn("not trusted", run["failures"][0])
|
self.assertEqual(run["failures"], [])
|
||||||
run["copy_bytes"].assert_called_once_with(b"what was there before")
|
raw, text, warning = run["done"][0]
|
||||||
|
self.assertIn("not trusted", warning)
|
||||||
|
run["copy_bytes"].assert_not_called()
|
||||||
|
|
||||||
|
def test_a_failed_keypress_still_reaches_the_history(self):
|
||||||
|
self.run_chain(paste_error=paste.PasteError("not trusted"))
|
||||||
|
rows = cfg.read_history()
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["text"], "Book it for Thursday.")
|
||||||
|
# The row goes in before the paste is attempted, so the paste failing
|
||||||
|
# has to be written back into it: the record tells the whole truth.
|
||||||
|
self.assertIn("not trusted", rows[0]["cleanup_error"])
|
||||||
|
|
||||||
|
def test_a_failed_transcription_keeps_the_audio(self):
|
||||||
|
"""Speech the user cannot repeat from memory must survive the failure."""
|
||||||
|
run = self.run_chain(transcribe_error=api.ApiError("server down"))
|
||||||
|
self.assertIn("server down", run["failures"][0])
|
||||||
|
self.assertIn("kept", run["failures"][0])
|
||||||
|
kept = list(cfg.RECORDINGS_DIR.glob("*.wav"))
|
||||||
|
self.assertEqual(len(kept), 1)
|
||||||
|
self.assertFalse(os.path.exists(self.wav))
|
||||||
|
|
||||||
|
def test_two_failures_in_one_second_keep_both_recordings(self):
|
||||||
|
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||||
|
self.wav = make_wav(self.path("clip2.wav"), speech(2.0))
|
||||||
|
with mock.patch.object(worker.time, "strftime",
|
||||||
|
return_value="20260820-120000"):
|
||||||
|
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||||
|
self.wav = make_wav(self.path("clip3.wav"), speech(2.0))
|
||||||
|
self.run_chain(transcribe_error=api.ApiError("down"))
|
||||||
|
self.assertEqual(len(list(cfg.RECORDINGS_DIR.glob("*.wav"))), 3)
|
||||||
|
|
||||||
|
def test_the_history_row_says_whether_cleanup_actually_ran(self):
|
||||||
|
"""The ask path cleans under its own setting; the record follows the
|
||||||
|
run, not the dictation gate."""
|
||||||
|
self.conf["cleanup_enabled"] = False
|
||||||
|
self.conf["assistant_cleanup"] = True
|
||||||
|
self.run_chain(ask=True)
|
||||||
|
row = cfg.read_history()[0]
|
||||||
|
self.assertNotEqual(row["cleanup_model"], "")
|
||||||
|
cfg.clear_history()
|
||||||
|
self.conf["cleanup_enabled"] = True
|
||||||
|
self.conf["assistant_cleanup"] = False
|
||||||
|
self.run_chain(ask=True)
|
||||||
|
self.assertEqual(cfg.read_history()[0]["cleanup_model"], "")
|
||||||
|
|
||||||
def test_the_transcription_is_told_the_language_and_the_glossary(self):
|
def test_the_transcription_is_told_the_language_and_the_glossary(self):
|
||||||
self.conf["language"] = "tr"
|
self.conf["language"] = "tr"
|
||||||
@@ -278,13 +339,41 @@ class Chain(DikteTest):
|
|||||||
|
|
||||||
|
|
||||||
class Busy(DikteTest):
|
class Busy(DikteTest):
|
||||||
def test_a_second_run_while_one_is_going_is_ignored(self):
|
def test_a_second_run_while_one_is_going_waits_its_turn(self):
|
||||||
|
"""The microphone is free while a transcript is being cleaned up, so
|
||||||
|
the next dictation can already have been spoken by then. It has to run
|
||||||
|
once the first is done, in the order they were spoken, on one thread."""
|
||||||
pipeline = worker.Pipeline(self.config())
|
pipeline = worker.Pipeline(self.config())
|
||||||
pipeline._thread = mock.Mock(is_alive=lambda: True)
|
order = []
|
||||||
self.assertTrue(pipeline.busy)
|
started, gate = threading.Event(), threading.Event()
|
||||||
with mock.patch.object(worker.threading, "Thread") as thread:
|
|
||||||
pipeline.run("/tmp/nope.wav", 1.0)
|
def work(wav_path, *_rest):
|
||||||
thread.assert_not_called()
|
order.append(wav_path)
|
||||||
|
started.set()
|
||||||
|
if wav_path == "first.wav":
|
||||||
|
gate.wait(5)
|
||||||
|
|
||||||
|
with mock.patch.object(pipeline, "_work", side_effect=work):
|
||||||
|
pipeline.run("first.wav", 1.0)
|
||||||
|
self.assertTrue(started.wait(5))
|
||||||
|
pipeline.run("second.wav", 1.0)
|
||||||
|
# Held, not dropped and not running beside the first.
|
||||||
|
self.assertEqual(order, ["first.wav"])
|
||||||
|
gate.set()
|
||||||
|
pipeline._thread.join(5)
|
||||||
|
self.assertEqual(order, ["first.wav", "second.wav"])
|
||||||
|
|
||||||
|
def test_a_run_arriving_after_the_queue_drained(self):
|
||||||
|
"""The worker thread ends with the queue; the next run brings one."""
|
||||||
|
pipeline = worker.Pipeline(self.config())
|
||||||
|
order = []
|
||||||
|
with mock.patch.object(pipeline, "_work",
|
||||||
|
side_effect=lambda wav, *rest: order.append(wav)):
|
||||||
|
pipeline.run("first.wav", 1.0)
|
||||||
|
pipeline._thread.join(5)
|
||||||
|
pipeline.run("second.wav", 1.0)
|
||||||
|
pipeline._thread.join(5)
|
||||||
|
self.assertEqual(order, ["first.wav", "second.wav"])
|
||||||
|
|
||||||
def test_the_chunk_length_matches_the_level_meter(self):
|
def test_the_chunk_length_matches_the_level_meter(self):
|
||||||
"""The silence thresholds are read in seconds, so the two must agree."""
|
"""The silence thresholds are read in seconds, so the two must agree."""
|
||||||
|
|||||||
Reference in New Issue
Block a user