mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-12 11:26:08 +00:00
Compare commits
4
Commits
v1.0.1
..
be09cc3ac6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be09cc3ac6 | ||
|
|
3663c7fd57 | ||
|
|
df99456cde | ||
|
|
084c48151d |
@@ -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.1"
|
__version__ = "1.0.2"
|
||||||
|
|||||||
+169
-18
@@ -33,8 +33,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 +45,13 @@ 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 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,6 +80,37 @@ ECHO_MS = 2000
|
|||||||
# short enough not to sit in the corner for the rest of the hour.
|
# short enough not to sit in the corner for the rest of the hour.
|
||||||
PEEK_MS = 12000
|
PEEK_MS = 12000
|
||||||
|
|
||||||
|
# When the releases page is looked at, and how often it is thought about after
|
||||||
|
# that. The delay is there so that a check never shares the first seconds of a
|
||||||
|
# start with the model being loaded and the desktop drawing the tray; the
|
||||||
|
# interval is not the interval between checks, which update.py holds at a day,
|
||||||
|
# but how often that clock is read, so that a machine left running for a week
|
||||||
|
# still asks once a day rather than once a boot.
|
||||||
|
UPDATE_DELAY_MS = 20000
|
||||||
|
UPDATE_POLL_MS = 3 * 3600 * 1000
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCheck(QObject):
|
||||||
|
"""One look at the releases page, off the interface thread.
|
||||||
|
|
||||||
|
An object of its own because the application is not one: a plain thread
|
||||||
|
cannot touch a widget, and a signal is the only way back onto the thread
|
||||||
|
that may.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The newer release, or None when there is nothing to say, and the reason
|
||||||
|
# nothing could be found out instead.
|
||||||
|
done = pyqtSignal(object, str)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
self.done.emit(update.check(), "")
|
||||||
|
except hub.HubError as exc:
|
||||||
|
self.done.emit(None, str(exc))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
class Dikte:
|
class Dikte:
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
@@ -111,6 +145,11 @@ 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
|
||||||
|
|
||||||
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
|
||||||
@@ -130,9 +169,9 @@ class Dikte:
|
|||||||
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.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)
|
||||||
@@ -159,6 +198,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 +261,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 +282,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 +341,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(
|
||||||
@@ -536,9 +597,11 @@ class Dikte:
|
|||||||
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():
|
||||||
@@ -555,7 +618,10 @@ class Dikte:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
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.overlay.show_recording()
|
self.overlay.show_recording()
|
||||||
self._begin_recording(DICTATION)
|
self._begin_recording(DICTATION)
|
||||||
@@ -583,7 +649,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):
|
||||||
@@ -646,7 +714,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):
|
||||||
@@ -829,26 +899,49 @@ class Dikte:
|
|||||||
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)
|
||||||
else:
|
else:
|
||||||
|
self._transcripts_pending += 1
|
||||||
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
|
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
|
||||||
|
|
||||||
|
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.
|
||||||
|
if self.state != RECORDING:
|
||||||
self.overlay.show_warning(
|
self.overlay.show_warning(
|
||||||
t("Pasted raw, cleanup failed: {error}", error=warning.splitlines()[0])
|
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):
|
||||||
@@ -885,9 +978,21 @@ class Dikte:
|
|||||||
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_error(self, 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._report(message, self.overlay)
|
||||||
self._set_state(IDLE)
|
self._transcript_settled({"ok": False, "error": message})
|
||||||
|
|
||||||
|
def _on_error(self, message):
|
||||||
|
"""The recorder or the key listener failed; no run reached the pipeline."""
|
||||||
|
self._report(message, self.overlay)
|
||||||
|
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,12 +1006,58 @@ class Dikte:
|
|||||||
if len(message) > len(first_line):
|
if len(message) > len(first_line):
|
||||||
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
|
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
|
||||||
|
|
||||||
|
# ---- updates ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _look_for_update(self):
|
||||||
|
"""The timer. update.py decides whether this is a request or a memory."""
|
||||||
|
if not self.conf["update_check"]:
|
||||||
|
return
|
||||||
|
self.updates.start()
|
||||||
|
|
||||||
|
def _on_update_checked(self, release, error):
|
||||||
|
if error:
|
||||||
|
# Nobody asked for this, so nobody is waiting to be told it failed.
|
||||||
|
# A machine that is offline, or a GitHub that is rate-limiting the
|
||||||
|
# address, is not a thing to interrupt a dictation about.
|
||||||
|
print(f"dikte: update check: {error}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
if release is None:
|
||||||
|
return
|
||||||
|
self._found_update(release)
|
||||||
|
# Once per version. A check that runs every day must not be a
|
||||||
|
# notification every day for an update somebody has decided to skip.
|
||||||
|
if update.announced() != release.version:
|
||||||
|
update.mark_announced(release.version)
|
||||||
|
self.tray.showMessage(
|
||||||
|
"Dikte",
|
||||||
|
t("Dikte {version} is out. The tray menu has the release page.",
|
||||||
|
version=release.version),
|
||||||
|
QSystemTrayIcon.MessageIcon.Information, 8000,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _found_update(self, release):
|
||||||
|
self.update_release = release
|
||||||
|
self._refresh_update()
|
||||||
|
|
||||||
|
def _refresh_update(self):
|
||||||
|
release = self.update_release
|
||||||
|
self.update_action.setVisible(release is not None)
|
||||||
|
if release is not None:
|
||||||
|
self.update_action.setText(
|
||||||
|
t("Dikte {version} is out…", version=release.version))
|
||||||
|
|
||||||
|
def open_release_page(self):
|
||||||
|
release = self.update_release
|
||||||
|
QDesktopServices.openUrl(
|
||||||
|
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
||||||
|
|
||||||
# ---- settings ---------------------------------------------------------
|
# ---- settings ---------------------------------------------------------
|
||||||
|
|
||||||
def open_settings(self):
|
def open_settings(self):
|
||||||
if self.settings_window is None:
|
if self.settings_window is None:
|
||||||
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||||
self.settings_window.applied.connect(self._apply_settings)
|
self.settings_window.applied.connect(self._apply_settings)
|
||||||
|
self.settings_window.update_found.connect(self._found_update)
|
||||||
self.settings_window.finished.connect(self._settings_closed)
|
self.settings_window.finished.connect(self._settings_closed)
|
||||||
self.settings_window.show()
|
self.settings_window.show()
|
||||||
self.settings_window.raise_()
|
self.settings_window.raise_()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
from PyQt6.QtCore import QCoreApplication, QTimer
|
from PyQt6.QtCore import QCoreApplication, QTimer
|
||||||
|
|
||||||
@@ -30,10 +31,12 @@ from . import cleanup
|
|||||||
from . import config as cfg
|
from . import config as cfg
|
||||||
from . import filetranscribe
|
from . import filetranscribe
|
||||||
from . import hotkey
|
from . import hotkey
|
||||||
|
from . import hub
|
||||||
from . import ipc
|
from . import ipc
|
||||||
from . import integrate
|
from . import integrate
|
||||||
from . import meeting
|
from . import meeting
|
||||||
from . import paste
|
from . import paste
|
||||||
|
from . import update
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
|
||||||
NOT_RUNNING = 3
|
NOT_RUNNING = 3
|
||||||
@@ -797,6 +800,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:
|
||||||
@@ -1098,6 +1128,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"),
|
||||||
|
|||||||
@@ -457,6 +457,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
|
||||||
|
|||||||
@@ -136,6 +136,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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ı",
|
||||||
@@ -192,6 +193,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",
|
||||||
|
|||||||
@@ -13,6 +13,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 +22,11 @@ from . import config as cfg
|
|||||||
from . import filetranscribe
|
from . import filetranscribe
|
||||||
from . import ggml
|
from . import ggml
|
||||||
from . import hotkey
|
from . import hotkey
|
||||||
|
from . import hub
|
||||||
from . import ipc
|
from . import ipc
|
||||||
from . import meeting
|
from . import meeting
|
||||||
from . import paste
|
from . import paste
|
||||||
|
from . import update
|
||||||
from .filetranscribe import FileTranscriber
|
from .filetranscribe import FileTranscriber
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
@@ -512,11 +515,16 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
class SettingsWindow(QDialog):
|
class SettingsWindow(QDialog):
|
||||||
applied = pyqtSignal()
|
applied = pyqtSignal()
|
||||||
|
# A newer release this window's own check found, so that the tray icon
|
||||||
|
# hears about it from here rather than waiting for its own next check.
|
||||||
|
update_found = pyqtSignal(object)
|
||||||
|
|
||||||
_models_loaded = pyqtSignal(list, str)
|
_models_loaded = pyqtSignal(list, str)
|
||||||
_transcribe_models_loaded = pyqtSignal(list, str)
|
_transcribe_models_loaded = pyqtSignal(list, str)
|
||||||
# Which key was tested, whether it worked, and what to write under it.
|
# Which key was tested, whether it worked, and what to write under it.
|
||||||
_test_done = pyqtSignal(str, bool, str)
|
_test_done = pyqtSignal(str, bool, str)
|
||||||
|
# The release that was found, or None, and what went wrong instead.
|
||||||
|
_update_checked = pyqtSignal(object, str)
|
||||||
|
|
||||||
def __init__(self, conf, meetings=None, parent=None):
|
def __init__(self, conf, meetings=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -533,6 +541,9 @@ class SettingsWindow(QDialog):
|
|||||||
self._key_fields = {}
|
self._key_fields = {}
|
||||||
self._testers = {}
|
self._testers = {}
|
||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
|
# Where "Open the release page" goes: the release itself once a check
|
||||||
|
# has named one, and the page that redirects to the newest until then.
|
||||||
|
self._release_url = update.RELEASES_PAGE
|
||||||
self.transcriber = FileTranscriber(conf, self)
|
self.transcriber = FileTranscriber(conf, self)
|
||||||
self.setWindowTitle(t("Dikte Settings"))
|
self.setWindowTitle(t("Dikte Settings"))
|
||||||
|
|
||||||
@@ -567,6 +578,7 @@ class SettingsWindow(QDialog):
|
|||||||
self._models_loaded.connect(self._on_models_loaded)
|
self._models_loaded.connect(self._on_models_loaded)
|
||||||
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
|
||||||
self._test_done.connect(self._on_test_done)
|
self._test_done.connect(self._on_test_done)
|
||||||
|
self._update_checked.connect(self._on_update_checked)
|
||||||
self.transcriber.progress.connect(self._on_file_progress)
|
self.transcriber.progress.connect(self._on_file_progress)
|
||||||
self.transcriber.finished.connect(self._on_file_finished)
|
self.transcriber.finished.connect(self._on_file_finished)
|
||||||
self.transcriber.failed.connect(self._on_file_failed)
|
self.transcriber.failed.connect(self._on_file_failed)
|
||||||
@@ -698,6 +710,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):
|
||||||
@@ -1568,6 +1597,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])
|
||||||
@@ -1661,6 +1692,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:
|
||||||
@@ -1891,6 +1923,45 @@ class SettingsWindow(QDialog):
|
|||||||
button.setEnabled(True)
|
button.setEnabled(True)
|
||||||
answer.setText(("✓ " if ok else "✗ ") + message)
|
answer.setText(("✓ " if ok else "✗ ") + message)
|
||||||
|
|
||||||
|
# ---- updates ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _check_for_update(self):
|
||||||
|
"""The button, which asks GitHub whatever the daily clock says."""
|
||||||
|
self.update_now.setEnabled(False)
|
||||||
|
self.update_status.setText(t("Looking…"))
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
self._update_checked.emit(update.check(force=True), "")
|
||||||
|
except hub.HubError as exc:
|
||||||
|
self._update_checked.emit(None, str(exc))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_update_checked(self, release, error):
|
||||||
|
self.update_now.setEnabled(True)
|
||||||
|
if error:
|
||||||
|
self.update_status.setText(error)
|
||||||
|
return
|
||||||
|
self._show_update(release, asked=True)
|
||||||
|
if release is not None:
|
||||||
|
self.update_found.emit(release)
|
||||||
|
|
||||||
|
def _show_update(self, release, asked=False):
|
||||||
|
"""What the line under the checkbox says, and whether the page button
|
||||||
|
is on it. `release` is None when this build is the newest one, and
|
||||||
|
`asked` is what tells "nothing new" from "nobody has looked yet"."""
|
||||||
|
self.update_page.setVisible(release is not None)
|
||||||
|
if release is None:
|
||||||
|
self.update_status.setText(
|
||||||
|
t("Dikte {version} is the newest release.", version=__version__)
|
||||||
|
if asked else t("This is Dikte {version}.", version=__version__))
|
||||||
|
return
|
||||||
|
self._release_url = release.url
|
||||||
|
self.update_status.setText(
|
||||||
|
t("Dikte {version} is out; this is {current}.",
|
||||||
|
version=release.version, current=__version__))
|
||||||
|
|
||||||
# ---- audio file ------------------------------------------------------
|
# ---- audio file ------------------------------------------------------
|
||||||
|
|
||||||
def _choose_file(self):
|
def _choose_file(self):
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
"""Whether a newer Dikte has been published, and where to get it.
|
||||||
|
|
||||||
|
GitHub is asked for the newest release, its number is held against the one this
|
||||||
|
build carries, and that is where it stops. Nothing is downloaded and nothing is
|
||||||
|
replaced. The four downloads are installed in four different ways, and three of
|
||||||
|
those belong to the platform rather than to Dikte: a Mac bundle is dragged into
|
||||||
|
Applications and cannot rewrite itself while it is running, the Windows setup
|
||||||
|
is an installer with an uninstall entry of its own, an AppImage is a single
|
||||||
|
file kept wherever its owner keeps it, and a checkout is updated with git. A
|
||||||
|
program that guessed at all four would be wrong on at least one of them, and
|
||||||
|
being wrong there means an installation somebody has to repair by hand. So the
|
||||||
|
answer ends in a browser, on the release page, where the same download that was
|
||||||
|
installed the first time is waiting.
|
||||||
|
|
||||||
|
The clock is kept in a file of its own rather than in the settings. A check
|
||||||
|
runs while the settings window may be open, and a background write into
|
||||||
|
config.json is exactly what would overwrite a setting somebody is in the middle
|
||||||
|
of changing.
|
||||||
|
|
||||||
|
Nothing here imports Qt or the rest of the application: `dikte update` at a
|
||||||
|
terminal and the timer behind the tray icon ask the same three questions of the
|
||||||
|
same module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import collections
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from . import hub
|
||||||
|
from . import paths
|
||||||
|
|
||||||
|
REPO = "yusufipk/dikte"
|
||||||
|
# Where somebody is sent. GitHub redirects this to whatever the newest release
|
||||||
|
# is, so it stays right without anybody writing a number into it.
|
||||||
|
RELEASES_PAGE = f"https://github.com/{REPO}/releases/latest"
|
||||||
|
|
||||||
|
# Once a day. A release happens every few weeks at best, and a question nobody
|
||||||
|
# is waiting on is not one to ask GitHub on every start.
|
||||||
|
INTERVAL = 24 * 3600
|
||||||
|
|
||||||
|
# When the last check was, what it found, and which version has already been
|
||||||
|
# announced. In the data directory rather than the config one: it is not a
|
||||||
|
# setting, nobody edits it, and losing it costs one extra request.
|
||||||
|
STATE_FILE = paths.DATA_DIR / "update.json"
|
||||||
|
|
||||||
|
Release = collections.namedtuple("Release", "version url published")
|
||||||
|
|
||||||
|
|
||||||
|
def _numbers(version):
|
||||||
|
"""(1, 0, 2) for "v1.0.2", "1.0.2" and "1.0.2-dev.abc1234" alike.
|
||||||
|
|
||||||
|
Empty for anything that does not start with a number, which is what a tag
|
||||||
|
naming something other than a version comes back as.
|
||||||
|
"""
|
||||||
|
number = str(version or "").strip().lstrip("vV").split("-")[0].split("+")[0]
|
||||||
|
parts = []
|
||||||
|
for piece in number.split("."):
|
||||||
|
digits = "".join(itertools.takewhile(str.isdigit, piece))
|
||||||
|
if not digits:
|
||||||
|
break
|
||||||
|
parts.append(int(digits))
|
||||||
|
return tuple((parts + [0, 0, 0])[:3]) if parts else ()
|
||||||
|
|
||||||
|
|
||||||
|
def newer(there, here=""):
|
||||||
|
"""Whether the release numbered `there` is one this build has not got.
|
||||||
|
|
||||||
|
Only the numbers are compared, and what follows them is dropped. A build
|
||||||
|
off master carries the released number with its commit after it
|
||||||
|
(1.0.1-dev.abc1234), and that build is ahead of 1.0.1 rather than behind
|
||||||
|
it; read as a version suffix it would be behind, and every nightly would be
|
||||||
|
told to update to the release it was already past.
|
||||||
|
"""
|
||||||
|
theirs = _numbers(there)
|
||||||
|
return bool(theirs) and theirs > _numbers(here or __version__)
|
||||||
|
|
||||||
|
|
||||||
|
def state():
|
||||||
|
"""What the last check wrote down; empty when there has never been one."""
|
||||||
|
try:
|
||||||
|
stored = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return stored if isinstance(stored, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _store(**changes):
|
||||||
|
stored = state()
|
||||||
|
stored.update(changes)
|
||||||
|
try:
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE_FILE.write_text(json.dumps(stored), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass # a check that cannot be written down still happened
|
||||||
|
return stored
|
||||||
|
|
||||||
|
|
||||||
|
def due(now=0):
|
||||||
|
"""Whether a day has gone by since the last time anybody asked."""
|
||||||
|
return (now or time.time()) - float(state().get("checked") or 0) >= INTERVAL
|
||||||
|
|
||||||
|
|
||||||
|
def latest(refresh=False):
|
||||||
|
"""The newest published release, asked for outright. Raises HubError."""
|
||||||
|
tag, url, published = hub.newest_release(REPO, refresh=refresh)
|
||||||
|
return Release(tag.lstrip("vV"), url or RELEASES_PAGE, published)
|
||||||
|
|
||||||
|
|
||||||
|
def remember(release):
|
||||||
|
"""Write down that a check has just happened, and what it found."""
|
||||||
|
_store(checked=time.time(), version=release.version, url=release.url,
|
||||||
|
published=release.published)
|
||||||
|
|
||||||
|
|
||||||
|
def pending():
|
||||||
|
"""The newer release the last check found, without asking anybody.
|
||||||
|
|
||||||
|
What the tray icon is built from: the answer has to be there the moment it
|
||||||
|
appears, and a request on the way to the screen is a request nobody has
|
||||||
|
time for.
|
||||||
|
"""
|
||||||
|
stored = state()
|
||||||
|
version = stored.get("version") or ""
|
||||||
|
if not newer(version):
|
||||||
|
return None
|
||||||
|
return Release(version, stored.get("url") or RELEASES_PAGE,
|
||||||
|
stored.get("published") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def check(force=False):
|
||||||
|
"""A newer release, or None when there is nothing to say.
|
||||||
|
|
||||||
|
The scheduled half: it asks only when a day has gone by, and answers from
|
||||||
|
what the last check found in between. `force` is the button in Settings and
|
||||||
|
the command line, which ask whatever the clock says.
|
||||||
|
|
||||||
|
The clock here is the only throttle. Once it has decided to ask, it asks
|
||||||
|
for real rather than reading hub.py's few hours of cache, which is there to
|
||||||
|
keep a settings window from fetching the same model list twice in an
|
||||||
|
evening and would only ever answer this with something it already knew.
|
||||||
|
"""
|
||||||
|
if not force and not due():
|
||||||
|
return pending()
|
||||||
|
release = latest(refresh=True)
|
||||||
|
remember(release)
|
||||||
|
return release if newer(release.version) else None
|
||||||
|
|
||||||
|
|
||||||
|
def announced():
|
||||||
|
"""The version somebody has already been shown a notification about."""
|
||||||
|
return state().get("announced") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def mark_announced(version):
|
||||||
|
"""Said once. A daily check must not be a daily interruption."""
|
||||||
|
_store(announced=version)
|
||||||
+28
-7
@@ -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,6 +46,13 @@ 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):
|
||||||
@@ -53,17 +61,30 @@ class Pipeline(QObject):
|
|||||||
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
|
def run(self, wav_path, duration, rms_values=(), ask=False, paste=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:
|
|
||||||
|
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))
|
||||||
|
if self._draining:
|
||||||
return
|
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.
|
||||||
|
|
||||||
|
|||||||
+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")
|
||||||
|
|||||||
+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."""
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from dikte import ipc
|
|||||||
from dikte import overlay as overlay_module
|
from dikte import overlay as overlay_module
|
||||||
from dikte import paste
|
from dikte import paste
|
||||||
from dikte import settings_ui
|
from dikte import settings_ui
|
||||||
|
from dikte import update
|
||||||
from tests.support import DikteTest, only_these_tools
|
from tests.support import DikteTest, only_these_tools
|
||||||
|
|
||||||
# One application for the whole run; Qt allows no second one.
|
# One application for the whole run; Qt allows no second one.
|
||||||
@@ -103,6 +104,7 @@ CHANGED = {
|
|||||||
"pause_shortcut": "Meta+P",
|
"pause_shortcut": "Meta+P",
|
||||||
"evdev_hotkey": True,
|
"evdev_hotkey": True,
|
||||||
"history_limit": 50,
|
"history_limit": 50,
|
||||||
|
"update_check": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -252,6 +254,31 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(shown, [provider])
|
self.assertEqual(shown, [provider])
|
||||||
self.assertFalse(box.isHidden())
|
self.assertFalse(box.isHidden())
|
||||||
|
|
||||||
|
def test_the_update_line_names_the_version_that_is_running(self):
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
self.assertIn(settings_ui.__version__, window.update_status.text())
|
||||||
|
# Nothing to open until a check has found something to open.
|
||||||
|
self.assertTrue(window.update_page.isHidden())
|
||||||
|
|
||||||
|
def test_a_newer_release_puts_the_page_button_on_screen(self):
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
told = []
|
||||||
|
window.update_found.connect(told.append)
|
||||||
|
release = update.Release("9.9.9", "https://example.invalid/9.9.9", "")
|
||||||
|
window._on_update_checked(release, "")
|
||||||
|
self.assertIn("9.9.9", window.update_status.text())
|
||||||
|
self.assertFalse(window.update_page.isHidden())
|
||||||
|
self.assertEqual(window._release_url, release.url)
|
||||||
|
# And the tray hears about it from here rather than waiting a day.
|
||||||
|
self.assertEqual(told, [release])
|
||||||
|
|
||||||
|
def test_a_check_that_failed_says_why_and_hands_the_button_back(self):
|
||||||
|
window = self.window(cfg.Config())
|
||||||
|
window.update_now.setEnabled(False)
|
||||||
|
window._on_update_checked(None, "api.github.com answered HTTP 403.")
|
||||||
|
self.assertIn("403", window.update_status.text())
|
||||||
|
self.assertTrue(window.update_now.isEnabled())
|
||||||
|
|
||||||
def test_the_settings_the_window_does_not_show_are_left_alone(self):
|
def test_the_settings_the_window_does_not_show_are_left_alone(self):
|
||||||
"""A tab nobody wrote must not reset what the command line set."""
|
"""A tab nobody wrote must not reset what the command line set."""
|
||||||
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
|
||||||
|
|||||||
@@ -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)
|
||||||
+35
-6
@@ -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
|
||||||
|
|
||||||
@@ -278,13 +279,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