Merge master into the live language switch branch

This commit is contained in:
2026-08-27 15:10:57 +03:00
51 changed files with 4250 additions and 273 deletions
+114
View File
@@ -0,0 +1,114 @@
name: build
# The three downloads, in one place. release.yml calls this one rather than
# holding a copy of it, and a pull request that touches the packaging runs it
# on its own, because the alternative is finding out that a build is broken
# from the release that was supposed to publish it.
on:
workflow_call:
inputs:
ref:
description: what to check out; the caller's own ref when empty
type: string
required: false
default: ""
version:
description: write this version into the tree before building
type: string
required: false
default: ""
pull_request:
paths:
- packaging/**
- .github/workflows/build.yml
- dikte/integrate.py
- dikte/trayicon.py
workflow_dispatch:
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
# The oldest Ubuntu still offered, because the glibc a build links
# against is the oldest one it will run on, and 22.04's covers every
# distribution released since. Move it up only when it goes away.
- os: ubuntu-22.04
kind: appimage
- os: macos-latest
kind: dmg
# Intel Macs. This runner is the last x86_64 image Actions will
# offer, and it goes away in August 2027.
- os: macos-15-intel
kind: dmg
# x64 only, which is what the PyQt6 wheel and whisper.cpp both
# publish for Windows; a machine on ARM runs the result emulated,
# the same way it runs everything else that was never built for it.
- os: windows-latest
kind: windows
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# PyQt6 ships Qt itself, but Qt still loads these from the system, and
# the build draws its own icon before it packages anything.
- name: Install the Qt runtime libraries
if: runner.os == 'Linux'
timeout-minutes: 5
run: |
apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
sudo apt-get $apt update
sudo apt-get $apt install --no-install-recommends -y \
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
libfontconfig1 libfreetype6 libgssapi-krb5-2
- name: Install PyQt6 and PyInstaller
run: python -m pip install --quiet PyQt6 pyinstaller
# Only for the builds off master: a tagged build already says the number
# it was tagged with, and rewriting it would be rewriting the tag.
- name: Write the version being built
if: inputs.version
# Named, because the Windows runner's own shell is PowerShell and this
# is a here-document.
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
python - <<'PY'
import os, pathlib, re
path = pathlib.Path("dikte/__init__.py")
path.write_text(re.sub(r'^__version__ = ".*"$',
f'__version__ = "{os.environ["VERSION"]}"',
path.read_text(), flags=re.M))
PY
- name: Build
if: runner.os != 'Windows'
run: ./packaging/build-${{ matrix.kind }}.sh
# The same steps as the other two, in the language the platform already
# has: drawing the icon, PyInstaller, and wrapping the result in what
# people download.
- name: Build
if: runner.os == 'Windows'
shell: pwsh
run: ./packaging/build-${{ matrix.kind }}.ps1
- uses: actions/upload-artifact@v4
with:
name: dikte-${{ matrix.os }}
path: dist/*
if-no-files-found: error
+17 -63
View File
@@ -46,9 +46,11 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
- timeout-minutes: 5
run: |
apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
sudo apt-get $apt update
sudo apt-get $apt install --no-install-recommends -y \
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
libfontconfig1 libfreetype6 libgssapi-krb5-2
- run: python -m pip install --quiet PyQt6
@@ -107,70 +109,16 @@ jobs:
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
# The builds themselves are build.yml, which a pull request touching the
# packaging also runs on its own. One definition, so the download somebody
# gets from a release and the one a pull request was checked against cannot
# come out of two different sets of steps.
build:
needs: version
strategy:
fail-fast: false
matrix:
include:
# The oldest Ubuntu still offered, because the glibc a build links
# against is the oldest one it will run on, and 22.04's covers every
# distribution released since. Move it up only when it goes away.
- os: ubuntu-22.04
kind: appimage
- os: macos-latest
kind: dmg
# Intel Macs. This runner is the last x86_64 image Actions will
# offer, and it goes away in August 2027.
- os: macos-15-intel
kind: dmg
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
uses: ./.github/workflows/build.yml
with:
ref: ${{ needs.version.outputs.ref }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# PyQt6 ships Qt itself, but Qt still loads these from the system, and
# the build draws its own icon before it packages anything.
- name: Install the Qt runtime libraries
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
libfontconfig1 libfreetype6 libgssapi-krb5-2
- name: Install PyQt6 and PyInstaller
run: python -m pip install --quiet PyQt6 pyinstaller
# Only for the builds off master: a tagged build already says the number
# it was tagged with, and rewriting it would be rewriting the tag.
- name: Write the version being built
if: needs.version.outputs.prerelease == 'true'
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
python - <<'PY'
import os, pathlib, re
path = pathlib.Path("dikte/__init__.py")
path.write_text(re.sub(r'^__version__ = ".*"$',
f'__version__ = "{os.environ["VERSION"]}"',
path.read_text(), flags=re.M))
PY
- name: Build
run: ./packaging/build-${{ matrix.kind }}.sh
- uses: actions/upload-artifact@v4
with:
name: dikte-${{ matrix.os }}
path: dist/*
if-no-files-found: error
version: ${{ needs.version.outputs.prerelease == 'true' && needs.version.outputs.version || '' }}
publish:
needs: [version, build]
@@ -207,6 +155,12 @@ jobs:
Accessibility permissions, which macOS asks for the first time each is
used. It asks again after an update, because an application signed with
no certificate is one macOS has never seen before.
Windows: run the setup, which installs for your account alone and asks
for no administrator. It carries the ffmpeg recording needs and adds a
Start Menu entry, a `dikte` command and, unless you untick it, a start
at sign-in. It is not signed either, so SmartScreen offers only "Don't
run" until you press More info. Add/Remove Programs uninstalls it.
EOF
)
+53 -2
View File
@@ -24,10 +24,18 @@ jobs:
# for the offscreen platform the tests run on. QtNetwork wants the Kerberos
# library, and the widgets want fontconfig, whether or not anything is
# ever drawn.
#
# A mirror that stops answering is what the retries and the timeout are
# for: apt waits on it by default, and a job waiting on apt sits there
# until the six hour limit rather than going red in a minute. The same
# two lines are in build.yml and release.yml, which install the same
# eight libraries.
- name: Install the Qt runtime libraries
timeout-minutes: 5
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
sudo apt-get $apt update
sudo apt-get $apt install --no-install-recommends -y \
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
libfontconfig1 libfreetype6 libgssapi-krb5-2
@@ -77,3 +85,46 @@ jobs:
bash -n scripts/release.sh
bash -n packaging/build-appimage.sh
bash -n packaging/build-dmg.sh
# The same job again for the same reason. The Windows backends are faked at
# the one function that loads user32 and kernel32, so every line of them is
# already read on the Linux above; what only this job can catch is the half
# that reads the real system. %APPDATA% and %LOCALAPPDATA% have to be the
# directories Windows actually hands out, a path spelled with a backslash has
# to be one the tests can still read, and the config-permission test has to
# skip rather than fail on a file system that decides by ACL.
windows:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
python: ["3.11", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
# No apt step: PyQt6's wheel carries the Qt DLLs it needs on Windows.
- name: Install PyQt6
run: python -m pip install --quiet PyQt6
- name: Run the tests
run: python -m unittest discover --verbose
# What the Mac does for its installer, in the language this one is in.
# Parsing only: install.ps1 writes into the Start Menu and the user PATH,
# and build-windows.ps1 downloads an ffmpeg and runs PyInstaller. The
# setup program itself is compiled by build.yml, on the pull requests
# that touch it.
- name: Check the installer and the build script parse
shell: pwsh
run: |
foreach ($script in "install.ps1", "packaging/build-windows.ps1") {
$problems = $null
[System.Management.Automation.Language.Parser]::ParseFile(
"$PWD/$script", [ref]$null, [ref]$problems) > $null
if ($problems) { $problems; exit 1 }
}
+29 -15
View File
@@ -53,19 +53,28 @@ forgets fails rather than hangs.
## Another platform
Three systems are supported: Wayland, X11 and macOS. Each one is a named entry
in a table, and one chooser picks between them, so a fourth adds an entry and a
line rather than a branch inside every function. The three tables are
`paste.Desktop` (clipboard and key press), `audio.Sound` (capture and the device
lists) and the `_macos()`/`_gnome()` pair in `hotkey.py`. Keep `sys.platform`
Four systems are supported: Wayland, X11, macOS and Windows. Each one is a named
entry in a table, and one chooser picks between them, so a fifth adds an entry
and a line rather than a branch inside every function. The tables are
`paste.Desktop` (clipboard and key press), `audio.Sound` (capture, the device
lists, and whether the far side of a meeting can be recorded at all),
`paths.directories()` (where the settings and the data live) and `hotkey.backend()`,
which names the one mechanism a session has for holding a key. Keep `sys.platform`
inside the chooser and read it there every time: a constant settled at import is
one no test can stand somewhere else.
Where a platform cannot do something, say so in its table entry rather than in
the code that asks. `audio.Sound.meetings` is the shape of it: Windows offers no
capture device for what the speakers are playing, and a caller reading a False
there can tell that apart from an empty device list, which only means the tool
that lists them is not installed.
The tests are split along the same line, and almost none of them are skipped.
892 of the 935 run on any machine, including every line of the Wayland, X11 and
macOS backends: the programs are faked at `shutil.which`, the frameworks at the
one function that loads them. A test class says which system it is standing on
rather than avoiding the question:
1096 of the 1159 run on any machine, including every line of the Wayland, X11,
macOS and Windows backends: the programs are faked at `shutil.which`, the
frameworks and system libraries at the one function that loads them
(`paste._win_api`, `hotkey._win_input`). A test class says which system it is
standing on rather than avoiding the question:
```python
class MacOS(ClipboardContract, DikteTest):
@@ -73,14 +82,19 @@ class MacOS(ClipboardContract, DikteTest):
here = paste.MACOS
```
so the Linux half is checked on a Mac and the macOS half on Linux, and a change
to a chooser cannot quietly break the platform nobody is sitting at. What the
systems owe in common is written once as a contract class and subclassed by each
of them.
so the Linux half is checked on a Mac, the macOS half on Linux and the Windows
half on both, and a change to a chooser cannot quietly break the platform nobody
is sitting at. What the systems owe in common is written once as a contract
class and subclassed by each of them.
The 43 that do carry `@linux_only` are the ones that would need the real thing:
the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. Mark a test
that way only when faking it would leave nothing to test. A test that quietly
the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. The 20 with
`@posix_only` are the half of `integrate.py` that writes files, the menu entry
and the login item a downloaded build puts down for itself, which want a home
directory laid out the way those two systems lay one out. Its Windows half is
one registry value, since the setup program there did the rest, and the three
functions that read and write it are faked like anything else. Mark a test
either way only when faking it would leave nothing to test. A test that quietly
stops running on the platform you are porting to protects nothing.
## What a pull request should carry
+32 -16
View File
@@ -5,10 +5,10 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the
missing punctuation), and the result lands in your clipboard and is pasted into
whatever window you were typing in.
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS and any other
Linux desktop that will let it read the keyboard. No
dependencies beyond system packages: just the Python standard library, 3.11 or
newer, and PyQt6.
Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS,
[Windows](README.windows.md) and any other Linux desktop that will let it read
the keyboard. No dependencies beyond system packages: just the Python standard
library, 3.11 or newer, and PyQt6.
*[Türkçe README](README.tr.md)*
@@ -24,15 +24,17 @@ newer, and PyQt6.
## Install
The [releases page](../../releases) has an AppImage and a disk image per Mac
architecture. Both write their own menu entry, login item and `dikte` command
the first time they run, and stand aside for an installation already on the
machine; `dikte integrate --remove` takes them back. The AppImage still wants
the system packages below, for the sound server, the clipboard and the
keyboard. The disk image is signed with no Apple certificate, so the first
launch is refused until you press **Open Anyway** under System Settings →
Privacy & Security, and macOS asks for the microphone and Accessibility again
after each update; installing from a checkout is what avoids that.
The [releases page](../../releases) has an AppImage, a disk image per Mac
architecture and a Windows setup. The first two write their own menu entry,
login item and `dikte` command the first time they run, and stand aside for an
installation already on the machine; `dikte integrate --remove` takes them
back. The AppImage still wants the system packages below, for the sound
server, the clipboard and the keyboard. The disk image is signed with no Apple
certificate, so the first launch is refused until you press **Open Anyway**
under System Settings → Privacy & Security, and macOS asks for the microphone
and Accessibility again after each update; installing from a checkout is what
avoids that. The Windows setup installs for your account alone and carries an
ffmpeg with it.
```sh
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
@@ -93,11 +95,22 @@ Dikte talks to. Build it (`cmake -B build -DWHISPER_BUILD_SERVER=ON
transcribe in the cloud. A meeting needs BlackHole or Loopback
(`brew install blackhole-2ch`); dictation does not.
Windows works the same way, holding the keys through the system's own hotkey
service while Dikte runs. The setup on the releases page carries the ffmpeg
recording needs and asks for no administrator; from a checkout it is `winget
install Gyan.FFmpeg`, `pip install PyQt6`, then `python -m dikte`, with an
optional `install.ps1` for the Start Menu entry and the `dikte` command.
Meetings are not supported there yet; the details are in the
[Windows README](README.windows.md).
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
two global shortcuts, whose keys are its two arguments, or the ones already in
your settings when it is given none. `./scripts/update.sh` pulls and puts all of
that back; `./scripts/uninstall.sh` takes it away again and leaves your settings
and dictations alone unless you pass `--purge`.
and dictations alone unless you pass `--purge`. Dikte looks at the releases page
once a day and puts a line in the tray menu when a newer version is out, which
opens the page rather than installing anything; the General tab turns that off
or runs it on the spot.
Speech to text and cleanup each pick a provider in the settings window, and both
run here by default, on models of your own. The cloud is the other option:
@@ -120,6 +133,7 @@ set next to it.
| Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` |
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
| Settings | Tray menu → *Settings*, or `dikte settings` |
| Look for a newer version | General tab → *Check now*, or `dikte update` |
| Reload after an update | Tray menu → *Restart*, or `dikte restart` |
| Quit | Tray menu → *Quit*, or `dikte quit` |
@@ -215,8 +229,9 @@ keys.
Everything below is in the `dikte` package, which is what `python3 -m dikte`
runs and what the `__main__.py` in it hands to every launcher and shortcut.
`scripts/` holds install-mac.sh, update.sh, uninstall.sh and release.sh;
`packaging/` builds the AppImage and the disk image that release.sh's tag
publishes; install.sh stays at the top, and `tests/` has a file per module.
`packaging/` builds the AppImage, the disk image and the Windows setup that
release.sh's tag publishes; install.sh stays at the top, and `tests/` has a
file per module.
```
app.py entry point, tray icon, state machine
@@ -229,6 +244,7 @@ api.py transcription and cleanup requests (stdlib only)
cleanup.py who rewrites the transcript: OpenRouter, here, Claude or Codex
ggml.py whisper.cpp and llama.cpp here: fetch, verify, keep serving
hub.py what GitHub and Hugging Face have on offer today
update.py whether a newer release is out, and the page it is on
worker.py transcribe → clean up → clipboard → paste
vad.py deciding whether a recording holds speech at all
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
+29 -17
View File
@@ -4,10 +4,10 @@
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır.
KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta ve klavyeyi
okumasına izin veren diğer Linux masaüstlerinde de çalışır. Sistem
paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya
üstü) ve PyQt6.
KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta,
[Windows](README.windows.md)'ta ve klavyeyi okumasına izin veren diğer Linux
masaüstlerinde de çalışır. Sistem paketleri dışında bağımlılığı yok: sadece
Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
*[English README](README.md)*
@@ -23,15 +23,16 @@ paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi
## Kurulum
[Sürümler sayfasında](../../releases) bir AppImage, bir de her Mac mimarisi
için birer disk imajı var. İkisi de ilk çalıştıklarında kendi menü girdisini,
oturum açılışını ve `dikte` komutunu yazar, makinede zaten duran bir kuruluma
dokunmazlar; `dikte integrate --remove` yazdıklarını geri alır. AppImage yine
de aşağıdaki sistem paketlerini ister: ses sunucusu, pano ve klavye onlardan
gelir. Disk imajı bir Apple sertifikasıyla imzalı değil, bu yüzden ilk açılış
reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik altından **Yine de Aç**
demek gerekir; macOS her güncellemeden sonra mikrofonu ve Erişilebilirliği
yeniden sorar, checkout'tan kurmak bundan kurtarır.
[Sürümler sayfasında](../../releases) bir AppImage, her Mac mimarisi için
birer disk imajı, bir de Windows kurulumu var. İlk ikisi ilk çalıştıklarında
kendi menü girdisini, oturum açılışını ve `dikte` komutunu yazar, makinede
zaten duran bir kuruluma dokunmazlar; `dikte integrate --remove` yazdıklarını
geri alır. AppImage yine de aşağıdaki sistem paketlerini ister: ses sunucusu,
pano ve klavye onlardan gelir. Disk imajı bir Apple sertifikasıyla imzalı
değil, bu yüzden ilk açılış reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik
altından **Yine de Aç** demek gerekir; macOS her güncellemeden sonra mikrofonu
ve Erişilebilirliği yeniden sorar, checkout'tan kurmak bundan kurtarır.
Windows kurulumu yalnızca kendi hesabına kurar ve yanında bir ffmpeg taşır.
```sh
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
@@ -92,11 +93,20 @@ build -j`) ve yolunu Ayarlar → API'ye yaz, ya da buluta çevir. Toplantı içi
BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için
gerekmiyor.
Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi
kısayol servisi üzerinden tutuyor. Sürümler sayfasındaki kurulum kaydın
istediği ffmpeg'i de taşıyor ve yönetici istemiyor; checkout'tan ise `winget
install Gyan.FFmpeg`, `pip install PyQt6`, sonra `python -m dikte`, Başlat
Menüsü girdisi ve `dikte` komutu için de isteğe bağlı `install.ps1`. Orada
toplantı kaydı henüz yok, ayrıntılar [Windows README](README.windows.md)'sinde.
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman
verilmezse ayarlarında duranlar. `./scripts/update.sh` son sürümü çeker ve
bunları yerine koyar; `./scripts/uninstall.sh` hepsini geri alır, `--purge`
demedikçe ayarlarına ve diktelerine dokunmaz.
demedikçe ayarlarına ve diktelerine dokunmaz. Dikte sürüm sayfasına günde bir
kez bakar ve yeni sürüm çıkmışsa tepsi menüsüne bir satır koyar; o satır bir şey
kurmaz, sayfayı açar. Genel sekmesi bu denetimi kapatır ya da anında çalıştırır.
Sesi yazıya çevirme ve temizleme, ayarlar penceresinde ayrı ayrı sağlayıcı
seçer; ikisi de varsayılan olarak burada, kendi modellerinle çalışır. Bulutu
@@ -119,6 +129,7 @@ yanındaki kutudan düşünme seviyesini de seçebilirsin.
| Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
| Yeni sürüm var mı bak | Genel sekmesi → *Şimdi bak*, ya da `dikte update` |
| Güncelleme sonrası yeniden yükle | Tepsi menüsü → *Yeniden başlat*, ya da `dikte restart` |
| Çık | Tepsi menüsü → *Çık*, ya da `dikte quit` |
@@ -211,9 +222,9 @@ Kısayollar sekmesi bağlanacak komutu gösterir.
Aşağıdakilerin hepsi `dikte` paketinin içinde: `python3 -m dikte` bunu çalıştırır,
içindeki `__main__.py` de her başlatıcının ve kısayolun adlandırdığı dosyadır.
`scripts/` altında install-mac.sh, update.sh, uninstall.sh ve release.sh var;
`packaging/` release.sh'ın attığı etiketin yayımladığı AppImage ile disk
imajını derler; install.sh en üstte kalır, `tests/` içinde de her modülün bir
dosyası.
`packaging/` release.sh'ın attığı etiketin yayımladığı AppImage'i, disk imajını
ve Windows kurulumunu derler; install.sh en üstte kalır, `tests/` içinde de her
modülün bir dosyası.
```
app.py giriş noktası, tepsi simgesi, durum makinesi
@@ -226,6 +237,7 @@ api.py transkript ve temizleme istekleri (yalnız stdlib)
cleanup.py transkripti kim temizler: OpenRouter, burası, Claude ya da Codex
ggml.py whisper.cpp ve llama.cpp'yi indirip burada çalıştırma
hub.py GitHub ve Hugging Face'te bugün ne olduğu
update.py yeni sürüm çıkmış mı, çıkmışsa hangi sayfada
worker.py transkript → temizleme → pano → yapıştırma
vad.py kayıtta gerçekten konuşma var mı kararı
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
+90
View File
@@ -0,0 +1,90 @@
# Dikte on Windows
Press `Ctrl+Space`, talk, press again: what you said is transcribed, cleaned
up and pasted where your cursor is.
## Requirements
Windows 10 or 11. The setup on the [releases page](../../releases) carries
everything else with it, and is x64, which an ARM machine runs emulated the way
it runs whisper.cpp. A checkout wants:
- **Python 3.11+** with **PyQt6** (`pip install PyQt6`; install.ps1 installs
it when it is missing)
- **ffmpeg** for microphone capture: `winget install Gyan.FFmpeg`
## Installing
`Dikte-<version>-x64-setup.exe` from the releases page installs for your
account alone, so no administrator is asked for, and puts down a Start Menu
entry, a `dikte` command and, unless you untick it, a start at sign-in. It is
signed with no certificate, so SmartScreen offers only **Don't run** until you
press **More info**. Add/Remove Programs uninstalls it, and `dikte integrate`
and `dikte integrate --remove` are the sign-in entry on its own, for changing
your mind about that later. The `dikte` command is the same file install.ps1
writes, so over a checkout the setup takes it over and uninstalling takes it
away; run install.ps1 again to get the checkout's back.
From a checkout instead:
```powershell
powershell -ExecutionPolicy Bypass -File install.ps1
```
This adds a **Dikte** entry to the Start Menu and a **`dikte`** command to the
terminal. Add `-Autostart` to also start it at sign-in; `-Uninstall` removes
all of it and leaves the repository and your settings alone.
To try it without installing anything:
```sh
python -m dikte
```
## First run
1. The tray icon appears and the Settings window opens.
2. Under **API and models**, download a local whisper model (the whisper.cpp
Windows build is fetched automatically) or enter an OpenAI, Groq or
OpenRouter key.
3. The shortcut defaults to `Ctrl+Space` and is changed under Shortcuts.
While Dikte runs, Windows' own hotkey service (RegisterHotKey) listens for
it: nothing to install and no permission to grant.
## What is different from Linux and macOS
- **Meeting recording (microphone + speakers) is not supported yet.** Windows
does not offer what the speakers are playing as a capture device, so there
is nothing to record the far side from. Everything else works, including
transcribing audio and video files.
- **The shortcut is swallowed**: while Dikte holds `Ctrl+Space`, the focused
application does not see it. This is how macOS behaves too, and unlike the
Linux listener, which shares the key.
- No external tools for the clipboard or the key press: both go straight
through the Windows API (the clipboard, SendInput).
- Settings live under `%APPDATA%\Dikte`, models and recordings under
`%LOCALAPPDATA%\Dikte`.
## Performance
- The local install fetches whisper.cpp's **OpenBLAS build**, which
transcribes about twice as fast as the stock one on a plain CPU. There is
no GPU build to fetch for machines without an NVIDIA card, and none for
Windows on ARM either: whisper.cpp publishes x64 only, so a Snapdragon
machine runs it under emulation and the cloud is the faster option there.
- Setting Settings → API and models → **Threads** near your physical core
count helps noticeably; the server's own default is 4.
- If speed matters more than accuracy, `ggml-small` and `ggml-base` are much
faster; `ggml-large-v3-turbo-q5_0` transcribes best.
## Troubleshooting
- **Recording does not start:** does `dikte doctor` find ffmpeg, and does
`dikte devices` list your microphone? `devices` also takes a fresh listing,
which is what to run after plugging one in.
- **Nothing is pasted:** a normal-privilege process cannot type into an
elevated (administrator) window; run Dikte elevated too, or paste by hand.
The text lands on the clipboard either way.
- **The shortcut does nothing:** another application already holds the
combination. Dikte says so in a tray notification when it asks for the key;
pick a different one under Settings → Shortcuts.
+1 -1
View File
@@ -10,4 +10,4 @@ business loading Qt to answer one question.
# both the .dmg's Info.plist and the AppImage's file name are built from it. A
# build off master rather than off a tag appends the commit to it, so that a
# bug report from someone running "latest" names a commit.
__version__ = "1.0.0"
__version__ = "1.0.2"
+30 -5
View File
@@ -18,6 +18,7 @@ import mimetypes
import os
import secrets
import socket
import sys
import threading
import urllib.error
import urllib.request
@@ -57,10 +58,20 @@ def timestamp_model(provider, selected=""):
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
# What a gateway in front of the model answers of its own accord: the request
# never reached the model, or the model was still working when the connection
# was given up on. Trying again is the only thing that fixes any of them, and
# with a long file it is worth the second try rather than losing the run.
RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
class ApiError(Exception):
def __init__(self, message, status=None):
def __init__(self, message, status=None, retryable=None):
super().__init__(message)
self.status = status
# Anything not on that list is the request itself being wrong, and it
# will be just as wrong the second time.
self.retryable = status in RETRY_STATUS if retryable is None else retryable
class Aborted(Exception):
@@ -148,6 +159,12 @@ def _stop_using(conn):
if sock is not None:
with contextlib.suppress(OSError):
sock.shutdown(socket.SHUT_RDWR)
if sys.platform == "win32":
# On Windows the shutdown leaves a blocked recv exactly where it
# was; only closing the OS handle ends it, and close() on the
# object would wait for the blocked reader to let go of it first.
with contextlib.suppress(OSError):
socket.close(sock.detach())
with contextlib.suppress(OSError):
conn.close()
@@ -211,7 +228,7 @@ def explain(exc, service):
if exc.status == 429:
return ApiError(t("{service} is rate limiting you (HTTP 429). Try again in "
"a moment.", service=service), exc.status)
return ApiError(f"{service}: {exc}", exc.status)
return ApiError(f"{service}: {exc}", exc.status, retryable=exc.retryable)
def _request(url, data, headers, timeout=120, aborter=None):
@@ -227,8 +244,11 @@ def _request(url, data, headers, timeout=120, aborter=None):
# not the network failing. URLError is an OSError, so both land here.
if aborter is not None and aborter.aborted:
raise Aborted from None
# A connection that dropped or timed out is the same bad minute as a
# 502, so it is worth the same second try.
raise ApiError(t("Could not connect: {reason}",
reason=getattr(exc, "reason", exc))) from exc
reason=getattr(exc, "reason", exc)),
retryable=True) from exc
except json.JSONDecodeError as exc:
raise ApiError(t("Could not parse the response: {error}", error=exc)) from exc
@@ -258,7 +278,12 @@ def _multipart(fields, file_field, file_path):
out += str(value).encode("utf-8") + b"\r\n"
filename = os.path.basename(file_path)
ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
# The two types a dictation actually sends are pinned: on Windows,
# guess_type answers from the registry and differs machine to machine.
known = {".wav": "audio/x-wav", ".mp3": "audio/mpeg"}
extension = os.path.splitext(filename)[1].lower()
ctype = (known.get(extension) or mimetypes.guess_type(filename)[0]
or "application/octet-stream")
with open(file_path, "rb") as fh:
payload = fh.read()
out += f"--{boundary}\r\n".encode()
@@ -306,7 +331,7 @@ def local_failure(service, server, exc):
"""
detail = server.error()
return ApiError(f"{service}: {exc}" + (f" ({detail})" if detail else ""),
exc.status)
exc.status, retryable=exc.retryable)
def _transcribe_request(target, audio_path, language, prompt, response_format,
+234 -5
View File
@@ -15,8 +15,10 @@ import json
import os
import signal
import socket
import subprocess
import sys
import threading
import time
# A Wayland client cannot place a window in a screen corner, so the indicator
# is drawn through XWayland.
@@ -32,8 +34,9 @@ if sys.platform == "darwin":
os.environ.get("PATH", "")) if part
)
from PyQt6.QtCore import QTimer, QElapsedTimer, QSocketNotifier # noqa: E402
from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtCore import (QObject, QTimer, QElapsedTimer, QSocketNotifier, # noqa: E402
QUrl, pyqtSignal)
from PyQt6.QtGui import QAction, QDesktopServices, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
@@ -43,11 +46,14 @@ from . import cli # noqa: E402
from . import config as cfg # noqa: E402
from . import ggml # noqa: E402
from . import hotkey # noqa: E402
from . import hub # noqa: E402
from . import i18n # noqa: E402
from . import integrate # noqa: E402
from . import ipc # noqa: E402
from . import mac_window # noqa: E402
from . import meeting # noqa: E402
from . import trayicon # noqa: E402
from . import update # noqa: E402
from .i18n import t # noqa: E402
from .meeting import MeetingPipeline # noqa: E402
from .overlay import Overlay # noqa: E402
@@ -76,6 +82,37 @@ ECHO_MS = 2000
# short enough not to sit in the corner for the rest of the hour.
PEEK_MS = 12000
# When the releases page is looked at, and how often it is thought about after
# that. The delay is there so that a check never shares the first seconds of a
# start with the model being loaded and the desktop drawing the tray; the
# interval is not the interval between checks, which update.py holds at a day,
# but how often that clock is read, so that a machine left running for a week
# still asks once a day rather than once a boot.
UPDATE_DELAY_MS = 20000
UPDATE_POLL_MS = 3 * 3600 * 1000
class UpdateCheck(QObject):
"""One look at the releases page, off the interface thread.
An object of its own because the application is not one: a plain thread
cannot touch a widget, and a signal is the only way back onto the thread
that may.
"""
# The newer release, or None when there is nothing to say, and the reason
# nothing could be found out instead.
done = pyqtSignal(object, str)
def start(self):
def work():
try:
self.done.emit(update.check(), "")
except hub.HubError as exc:
self.done.emit(None, str(exc))
threading.Thread(target=work, daemon=True).start()
class Dikte:
def __init__(self, app):
@@ -97,6 +134,9 @@ class Dikte:
self.meeting_base = ""
self.meeting_message = ""
self.settings_window = None
# The single-instance server, handed over once run_app has opened it, so
# that a restart can stop answering before the replacement starts.
self.server = None
self._quitting = False
# A request that asked to be told how its run ended waits in here until
# the run gets there, keyed by which of the three it was waiting on.
@@ -107,6 +147,12 @@ class Dikte:
# Which recording is the current one, so a timer set for the run that
# started it cannot stop the one that came after.
self._run_id = 0
# The application that was in front when the recording started, which
# is where the transcript is meant to go, and the timer watching for
# the moment it has to be put back there. macOS only; see
# _give_the_front_back.
self.front_before = None
self._front_watch = None
self.overlay = Overlay(self.conf["overlay_corner"])
# The agent's indicator sits on top of the dictation one when both are
@@ -155,6 +201,17 @@ class Dikte:
self.meeting_ticker.setInterval(500)
self.meeting_ticker.timeout.connect(self._meeting_tick)
# What the last check found, read from disk rather than asked for, so
# that a tray built in the next line already knows to say so.
self.update_release = update.pending()
self.updates = UpdateCheck()
self.updates.done.connect(self._on_update_checked)
self.update_ticker = QTimer()
self.update_ticker.setInterval(UPDATE_POLL_MS)
self.update_ticker.timeout.connect(self._look_for_update)
self.update_ticker.start()
QTimer.singleShot(UPDATE_DELAY_MS, self._look_for_update)
self.tray = QSystemTrayIcon()
self._apply_settings()
self.tray.show()
@@ -207,6 +264,11 @@ class Dikte:
self.menu.addAction(self.meeting_cancel_action)
self.menu.addSeparator()
# Named in _refresh_update, and hidden until a check has found one.
self.update_action = QAction("", self.menu)
self.update_action.triggered.connect(self.open_release_page)
self.menu.addAction(self.update_action)
self.settings_action = QAction(t("Settings…"), self.menu)
self.settings_action.triggered.connect(self.open_settings)
self.menu.addAction(self.settings_action)
@@ -223,6 +285,7 @@ class Dikte:
self.tray.setContextMenu(self.menu)
self.tray.setToolTip(t("Dikte: ready"))
self.tray.activated.connect(self._tray_clicked)
self._refresh_update()
self._set_icon("audio-input-microphone")
def _tray_clicked(self, reason):
@@ -550,9 +613,19 @@ class Dikte:
self.last_toggle.restart()
return False
def _the_front(self):
"""The application a recording is about to start from, or None.
Asked before the indicator goes up rather than alongside the
microphone: putting a window on screen can take the front as well, and
once it has, the only answer left to the question is Dikte.
"""
return mac_window.frontmost_pid() if sys.platform == "darwin" else None
def start(self):
if self.state != IDLE or self.recording:
return
self.front_before = self._the_front()
self.overlay.show_recording()
self._begin_recording(DICTATION)
self._set_state(RECORDING)
@@ -560,6 +633,7 @@ class Dikte:
def start_ask(self):
if self.ask_state != IDLE or self.recording:
return
self.front_before = self._the_front()
self.ask_overlay.show_recording(asking=True)
self._begin_recording(ASK)
self._set_ask_state(RECORDING)
@@ -572,6 +646,80 @@ class Dikte:
self.elapsed.restart()
self.ticker.start()
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
self._give_the_front_back(self.front_before)
def _give_the_front_back(self, was_in_front):
"""Hand the front back to whoever had it when the recording started.
On macOS a recording goes through ffmpeg's avfoundation input, and
opening a capture session there brings the process that did it to the
front. ffmpeg is a child of Dikte with no bundle of its own, so the
system credits the move to Dikte: the window the user was typing in
loses the front, its caret stops, its title bar greys out, and the
Cmd+V at the end of the dictation has nowhere to land. Measured with a
TextEdit document in front:
press the shortcut front = TextEdit
recorder.start returns front = TextEdit
89 ms later front = Dikte
Nothing about the capture session can be asked not to do this. It is
not a window of ours and no flag reaches it. Starting ffmpeg in its own
session, and clearing __CFBundleIdentifier from its environment, were
both tried and both measured to make no difference. So it is undone
instead. The move lands a moment after the process starts rather than
during the call, hence the short watch rather than one attempt: it
gives up as soon as it has put the front back, and in any case after a
second and a half, which is longer than the microphone has ever taken
to open.
Silent off macOS, and silent when the recording started from Dikte
itself: there is nothing to give back.
"""
# One watch at a time. A second recording started before the first
# watch had finished would otherwise leave two of them running, and the
# older one would put the front back where the older recording
# started, which by then is the wrong window.
if self._front_watch is not None:
self._front_watch.stop()
self._front_watch = None
if not was_in_front or was_in_front == os.getpid():
return
deadline = time.monotonic() + 1.5
watch = QTimer(self.app)
# Ten milliseconds because the front is already gone by the time this
# notices, and every tick it waits is a tick of the user's window drawn
# inactive: at forty the title bar visibly blinks, at ten it does not.
# Two messages to AppKit per tick, for at most a second and a half.
watch.setInterval(10)
# activateWithOptions: answers whether macOS accepted the request, not
# whether the other application is already back in front. Keep the
# watch alive until that asynchronous handoff is observable; on Intel
# Macs it can take hundreds of milliseconds after the call returned.
restore_requested = False
def look():
nonlocal restore_requested
if time.monotonic() > deadline:
self._stop_watching_the_front()
return
if mac_window.is_frontmost():
if not restore_requested:
restore_requested = mac_window.activate(was_in_front)
elif restore_requested:
# The request has landed. Stop only now, rather than as soon
# as AppKit accepted it, so a delayed or failed handoff stays
# under observation until the deadline guard above.
self._stop_watching_the_front()
watch.timeout.connect(look)
self._front_watch = watch
watch.start()
def _stop_watching_the_front(self):
if self._front_watch is not None:
self._front_watch.stop()
self._front_watch = None
def stop(self):
if self.state != RECORDING:
@@ -687,6 +835,12 @@ class Dikte:
return
base = meeting.new_base()
_, wav_path = cfg.meeting_paths(base)
# A meeting opens the same capture as a dictation does, and takes the
# front the same way: whoever is being recorded is in a call, and
# having their window go inactive mid-sentence is worse here than
# anywhere else. Kept as a local rather than on self: a dictation may
# already be waiting on its own note for where to paste.
was_in_front = self._the_front()
self.meeting_recorder.start(
str(wav_path),
self.conf["meeting_mic_target"] or self.conf["mic_target"],
@@ -695,6 +849,7 @@ class Dikte:
)
if not self.meeting_recorder.active:
return # start() has already said what went wrong
self._give_the_front_back(was_in_front)
self.meeting_base = base
self.meeting_elapsed.restart()
self.meeting_ticker.start()
@@ -821,11 +976,13 @@ class Dikte:
def _on_recorded(self, wav_path, duration, rms_values):
owner, self.recorder_owner = self.recorder_owner, None
wants_paste = self.paste_override.pop(owner, None)
focus, self.front_before = self.front_before, None
if owner == ASK:
self.ask_pipeline.run(wav_path, duration, rms_values, ask=True,
paste=wants_paste)
paste=wants_paste, focus=focus)
else:
self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste)
self.pipeline.run(wav_path, duration, rms_values,
paste=wants_paste, focus=focus)
def _on_finished(self, _raw, text, warning):
if warning:
@@ -897,6 +1054,51 @@ class Dikte:
if len(message) > len(first_line):
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
# ---- updates ----------------------------------------------------------
def _look_for_update(self):
"""The timer. update.py decides whether this is a request or a memory."""
if not self.conf["update_check"]:
return
self.updates.start()
def _on_update_checked(self, release, error):
if error:
# Nobody asked for this, so nobody is waiting to be told it failed.
# A machine that is offline, or a GitHub that is rate-limiting the
# address, is not a thing to interrupt a dictation about.
print(f"dikte: update check: {error}", file=sys.stderr)
return
if release is None:
return
self._found_update(release)
# Once per version. A check that runs every day must not be a
# notification every day for an update somebody has decided to skip.
if update.announced() != release.version:
update.mark_announced(release.version)
self.tray.showMessage(
"Dikte",
t("Dikte {version} is out. The tray menu has the release page.",
version=release.version),
QSystemTrayIcon.MessageIcon.Information, 8000,
)
def _found_update(self, release):
self.update_release = release
self._refresh_update()
def _refresh_update(self):
release = self.update_release
self.update_action.setVisible(release is not None)
if release is not None:
self.update_action.setText(
t("Dikte {version} is out…", version=release.version))
def open_release_page(self):
release = self.update_release
QDesktopServices.openUrl(
QUrl(release.url if release is not None else update.RELEASES_PAGE))
# ---- settings ---------------------------------------------------------
def open_settings(self):
@@ -904,6 +1106,7 @@ class Dikte:
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)
self.settings_window.show()
self.settings_window.raise_()
@@ -992,8 +1195,29 @@ class Dikte:
if self.settings_window is not None:
self.settings_window.close()
self.shutdown()
# Stop answering before the replacement is started, not just afterwards.
# execv leaves nothing behind to answer, but a Windows restart is two
# processes for a moment, and removeServer does nothing about a name
# another process is holding. The new one then either opens a second
# server on a name the old one is still answering on, so that a command
# arriving in that moment reaches the process that is going away, or
# fails to open one at all and says so to a console nobody is watching.
# Closing first leaves neither.
if self.server is not None:
self.server.close()
QLocalServer.removeServer(SERVER_NAME)
args = ipc.launcher() + ["--gui"]
if sys.platform == "win32":
# execv on Windows mangles arguments with spaces and leaves the two
# processes sharing a console; a detached start does neither.
subprocess.Popen(
args,
creationflags=(subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP),
close_fds=True,
)
QApplication.instance().quit()
return
os.execv(args[0], args)
def shutdown(self):
@@ -1064,7 +1288,11 @@ def install_signal_handlers(app):
app.quit() # aboutToQuit runs shutdown()
notifier.activated.connect(woken)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
# SIGHUP does not exist on Windows, and neither does a session to hang up.
signals = [signal.SIGINT, signal.SIGTERM]
if hasattr(signal, "SIGHUP"):
signals.append(signal.SIGHUP)
for sig in signals:
# A handler that does nothing, so that the default action, stopping the
# process where it stands, is replaced by the wakeup above.
signal.signal(sig, lambda *_: None)
@@ -1154,6 +1382,7 @@ def run_app(args):
QLocalServer.removeServer(SERVER_NAME)
if not server.listen(SERVER_NAME):
print(f"dikte: could not open the IPC socket: {server.errorString()}")
dikte.server = server
def on_connection():
conn = server.nextPendingConnection()
+1
View File
@@ -378,6 +378,7 @@ def _stream(cmd, conf, on_event, should_stop):
cmd, cwd=working_dir(conf), stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", bufsize=1,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError as exc:
raise AssistantError(t("Could not run {binary}: {error}",
+174 -8
View File
@@ -33,6 +33,10 @@ from PyQt6.QtCore import QObject, pyqtSignal
from .i18n import t
# Console programs started from a windowless process would otherwise each open
# a console window of their own on Windows.
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
RATE = 16000
CHANNELS = 1
SAMPLE_WIDTH = 2 # s16
@@ -57,6 +61,19 @@ QUIET_MIC_SECONDS = 10
QUIET_MIC_SHARE = 0.5
def _interrupt(proc):
"""Ask a recorder process to end.
SIGINT is the polite way everywhere it exists; Windows has no equivalent a
child can be sent, so the process is terminated outright. The captured
audio is not lost either way: it has already been read from the pipe.
"""
if sys.platform == "win32":
proc.terminate()
else:
proc.send_signal(signal.SIGINT)
class Recorder(QObject):
"""Runs the available sound-server recorder and reads raw PCM from stdout."""
@@ -111,7 +128,8 @@ class Recorder(QObject):
try:
self._proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0,
creationflags=NO_WINDOW,
)
except OSError as exc:
self.failed.emit(t("Could not start recording: {error}", error=exc))
@@ -172,7 +190,7 @@ class Recorder(QObject):
proc = self._proc
if proc and proc.poll() is None:
try:
proc.send_signal(signal.SIGINT)
_interrupt(proc)
proc.wait(timeout=1.5)
except (subprocess.TimeoutExpired, OSError):
try:
@@ -284,6 +302,14 @@ class MeetingRecorder(QObject):
def start(self, path, mic_target="", system_target="", max_seconds=14400):
if self.active:
return
# Before ffmpeg is looked for, because installing it would not help: a
# system with no way to capture what the speakers are playing has none
# whatever else is on the machine.
if not sound().meetings:
self.failed.emit(t("This system offers nothing that records what "
"the speakers are playing, so a meeting cannot "
"be recorded on it."))
return
if not shutil.which("ffmpeg"):
self.failed.emit(t("ffmpeg not found. Install it to record a meeting."))
return
@@ -312,7 +338,8 @@ class MeetingRecorder(QObject):
self._procs = []
for command, log in zip(commands, self._logs):
self._procs.append(subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=log, bufsize=0
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
creationflags=NO_WINDOW,
))
except (OSError, wave.Error) as exc:
# One of two capture processes may already be running, and a Mac
@@ -411,7 +438,7 @@ class MeetingRecorder(QObject):
running = [proc for proc in self._procs if proc.poll() is None]
for proc in running:
try:
proc.send_signal(signal.SIGINT)
_interrupt(proc)
except OSError:
pass
for proc in running:
@@ -838,13 +865,131 @@ def _avfoundation_default_output():
return ""
# Windows records through DirectShow, the one capture API ffmpeg's Windows
# builds all ship with. What the speakers are playing is not offered as a
# device at all, so a meeting has nothing to record the far side from yet.
# A device entry and the line under it, in the two shapes ffmpeg has printed
# this listing in. Newer builds mark each device `(audio)` or `(video)`; older
# ones print no marker and group the devices under a heading instead. Both are
# anchored at each end, so that the error lines the command ends with, which
# quote the device name that was not found, are not read as devices. The
# bracketed prefix is not pinned to a spelling: ffmpeg 8 writes `[in#0 @ ...]`
# where the versions before it wrote `[dshow @ ...]`.
_DSHOW_ENTRY = re.compile(
r'^(?:\[[^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$')
_DSHOW_ALTERNATIVE = re.compile(
r'^(?:\[[^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$')
_DSHOW_HEADING = re.compile(r'DirectShow (audio|video) devices')
# The last listing taken, so that a dictation does not pay for one of its own.
_DSHOW_SEEN = []
def _parse_dshow_listing(text):
"""[(id, name)] for the audio devices in one ffmpeg device listing.
Two friendly names on one machine are routinely identical: a laptop with a
headset plugged in shows two microphones called the same thing, and
`audio=<name>` would reach only the first of them either way. The
alternative name ffmpeg prints under each device is unique and is what the
recorder is given back, while the friendly name is what a user picks from.
"""
devices = []
heading = ""
for line in text.splitlines():
found = _DSHOW_HEADING.search(line)
if found:
heading = found.group(1)
continue
found = _DSHOW_ALTERNATIVE.match(line.strip())
if found:
if devices:
devices[-1][0] = found.group(1)
continue
found = _DSHOW_ENTRY.match(line.strip())
if found:
kind = (found.group(2) or heading).lower()
devices.append([found.group(1), found.group(1), kind])
return [(identifier, name) for identifier, name, kind in devices
if "audio" in kind]
def _dshow_devices():
"""[(id, name)] for every DirectShow audio capture device, freshly asked.
The list comes out on stderr of a command that then fails, the same
documented trick AVFoundation uses above.
"""
if not shutil.which("ffmpeg"):
return []
try:
result = subprocess.run(
["ffmpeg", "-hide_banner", "-list_devices", "true",
"-f", "dshow", "-i", "dummy"],
capture_output=True, timeout=8, check=False, creationflags=NO_WINDOW,
)
except (subprocess.SubprocessError, OSError):
return []
devices = _parse_dshow_listing(result.stderr.decode("utf-8", "replace"))
_DSHOW_SEEN[:] = devices
return devices
def _dshow_first_device():
"""The device an unset target stands for, without a listing per dictation.
dshow has no "default" for an empty target to mean, so it has to be turned
into a name, and asking ffmpeg for one costs a process every time the key
is pressed. The last listing is used when there is one: opening Settings or
running `dikte devices` takes a fresh one, which is what somebody who has
just plugged a microphone in does anyway.
"""
devices = _DSHOW_SEEN or _dshow_devices()
return devices[0][0] if devices else ""
def _dshow_record(target):
if not shutil.which("ffmpeg"):
return []
device = target or _dshow_first_device()
if not device:
return []
return [
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
# dshow holds half a second of audio before handing anything over;
# asked for the chunk the level meter is measured in instead.
"-f", "dshow", "-audio_buffer_size", str(CHUNK_LATENCY_MS),
"-i", f"audio={device}",
"-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-",
]
def _dshow_meeting(mic_target, system_target):
return [] # no monitor devices to record the far side from
def _dshow_no_outputs():
return []
def _dshow_no_default_output():
return ""
Sound = collections.namedtuple(
"Sound",
# How to capture one source and how to capture two at once, that one as the
# list of processes it takes, the two device lists, which device a meeting
# records the far side from, and what to say when the programs for any of
# it are not installed.
"record meeting inputs outputs default_output missing",
# records the far side from, whether this system can record one at all, and
# what to say when the programs for any of it are not installed.
#
# `meetings` is the sound system's own answer, not this machine's: an empty
# output list means the tool that lists them is missing, which is a thing a
# user can go and fix, while False here is a thing they cannot.
"record meeting inputs outputs default_output meetings missing",
)
PULSE = Sound(
@@ -853,6 +998,7 @@ PULSE = Sound(
inputs=_pulse_inputs,
outputs=_pulse_outputs,
default_output=_pulse_default_output,
meetings=True,
missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.",
)
@@ -865,13 +1011,33 @@ COREAUDIO = Sound(
# empty list would leave nothing to pick.
outputs=_avfoundation_named_inputs,
default_output=_avfoundation_default_output,
# With a loopback driver installed, which is what the Settings note is for.
meetings=True,
missing="ffmpeg not found. Install it with: brew install ffmpeg",
)
DSHOW = Sound(
record=_dshow_record,
meeting=_dshow_meeting,
inputs=_dshow_devices,
outputs=_dshow_no_outputs,
default_output=_dshow_no_default_output,
# Windows offers no capture device for what the speakers are playing, and
# there is no driver to install that would add one.
meetings=False,
missing="ffmpeg or a microphone was not found. Install ffmpeg with: "
"winget install Gyan.FFmpeg",
)
def sound():
"""The programs this machine records through."""
return COREAUDIO if sys.platform == "darwin" else PULSE
if sys.platform == "darwin":
return COREAUDIO
if sys.platform == "win32":
return DSHOW
return PULSE
def list_sources():
+1
View File
@@ -199,6 +199,7 @@ def _output(cmd, timeout, service):
cmd, cwd=os.path.expanduser("~"), stdin=subprocess.DEVNULL,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except subprocess.TimeoutExpired:
raise CleanupError(t("{service} did not finish within {seconds} seconds.",
+72 -5
View File
@@ -17,8 +17,10 @@ import json
import os
import shutil
import signal
import subprocess
import sys
import time
import webbrowser
from PyQt6.QtCore import QCoreApplication, QTimer
@@ -29,10 +31,12 @@ from . import cleanup
from . import config as cfg
from . import filetranscribe
from . import hotkey
from . import hub
from . import ipc
from . import integrate
from . import meeting
from . import paste
from . import update
from . import __version__
NOT_RUNNING = 3
@@ -136,6 +140,16 @@ def launch_gui(verb=""):
if verb:
args.append(verb)
args.append("--gui")
if sys.platform == "win32":
# execv on Windows mangles arguments with spaces and would leave the
# application tied to this console; start it detached instead.
subprocess.Popen(
args,
creationflags=(subprocess.DETACHED_PROCESS
| subprocess.CREATE_NEW_PROCESS_GROUP),
close_fds=True,
)
sys.exit(0)
os.execv(args[0], args)
@@ -299,6 +313,9 @@ def cmd_transcribe(opts):
return fail(opts, f"no such file: {path}")
conf = cfg.Config()
# This runs here rather than in the instance, so the local servers have to
# be handed their settings here too; the GUI does this at startup.
conf.apply_local()
timestamps = opts.srt or _pick(opts.timestamps, conf["file_timestamps"])
worker = filetranscribe.FileTranscriber(conf)
@@ -643,7 +660,10 @@ def cmd_devices(opts):
"default": name == default}
for name, desc in audio.list_monitors()]
if not mics and not monitors:
return fail(opts, "pactl found nothing; is PipeWire running?")
# Which program was asked, and so which one to go and look at, is not
# the same on all four systems: naming pactl on Windows sends somebody
# after a program that was never going to be there.
return fail(opts, audio.sound().missing)
lines = ["Microphones:"]
lines += [f" {'*' if item['chosen'] else ' '} {item['name']}\n"
@@ -760,11 +780,14 @@ def cmd_integrate(opts):
Run for you on every start, so this is for the two cases that start does
not cover: undoing it, and repairing it from a terminal after the AppImage
was moved while Dikte was not running.
was moved while Dikte was not running. On Windows the setup program wrote
the rest, and what is left for this is the switch it could only offer while
it was on the screen: typing it starts Dikte at sign-in, --remove stops it.
"""
if not integrate.packaged():
installer = "install.ps1" if sys.platform == "win32" else "./install.sh"
return fail(opts, "this is a checkout, not a downloaded build; "
"./install.sh writes those files here", 2)
f"{installer} writes those files here", 2)
try:
# force, because typing this is asking for it outright, where the same
# call on every start stands aside for an installation already there.
@@ -777,6 +800,33 @@ def cmd_integrate(opts):
f"{verb}:\n{listing}" if paths else "Nothing to change.")
def cmd_update(opts):
"""Whether a newer Dikte has been released, and where it is.
It looks and nothing more: what to do about the answer is a download page,
because the AppImage, the disk image, the Windows setup and a checkout are
four different installations and only their owner knows which one this is.
"""
try:
release = update.latest(refresh=True)
except hub.HubError as exc:
return fail(opts, exc)
# Written down even when there is nothing new, so that the application does
# not go and ask the same question an hour later.
update.remember(release)
waiting = update.newer(release.version)
payload = {"ok": True, "current": __version__, "latest": release.version,
"update": waiting, "url": release.url}
if not waiting:
return out(opts, payload,
f"Dikte {__version__} is the newest release.")
if opts.open:
webbrowser.open(release.url)
return out(opts, payload,
f"Dikte {release.version} is out; this is {__version__}.\n"
f"{release.url}")
def cmd_status(opts):
reply = ipc.send("status")
if reply is None:
@@ -802,7 +852,18 @@ def cmd_status(opts):
def cmd_doctor(opts):
"""What the settings window checks behind its buttons, in one pass."""
conf = cfg.Config()
wanted = ["pw-record", "wl-copy", "ydotool", "ffmpeg", "pactl", "kwriteconfig6",
# The two the clipboard and the key press go through come out of the table
# rather than being spelled here, because they are not the same pair on all
# four systems: X11 pastes with xclip where Wayland pastes with wl-copy, a
# Mac shells out for one half and Windows for neither. A row saying ydotool
# is missing on a machine that would never have run it is not a diagnosis,
# it is a red mark to explain away.
here = paste.desktop()
wanted = [here.clipboard, here.keyboard]
if sys.platform.startswith("linux"):
# Recording, the device list, and KDE's shortcut registry.
wanted += ["pw-record", "pactl", "kwriteconfig6"]
wanted += ["ffmpeg",
assistant.executable(assistant.provider(conf)) or "claude",
cleanup.executable(cleanup.provider(conf))]
programs = {name: shutil.which(name) or "" for name in wanted if name}
@@ -1060,12 +1121,18 @@ def build_parser():
remove.set_defaults(func=cmd_shortcut)
integrated = leaf(subs, "integrate",
"menu entry, login item and command, for a downloaded build")
"menu entry, start at sign-in and command, "
"for a downloaded build")
integrated.add_argument("--remove", action="store_true",
help="take them away again")
integrated.set_defaults(func=cmd_integrate)
# --- the application --------------------------------------------------
updates = leaf(subs, "update", "whether a newer Dikte has been released")
updates.add_argument("--open", action="store_true",
help="open the release page in a browser")
updates.set_defaults(func=cmd_update)
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
for name, help_text in (("settings", "open the settings window"),
("restart", "reload the running instance"),
+3
View File
@@ -457,6 +457,9 @@ DEFAULTS = {
"overlay_corner": "bottom-left",
"keep_audio": False,
"history_limit": 200,
# A look at the releases page once a day, and nothing more than a look:
# what is found opens a browser, never an installer.
"update_check": True,
"file_timestamps": False,
"file_cleanup": True,
"file_cleanup_prompt": "", # empty -> language-specific default
+96 -33
View File
@@ -1,15 +1,19 @@
"""Transcribe an existing audio/video file with the same models.
ffmpeg converts whatever comes in to 16 kHz mono WAV, and for a hosted API to
mp3 on top of that. The upload limit is the only reason a file is ever cut up,
and uncompressed audio reaches it after ten minutes where mp3 takes an hour.
mp3 on top of that. Two things decide where a file is cut up: the upload limit,
which uncompressed audio reaches after ten minutes where mp3 takes an hour, and
the clock. An hour of audio in one request is minutes of work at the other end,
and the gateway in front of the model hangs up long before the answer comes
back, which arrives here as a 502 with the whole chunk lost. So a chunk is also
capped at MAX_CHUNK_SECONDS however small it is on disk.
That is worth the encoder, because a cut is not free. Whisper hears in thirty
second windows and decides for itself where one cue ends and the next begins; a
chunk that starts in the middle of a sentence can come back as one cue per
window, twenty seconds of text at a time, for the whole rest of the chunk. So
the file is cut as rarely as the limit allows, what is cut overlaps, and
stitch() drops the half that was heard twice.
A cut is not free, which is what the encoder buys and why nothing is cut more
finely than that. Whisper hears in thirty second windows and decides for itself
where one cue ends and the next begins; a chunk that starts in the middle of a
sentence can come back as one cue per window, twenty seconds of text at a time,
for the whole rest of the chunk. So what is cut overlaps, and stitch() drops
the half that was heard twice.
"""
import contextlib
@@ -19,6 +23,7 @@ import shutil
import subprocess
import tempfile
import threading
import time
import wave
from PyQt6.QtCore import QObject, pyqtSignal
@@ -29,10 +34,14 @@ from . import ggml
from .i18n import t
UPLOAD_LIMIT = 24 * 1024 * 1024 # the APIs take 25 MB; leave the form its room
MAX_CHUNK_SECONDS = 900 # as much audio as a hosted request can outlive
MP3_BITRATE = "48k" # mono speech at 16 kHz: whisper hears nothing less
OVERLAP_SECONDS = 30 # a whisper window: how far back a chunk starts
WAV_CHUNK_SECONDS = 600 # 19 MB, for the caller that uploads the WAV itself
CLEANUP_CHUNK_CHARS = 12000 # keep each cleanup call comfortably small
HOSTED_TIMEOUT = 600 # a quarter hour of audio, with room for the upload
RETRIES = 3 # how many times one chunk is asked for in all
RETRY_WAIT = 5 # seconds before the second try, doubled after that
RATE = 16000
MIN_SUBTITLE_SECONDS = 1.5 # how long a cue with no end time of its own stays up
@@ -86,9 +95,40 @@ class FileTranscriber(QObject):
def _check(self):
self._abort.check()
def _wait(self, seconds):
"""Sleep on it, with the Stop button still able to get through."""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
self._check()
time.sleep(0.25)
self._check()
def _attempt(self, call, stage):
"""`call`, asked again when what failed was the network rather than us.
One chunk is a quarter hour of audio that took a minute to encode and a
minute to upload, so a gateway having a bad moment is worth waiting out
rather than throwing the run away over. `stage` is what the status line
said before the failure, put back once the wait is over.
"""
for attempt in range(1, RETRIES + 1):
self._check()
try:
return call()
except api.ApiError as exc:
if attempt == RETRIES or not exc.retryable:
raise
self.progress.emit(t(
"{error} Trying again ({attempt}/{total})…",
error=exc, attempt=attempt + 1, total=RETRIES))
self._wait(RETRY_WAIT * 2 ** (attempt - 1))
self.progress.emit(stage)
def _work(self, path, timestamps, do_cleanup):
conf = self.conf
workdir = None
pieces = []
segments = []
try:
if not shutil.which("ffmpeg"):
raise api.ApiError(t("ffmpeg not found. Install it to transcribe files."))
@@ -104,39 +144,36 @@ class FileTranscriber(QObject):
if len(chunks) > 1:
self.progress.emit(t("Splitting into {count} chunks…", count=len(chunks)))
pieces = []
segments = []
for index, (chunk_path, offset) in enumerate(chunks, start=1):
self._check()
self.progress.emit(
t("Transcribing chunk {index}/{count}",
stage = (t("Transcribing chunk {index}/{count}",
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:
heard = self._attempt(lambda: api.transcribe_segments(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
timeout=HOSTED_TIMEOUT,
aborter=self._abort,
), stage)
segments = stitch(segments, [
(start + offset, end + offset, line)
for start, end, line in api.transcribe_segments(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
aborter=self._abort,
)
for start, end, line in heard
])
else:
pieces.append(api.transcribe(
pieces.append(self._attempt(lambda: api.transcribe(
target,
chunk_path,
language=conf["language"],
prompt=conf["transcribe_prompt"],
timeout=HOSTED_TIMEOUT,
aborter=self._abort,
))
), stage))
if timestamps:
pieces = [f"[{format_timestamp(start)}] {line}"
for start, _, line in segments]
text = "\n".join(pieces) if timestamps else " ".join(pieces)
text = _joined(pieces, segments, timestamps)
if do_cleanup and text:
self._check()
@@ -148,6 +185,15 @@ class FileTranscriber(QObject):
except Cancelled:
self.progress.emit(t("Stopped."))
except (api.ApiError, OSError, subprocess.SubprocessError, wave.Error) as exc:
# An hour of a long file already heard is not worth throwing away
# because the chunk after it failed, or because cleanup did. Hand
# over what there is, and say in the same breath where it stops.
partial = _joined(pieces, segments, timestamps)
if partial:
self.finished.emit(partial, segments)
self.failed.emit(t("{error} The transcript up to there is below.",
error=exc))
else:
self.failed.emit(str(exc))
finally:
self._local = None
@@ -181,12 +227,21 @@ class FileTranscriber(QObject):
self._local = ggml.llm if cleanup.provider(conf) == "local" else None
prompt = conf.cleanup_prompt(with_timestamps=timestamps, subtitles=True)
out = []
stage = t("Cleaning up…")
for block in split_text(text, timestamps):
self._check()
out.append(cleanup.run(block, conf, prompt, aborter=self._abort))
out.append(self._attempt(
lambda: cleanup.run(block, conf, prompt, aborter=self._abort), stage))
return ("\n" if timestamps else "\n\n").join(out)
def _joined(pieces, segments, timestamps):
"""The transcript as one string, out of whichever of the two is holding it."""
if timestamps:
pieces = [f"[{format_timestamp(start)}] {line}" for start, _, line in segments]
return "\n".join(pieces) if timestamps else " ".join(pieces)
def format_timestamp(seconds):
seconds = int(seconds)
hours, rest = divmod(seconds, 3600)
@@ -283,6 +338,7 @@ def _ffmpeg(args, out, aborter=None):
["ffmpeg", "-nostdin", "-y", *args],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
# A two hour film is a minute of ffmpeg, which is a minute of a Stop button
# doing nothing unless the abort reaches the process itself.
@@ -308,13 +364,20 @@ def wav_seconds(wav_path):
def chunk_seconds(path, duration):
"""How many seconds of this audio fit in one request, or 0 when all of it does.
Measured rather than worked out: what an encoder makes of an hour of speech
depends on the speech, and the file on disk is the only honest answer.
Whichever of the two limits bites first. How much fits under the upload
limit is measured rather than worked out: what an encoder makes of an hour
of speech depends on the speech, and the file on disk is the only honest
answer. The other limit is MAX_CHUNK_SECONDS, and it is the one that catches
a long file at this bitrate: an hour and a half of mp3 is two chunks by size
and one of them is an hour of audio in a single request, which no hosted
gateway stays on the line for.
"""
size = os.path.getsize(path)
if size <= UPLOAD_LIMIT or duration <= 0:
if duration <= 0:
return 0.0
return max(60.0, duration * UPLOAD_LIMIT / size * 0.95)
size = os.path.getsize(path)
fits = duration * UPLOAD_LIMIT / size * 0.95 if size > UPLOAD_LIMIT else duration
seconds = max(60.0, min(fits, MAX_CHUNK_SECONDS))
return 0.0 if seconds >= duration else seconds
def split_wav(wav_path, workdir, seconds=WAV_CHUNK_SECONDS, overlap=OVERLAP_SECONDS):
+72 -11
View File
@@ -43,6 +43,7 @@ import threading
import time
import urllib.error
import urllib.request
import zipfile
from . import hub
from . import paths
@@ -154,11 +155,14 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
try:
with urllib.request.urlopen(request, timeout=60) as response:
total = int(response.headers.get("Content-Length") or item.size or 0)
# Windows refuses to delete a file that is open, so nothing is
# unlinked until the handle is closed again.
stopped = overlong = False
with open(part, "wb") as out:
while True:
if should_stop is not None and should_stop():
part.unlink(missing_ok=True)
return False
stopped = True
break
block = response.read(DOWNLOAD_CHUNK)
if not block:
break
@@ -168,11 +172,17 @@ def download(item, target, on_progress=None, should_stop=None, require_hash=True
# More than was announced: a body that does not end is the
# one way this loop could run until the disk is full.
if total and done > total:
overlong = True
break
if on_progress is not None:
on_progress(done, total)
if stopped:
part.unlink(missing_ok=True)
return False
if overlong:
part.unlink(missing_ok=True)
raise LocalError(t("{name} is longer than it said it "
"would be.", name=item.name))
if on_progress is not None:
on_progress(done, total)
# A proxy notice or an error page that came back as 200 would otherwise
# be renamed into place and only fail when something tries to read it.
if total and done != total:
@@ -218,9 +228,11 @@ def _has_vulkan():
llama.cpp publishes no CUDA build for Linux, so Vulkan is what a graphics
card gets here. The build without it is smaller and runs on the CPU, and
fetching the Vulkan one for a machine that cannot load it would only make
the download bigger.
the download bigger. Windows spells the loader vulkan-1.dll.
"""
return bool(ctypes.util.find_library("vulkan"))
return bool(ctypes.util.find_library("vulkan")
or (sys.platform == "win32"
and ctypes.util.find_library("vulkan-1")))
def _wanted_assets(program):
@@ -233,6 +245,20 @@ def _wanted_assets(program):
arch = _arch()
if sys.platform == "darwin":
return () if program is WHISPER else (f"bin-macos-{arch}.tar.gz",)
if sys.platform == "win32":
if program is WHISPER:
# The BLAS build first: on a plain CPU it transcribes about twice
# as fast as the stock one, and it carries everything it needs.
# Full names, because "bin-x64.zip" alone would also match the
# CUDA archives, whichever the release happened to list first.
#
# x64 whatever this machine is, because whisper.cpp publishes no
# arm64 build for Windows: a Snapdragon runs this one emulated,
# which is slow but is the only local option there is.
return ("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip")
if _has_vulkan() and arch == "x64":
return ("bin-win-vulkan-x64.zip", f"bin-win-cpu-{arch}.zip")
return (f"bin-win-cpu-{arch}.zip",)
if program is LLAMA and _has_vulkan():
return (f"bin-ubuntu-vulkan-{arch}.tar.gz", f"bin-ubuntu-{arch}.tar.gz")
return (f"bin-ubuntu-{arch}.tar.gz",)
@@ -278,6 +304,11 @@ def system_program(program):
return bool(shutil.which(program.binary))
def _binary_file(program):
"""What the program's file is called on disk here."""
return f"{program.binary}.exe" if sys.platform == "win32" else program.binary
def _find_binary(root, name):
for path in sorted(pathlib.Path(root).rglob(name)):
if path.is_file():
@@ -286,19 +317,24 @@ def _find_binary(root, name):
def _extract(archive, into):
"""Unpack a release tarball, refusing anything that reaches outside `into`.
"""Unpack a release archive, refusing anything that reaches outside `into`.
The archives lay their libraries next to their binaries and are linked with
an $ORIGIN runpath, so a whole directory is what has to survive the trip and
the binary cannot be lifted out of it.
the binary cannot be lifted out of it. Linux and macOS releases come as
tarballs, Windows ones as zips; zipfile never writes outside its target.
"""
try:
if str(archive).endswith(".zip"):
with zipfile.ZipFile(archive) as bundle:
bundle.extractall(into)
return
with tarfile.open(archive, "r:gz") as tar:
try:
tar.extractall(into, filter="data")
except TypeError: # Python without the extraction filters
tar.extractall(into)
except (tarfile.TarError, OSError) as exc:
except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
raise LocalError(t("Could not unpack {name}: {error}",
name=os.path.basename(str(archive)), error=exc)) from exc
@@ -344,7 +380,7 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
if not download(item, archive, on_progress, should_stop):
return ""
_extract(archive, into)
binary = _find_binary(into, program.binary)
binary = _find_binary(into, _binary_file(program))
if binary is None:
raise LocalError(t("{name} was not in the download.",
name=program.binary))
@@ -501,6 +537,26 @@ def _tail(path, lines=3):
return " | ".join(found[-lines:])
def _win_image_name(pid):
"""The lower-cased file name of the process's executable, or ''."""
import ctypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.restype = ctypes.c_void_p
kernel32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
if not handle:
return ""
try:
buffer = ctypes.create_unicode_buffer(260)
size = ctypes.c_uint32(len(buffer))
ok = kernel32.QueryFullProcessImageNameW(
ctypes.c_void_p(handle), 0, buffer, ctypes.byref(size))
return os.path.basename(buffer.value).lower() if ok else ""
finally:
kernel32.CloseHandle(handle)
class Server:
"""One process, started when something needs it and stopped when nothing does.
@@ -602,6 +658,8 @@ class Server:
args + ["--host", HOST, "--port", str(port)],
stdout=sink, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
# No console window of its own on Windows.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except OSError as exc:
raise LocalError(t("Could not start {name}: {error}",
@@ -699,8 +757,11 @@ class Server:
number could belong to something else entirely, and killing it would be
a good deal worse than the leak being cleaned up. The program name alone
could be somebody else's copy; the name together with Dikte's own data
directory on the command line could not.
directory on the command line could not. Windows offers no command line
to read, so the executable's name is the whole of the answer there.
"""
if sys.platform == "win32":
return _win_image_name(pid) == _binary_file(self.program).lower()
try:
blob = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
except OSError:
+189 -13
View File
@@ -440,15 +440,175 @@ def _carbon():
return carbon
# --- Windows: RegisterHotKey ------------------------------------------------
# Windows virtual-key codes: where a key sits, not what a layout prints on it.
WIN_KEYS = {
"space": 0x20, "tab": 0x09, "enter": 0x0D, "return": 0x0D,
"esc": 0x1B, "escape": 0x1B, "backspace": 0x08, "insert": 0x2D,
"delete": 0x2E, "home": 0x24, "end": 0x23, "pgup": 0x21, "pgdown": 0x22,
"up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,
**{str(digit): 0x30 + digit for digit in range(10)},
**{chr(ord("a") + i): 0x41 + i for i in range(26)},
**{f"f{n}": 0x6F + n for n in range(1, 13)},
}
WIN_MODS = {
"alt": 0x0001, "ctrl": 0x0002, "control": 0x0002, "shift": 0x0004,
"meta": 0x0008, "super": 0x0008, "win": 0x0008,
}
WIN_MOD_NOREPEAT = 0x4000 # holding the combination fires it once
WM_HOTKEY = 0x0312
WM_QUIT = 0x0012
def _win_input():
"""user32 and kernel32, which is all the listener talks to.
Loaded on the first start rather than at import: this module is read on
every system, and these two libraries exist on one of them.
"""
return ctypes.windll.user32, ctypes.windll.kernel32
def parse_windows_shortcut(text):
"""'Ctrl+Space' -> (2, 32), or (None, None) when unusable."""
parts = [part.strip().lower() for part in str(text).split("+") if part.strip()]
modifiers, key = 0, None
for part in parts:
if part in WIN_MODS:
modifiers |= WIN_MODS[part]
elif key is None and part in WIN_KEYS:
key = WIN_KEYS[part]
else:
return None, None
if key is None:
return None, None
return modifiers, key
class WinHotkey(QObject):
"""Catches global shortcuts through Windows' own hotkey service.
RegisterHotKey asks for one combination rather than reading the keyboard,
so it needs no permission at all. Like Carbon's and unlike the evdev
listener it swallows the key: while Dikte holds a combination, nothing
else on the machine receives it.
RegisterHotKey only fires on the thread that called it, so registration
and the message loop live together on one worker thread; start() hands the
bindings over and waits for it to report what Windows actually gave us.
"""
triggered = pyqtSignal(str) # the name the binding was registered under
failed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._user32 = None
self._kernel32 = None
self._thread = None
self._thread_id = None
self._count = 0
@property
def running(self):
return self._count > 0 and self._thread is not None and self._thread.is_alive()
def start(self, bindings):
"""`bindings` is {name: 'Ctrl+Space'}; an empty combination is skipped."""
self.stop()
try:
self._user32, self._kernel32 = _win_input()
except (AttributeError, OSError) as exc:
self.failed.emit(t("Could not reach the Windows shortcut service: "
"{error}", error=exc))
return False
wanted = []
for identifier, (name, shortcut) in enumerate(bindings.items(), 1):
if not shortcut:
continue
modifiers, key = parse_windows_shortcut(shortcut)
if key is None:
self.failed.emit(
t("Could not parse the shortcut: {shortcut}", shortcut=shortcut)
)
continue
wanted.append((identifier, name, shortcut, modifiers, key))
if not wanted:
return False
ready = threading.Event()
outcome = {"count": 0, "thread_id": None}
self._thread = threading.Thread(
target=self._loop, args=(wanted, ready, outcome), daemon=True
)
self._thread.start()
ready.wait(timeout=5)
self._thread_id = outcome["thread_id"]
self._count = outcome["count"]
if not self._count:
self._thread = None
return self._count > 0
def stop(self):
if self._thread and self._thread_id and self._user32:
self._user32.PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0)
self._thread.join(timeout=1.5)
self._thread = None
self._thread_id = None
self._count = 0
_REGISTERED.clear()
def _loop(self, wanted, ready, outcome):
import ctypes.wintypes
user32, kernel32 = self._user32, self._kernel32
outcome["thread_id"] = kernel32.GetCurrentThreadId()
# The message queue a PostThreadMessage needs only exists once the
# thread has asked for messages; peek once before reporting ready.
message = ctypes.wintypes.MSG()
user32.PeekMessageW(ctypes.byref(message), None, WM_QUIT, WM_QUIT, 0)
names = {}
for identifier, name, shortcut, modifiers, key in wanted:
if user32.RegisterHotKey(None, identifier,
modifiers | WIN_MOD_NOREPEAT, key):
names[identifier] = name
spec = SHORTCUTS.get(name)
if spec:
_REGISTERED[spec.desktop_id] = shortcut
else:
# This is the conflict warning on Windows: there is no list to
# read beforehand, the answer comes from asking for the key.
self.failed.emit(t(
"Windows would not give Dikte {shortcut}; another "
"application already holds it.", shortcut=shortcut))
outcome["count"] = len(names)
ready.set()
if not names:
return
try:
while user32.GetMessageW(ctypes.byref(message), None, 0, 0) > 0:
if message.message == WM_HOTKEY:
name = names.get(int(message.wParam))
if name:
self.triggered.emit(name)
finally:
for identifier in names:
user32.UnregisterHotKey(None, identifier)
# --- the desktop's own shortcut -------------------------------------------
# The four ways a combination can reach Dikte. Everything below asks backend()
# The five ways a combination can reach Dikte. Everything below asks backend()
# rather than looking at the session itself, so the name shown, the status read
# back, what Install writes and what the installer promises cannot disagree
# about which one this session got.
KDE = "kde"
GNOME = "gnome"
MACOS = "macos"
WINDOWS = "windows"
LISTENER = "listener"
@@ -456,6 +616,10 @@ def _macos():
return sys.platform == "darwin"
def _windows():
return sys.platform == "win32"
def backend():
"""Which shortcut mechanism this session has.
@@ -467,6 +631,8 @@ def backend():
"""
if _macos():
return MACOS
if _windows():
return WINDOWS
names = os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":")
names = [name.strip() for name in names if name.strip()]
if any("gnome" in name for name in names) and shutil.which("gsettings"):
@@ -596,7 +762,11 @@ def gnome_shortcut_status(desktop_id=DESKTOP_ID):
def listener(parent=None):
"""The thing that hears the key, for whichever system this is."""
return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent)
if _macos():
return CarbonHotkey(parent)
if _windows():
return WinHotkey(parent)
return EvdevHotkey(parent)
def default_combo(which):
@@ -613,16 +783,20 @@ def default_combo(which):
def valid_shortcut(text):
"""Whether this machine can bind the combination as it was typed."""
parse = parse_macos_shortcut if _macos() else parse_shortcut
return parse(text)[1] is not None
if _macos():
return parse_macos_shortcut(text)[1] is not None
if _windows():
return parse_windows_shortcut(text)[1] is not None
return parse_shortcut(text)[1] is not None
def installs_shortcuts():
"""Whether this system keeps a shortcut registry to write into.
KDE and GNOME do, and something outside Dikte reads it, so the combination
survives Dikte being closed. macOS and the plain listener do not: there is
nothing to install, nothing to remove, and Settings should not offer either.
survives Dikte being closed. macOS, Windows and the plain listener do not:
there is nothing to install, nothing to remove, and Settings should not
offer either.
"""
return backend() in (KDE, GNOME)
@@ -631,7 +805,7 @@ def shortcut_needs_restart():
"""Whether an installed shortcut waits for the next login before it works.
KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding
up as it is written, and the other two never had one to write.
up as it is written, and the others never had one to write.
"""
return backend() == KDE
@@ -644,7 +818,7 @@ def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
if which == KDE:
return install_kde_shortcut(shortcut, exec_command, name, desktop_id)
_REGISTERED[desktop_id] = shortcut
if which == MACOS:
if which in (MACOS, WINDOWS):
return True, t(
"Shortcut saved: {shortcut}\nDikte holds this one itself while it "
"is running, so it works as soon as the settings are saved.",
@@ -686,6 +860,8 @@ def desktop_name():
which = backend()
if which == MACOS:
return "macOS"
if which == WINDOWS:
return "Windows"
if which == GNOME:
return "GNOME"
if which == KDE:
@@ -776,11 +952,11 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID):
def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
"""Names of other KDE entries bound to the same combination."""
if backend() != KDE:
# Nowhere else has a list to read. macOS answers the question by
# refusing the registration, which CarbonHotkey reports when it asks
# for the key; the other two would only be reading a file their session
# never looks at, and a leftover one from a Plasma install the user has
# since left would refuse perfectly good combinations.
# Nowhere else has a list to read. macOS and Windows answer the question
# by refusing the registration, which their listeners report when they
# ask for the key; the other two would only be reading a file their
# session never looks at, and a leftover one from a Plasma install the
# user has since left would refuse perfectly good combinations.
return []
try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8")
+16
View File
@@ -136,6 +136,22 @@ def release(repo, tag="latest", refresh=False):
return data.get("tag_name") or tag, assets
def newest_release(repo, refresh=False):
"""(tag, page, published) for the newest release of a repository.
release() above is for taking a file out of one and insists on there being
files to take; this is for the number, which a release with nothing
attached answers just as well. GitHub keeps prereleases out of "latest" on
its own, which is what leaves the nightly build off this answer.
"""
data = _fetch(f"gh-newest-{repo}",
f"{GITHUB_API}/repos/{repo}/releases/latest", refresh=refresh)
if not isinstance(data, dict) or not data.get("tag_name"):
raise HubError(t("{repo} has published no release.", repo=repo))
return (data["tag_name"], data.get("html_url") or "",
data.get("published_at") or "")
def files(repo, revision="main", refresh=False):
"""[Item] for every file in a Hugging Face repository.
+48
View File
@@ -109,6 +109,10 @@ TR = {
"Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.",
"ffmpeg not found. Install it with: brew install ffmpeg":
"ffmpeg bulunamadı. Şununla kur: brew install ffmpeg",
"ffmpeg or a microphone was not found. Install ffmpeg with: "
"winget install Gyan.FFmpeg":
"ffmpeg ya da bir mikrofon bulunamadı. ffmpeg'i şununla kur: "
"winget install Gyan.FFmpeg",
"Audio recorder stopped before receiving sound: {error}":
"Ses kayıt aracı veri alamadan kapandı: {error}",
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
@@ -186,6 +190,25 @@ TR = {
"Silence threshold": "Sessizlik eşiği",
"Keep audio files ({path})": "Ses kayıtlarını sakla ({path})",
# --- updates --------------------------------------------------------
"Updates": "Güncelleme",
"Look for a newer version once a day": "Günde bir kez yeni sürüm var mı diye bak",
"Dikte only looks. What it finds opens the release page in your browser; "
"it downloads and installs nothing by itself.":
"Dikte yalnızca bakar. Bulduğu şey tarayıcında sürüm sayfasını açar; "
"kendi başına hiçbir şey indirmez ve kurmaz.",
"Check now": "Şimdi bak",
"Looking…": "Bakılıyor…",
"Open the release page": "Sürüm sayfasını",
"This is Dikte {version}.": "Buradaki sürüm Dikte {version}.",
"Dikte {version} is the newest release.": "En yeni sürüm zaten bu: Dikte {version}.",
"Dikte {version} is out; this is {current}.":
"Dikte {version} çıkmış; buradaki sürüm {current}.",
"Dikte {version} is out…": "Dikte {version} çıkmış…",
"Dikte {version} is out. The tray menu has the release page.":
"Dikte {version} çıkmış. Sürüm sayfası tepsi menüsünde.",
"{repo} has published no release.": "{repo} için yayımlanmış sürüm yok.",
# --- settings: api --------------------------------------------------
"Keys": "Anahtarlar",
"Speech to text": "Sesi yazıya çevirme",
@@ -291,6 +314,10 @@ TR = {
"Converting audio…": "Ses dönüştürülüyor…",
"Splitting into {count} chunks…": "{count} parçaya bölünüyor…",
"Transcribing chunk {index}/{count}": "{index}/{count} parça yazıya çevriliyor…",
"{error} Trying again ({attempt}/{total})…":
"{error} Yeniden deneniyor ({attempt}/{total})…",
"{error} The transcript up to there is below.":
"{error} Oraya kadar çevrilen metin aşağıda.",
"Done: {chars} characters.": "Bitti: {chars} karakter.",
"Stopped.": "Durduruldu.",
"Failed: {error}": "Başarısız: {error}",
@@ -351,6 +378,11 @@ TR = {
"meantime.":
"Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey "
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"Dikte asks Windows for these combinations itself, while it is running. "
"Nothing is installed, and no other application receives them in the "
"meantime.":
"Dikte bu kombinasyonları çalışırken Windows'tan kendisi ister. Hiçbir şey "
"kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.",
"{desktop} keeps no shortcut registry, so Dikte listens for these "
"combinations itself while it is running. Your user has to be able to read "
"/dev/input for that, and the focused application receives the keys as "
@@ -390,6 +422,12 @@ TR = {
"macOS would not give Dikte {shortcut}; another application already holds it.":
"macOS {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama "
"onu şimdiden tutuyor.",
"Could not reach the Windows shortcut service: {error}":
"Windows kısayol servisine ulaşılamadı: {error}",
"Windows would not give Dikte {shortcut}; another application already "
"holds it.":
"Windows {shortcut} kombinasyonunu Dikte'ye vermedi; başka bir uygulama "
"onu şimdiden tutuyor.",
"Cannot read /dev/input. Your user needs to be in the 'input' group:\n"
" sudo usermod -aG input $USER (then log out and back in)":
"/dev/input okunamıyor. Kullanıcının 'input' grubunda olması gerekir:\n"
@@ -641,6 +679,16 @@ TR = {
"macOS, hoparlörden çıkan sesi kaydedilebilir bir kaynak olarak sunmaz. "
"BlackHole ya da Loopback kur, toplantının sesini oradan geçir ve "
"yukarıdan onu seç.",
"This system offers nothing that records what the speakers are playing, "
"so a meeting cannot be recorded on it. Dictation and transcribing a file "
"are unaffected.":
"Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; "
"burada toplantı kaydedilemez. Dikte ve dosya deşifresi bundan "
"etkilenmez.",
"This system offers nothing that records what the speakers are playing, "
"so a meeting cannot be recorded on it.":
"Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; "
"burada toplantı kaydedilemez.",
"Wear headphones if you can. Through speakers your microphone hears the "
"other side as well, and although a line that lands on both channels at "
"once is dropped again, the repair is never as clean as not needing it.":
+124 -9
View File
@@ -6,6 +6,12 @@ downloaded an AppImage or dragged Dikte.app out of a disk image ran no
installer at all, so the application writes those files itself, on its first
run and again whenever the file it was started from has moved.
Windows is the one platform where the download is an installer, and it wrote
the Start Menu entry, the `dikte` command and the uninstaller as it ran. What
is left here is the one thing it can only ask about once: whether Dikte starts
when you sign in. `dikte integrate` turns that on later and `--remove` turns it
off, and a plain start only repairs an entry that is already there.
Nothing here runs from a checkout. install.sh has already written the same
files there, pointing at the interpreter that checkout was installed against,
and overwriting them with a guess would be a downgrade.
@@ -34,6 +40,15 @@ import sys
AGENT_ID = "io.github.yusufipk.dikte"
ICON_NAME = "dikte"
DESKTOP_FILE = "dikte.desktop"
MACOS_COMMAND_MARKER = "# Written by Dikte itself. Delete it to be rid of it.\n"
# The windowed executable the Windows setup installs, beside the console one
# the `dikte` command runs.
WINDOWS_APP = "Dikte.exe"
# Where Windows keeps what to start when somebody signs in, and the name the
# setup program files Dikte's entry under. Both halves have to agree: the
# uninstaller deletes this value, and so does `dikte integrate --remove`.
RUN_KEY = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
RUN_VALUE = "Dikte"
def packaged():
@@ -55,6 +70,13 @@ def target():
for parent in executable.parents:
if parent.suffix == ".app":
return parent
if sys.platform == "win32":
# The windowed executable, whichever of the two is running: the console
# one is what the `dikte` command names, and a sign-in that started
# that one would open a console window nobody asked for.
windowed = executable.with_name(WINDOWS_APP)
if windowed.is_file():
return windowed
return executable
@@ -151,11 +173,12 @@ def use_system_certificates():
def bundled_bin():
"""Where a build keeps the helper programs it carries, if it carries any.
The disk image ships an ffmpeg because macOS records through one and has
nothing like it preinstalled, so a Mac that downloaded Dikte and nothing
else would otherwise not be able to record at all. The AppImage carries
none: Linux records through parec or pw-record, which come with the sound
server, and the distributions all package ffmpeg for the rest.
The disk image and the Windows setup both ship an ffmpeg, because both
systems record through one and neither has anything like it preinstalled,
so a machine that downloaded Dikte and nothing else would otherwise not be
able to record at all. The AppImage carries none: Linux records through
parec or pw-record, which come with the sound server, and the distributions
all package ffmpeg for the rest.
"""
binary = pathlib.Path(sys.executable).parent
if sys.platform == "darwin" and binary.name == "MacOS":
@@ -209,6 +232,8 @@ def install(force=False):
"""
if sys.platform == "darwin":
return _macos_install(target(), force)
if sys.platform == "win32":
return _windows_install(target(), force)
return _linux_install(target(), force)
@@ -216,6 +241,8 @@ def remove():
"""Take them away again. The paths that were there to delete."""
if sys.platform == "darwin":
return _macos_remove()
if sys.platform == "win32":
return _windows_remove()
return _linux_remove()
@@ -398,6 +425,20 @@ def _agent_path():
return pathlib.Path.home() / "Library" / "LaunchAgents" / f"{AGENT_ID}.plist"
def _macos_command_path():
return pathlib.Path.home() / ".local" / "bin" / "dikte"
def _macos_command_is_ours(command):
"""Whether this is the wrapper a downloaded Mac build wrote itself."""
try:
return command.is_file() and MACOS_COMMAND_MARKER in command.read_text(
encoding="utf-8"
)
except (OSError, UnicodeDecodeError):
return False
def _agent_plist(app):
"""Through `open` rather than the executable inside the bundle, so that the
process is one LaunchServices started: that is what gives it the bundle's
@@ -449,13 +490,13 @@ def _macos_install(app, force=False):
# The command, as a wrapper rather than a symlink: the executable has to be
# run from inside the bundle for macOS to file its permissions under Dikte,
# and a symlink somewhere else is a different process to macOS.
command = pathlib.Path.home() / ".local" / "bin" / "dikte"
command = _macos_command_path()
binary = app / "Contents" / "MacOS" / "Dikte"
marker = "# Written by Dikte itself. Delete it to be rid of it.\n"
script = f'#!/bin/sh\n{marker}exec {shlex.quote(str(binary))} "$@"\n'
script = (f'#!/bin/sh\n{MACOS_COMMAND_MARKER}'
f'exec {shlex.quote(str(binary))} "$@"\n')
# install-mac.sh writes its own wrapper here, naming the checkout's Python.
# Ours only replaces a wrapper it wrote before, or nothing at all.
ours = command.exists() and marker in command.read_text(encoding="utf-8")
ours = _macos_command_is_ours(command)
if (not command.exists() or ours or force) and _write(command, script):
command.chmod(0o755)
written.append(command)
@@ -470,6 +511,10 @@ def _macos_remove():
capture_output=True, check=False)
agent.unlink()
gone.append(agent)
command = _macos_command_path()
if _macos_command_is_ours(command):
command.unlink()
gone.append(command)
return gone
@@ -481,3 +526,73 @@ def _launchctl_reload(agent):
capture_output=True, check=False)
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
capture_output=True, check=False)
# --- Windows --------------------------------------------------------------
#
# The setup program did the installing here, which leaves one question a
# wizard can only ask while it is on the screen: whether Dikte starts when you
# sign in. That answer is a registry value, so it is one both sides can write:
# the setup program sets it from the tick box, the uninstaller deletes it
# however it got there, and the two functions below are the same switch from a
# terminal, long after the wizard is gone.
def _run_entry():
"""What the autostart entry names, or "" when there is none."""
import winreg
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
value, kind = winreg.QueryValueEx(key, RUN_VALUE)
except OSError:
return ""
return value if kind == winreg.REG_SZ and isinstance(value, str) else ""
def _write_run_entry(command):
import winreg
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
winreg.SetValueEx(key, RUN_VALUE, 0, winreg.REG_SZ, command)
def _delete_run_entry():
"""Whether there was one to delete."""
import winreg
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0,
winreg.KEY_SET_VALUE) as key:
winreg.DeleteValue(key, RUN_VALUE)
except OSError:
return False
return True
def _run_entry_name():
"""What to call the value in a listing, since it is not a file."""
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
def _windows_install(app, force=False):
"""Point the autostart entry at this build. What changed.
Only `force`, which is what typing `dikte integrate` means, creates one.
The call on every start repairs an entry that is already there and names an
executable somewhere else, which is what an installation moved to another
drive or reinstalled into another directory leaves behind; somebody who
unticked the box in the wizard, or turned it off since, is not asked again
by every start.
"""
command = f'"{app}"'
current = _run_entry()
if not current and not force:
return []
if current == command:
return []
_write_run_entry(command)
return [_run_entry_name()]
def _windows_remove():
"""Stop starting at sign-in. The Start Menu entry, the command and the
files are the uninstaller's, and Add/Remove Programs is where they go."""
return [_run_entry_name()] if _delete_run_entry() else []
+15 -1
View File
@@ -15,7 +15,11 @@ import sys
from PyQt6.QtNetwork import QLocalSocket
SERVER_NAME = "dikte-" + str(os.getuid())
from . import integrate
SERVER_NAME = "dikte-" + (
str(os.getuid()) if hasattr(os, "getuid")
else os.environ.get("USERNAME", "user"))
# Long enough for a process that is already running to answer, short enough that
# "nothing is running" is not a noticeable pause in front of a key press.
@@ -41,9 +45,19 @@ def launcher():
under a fresh /tmp path every run, so what a shortcut written today has to
say is the .AppImage file the user keeps, not the binary inside this run's
mount. APPIMAGE is what the runtime puts that path in.
The Windows build is two executables over one program, and the one to start
again is always the windowed one: `dikte toggle` typed at a terminal runs
the console one, and the application it leaves running should no more be
tied to that terminal than the one the Start Menu starts.
"""
if not getattr(sys, "frozen", False):
return [sys.executable, script_path()]
if sys.platform == "win32":
windowed = os.path.join(os.path.dirname(sys.executable),
integrate.WINDOWS_APP)
if os.path.isfile(windowed):
return [windowed]
return [os.environ.get("APPIMAGE") or sys.executable]
+203
View File
@@ -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
+10 -2
View File
@@ -7,6 +7,8 @@ from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
from PyQt6.QtWidgets import QWidget, QApplication
from . import mac_window
BARS = 22
HEIGHT = 56
MIN_WIDTH = 210
@@ -68,9 +70,10 @@ class Overlay(QWidget):
| Qt.WindowType.Tool
| Qt.WindowType.WindowDoesNotAcceptFocus
)
if sys.platform != "darwin":
if sys.platform not in ("darwin", "win32"):
# It is the window manager that would otherwise move this out of
# the corner. macOS has no such hint, and Qt warns about it.
# the corner. macOS has no such hint, and Qt warns about it;
# Windows places tool windows where they ask to be anyway.
flags |= Qt.WindowType.X11BypassWindowManagerHint
# One that can be clicked away has to receive the click, which means it
# also swallows one aimed at whatever is underneath it. The rest stay
@@ -201,6 +204,11 @@ class Overlay(QWidget):
self._reposition()
if not self.isVisible():
self.show()
if sys.platform == "darwin":
# After show(), because the window it works on does not exist until
# then, and every time, because a window Qt rebuilt has the setting
# again at its default.
mac_window.keep_on_screen(self)
if self._concealed:
self.raise_()
self._concealed = False
+224 -6
View File
@@ -147,7 +147,9 @@ def _program_keyboard(program, command, hint=""):
def ready():
return shutil.which(program) is not None
def press(shortcut, delay):
def press(shortcut, delay, _focus=None):
# Nothing here takes the front from the window being dictated into, so
# there is nothing to hand back: the process id is a macOS concern.
if not ready():
raise PasteError(t("{tool} not found, cannot paste automatically.",
tool=program))
@@ -296,11 +298,27 @@ def _ask_for_permission():
pass
def _macos_press(shortcut, delay):
def _macos_press(shortcut, delay, focus=None):
"""Post the key down and up straight into the window system.
Nothing is typed anywhere until macOS has been told to trust Dikte, and it
only asks once, when the paste it was granted for is first tried.
`focus` is the application that was in front when the recording began. The
keys land wherever the window system is pointing, so a Dikte that has ended
up in front would swallow its own transcript; when that has happened the
front is handed back before pressing. Nothing is taken from anyone else: an
application the user went to while the transcription ran is where they want
the text now.
This runs on the transcription's own thread rather than the main one, and
the two calls it makes are the kind AppKit documents as answering
atomically wherever they are asked from: NSRunningApplication is thread
safe by its own header, and the workspace lookup behind it returns a
reference rather than anything that has to be held. Stressed with four
threads and 32000 lookups against a running main loop without a fault; if
one ever does happen, mac_window answers None and the press goes ahead
where it would have gone anyway.
"""
keycode, flags = _macos_keys(shortcut)
services, core = _macos_api()
@@ -310,6 +328,12 @@ def _macos_press(shortcut, delay):
"macOS has not been told to let Dikte press keys. Turn Dikte on "
"under System Settings → Privacy & Security → Accessibility."
))
if focus:
# Imported here rather than at the top: it reaches for QtGui, and a
# terminal that only wants the clipboard should not pay for that.
from . import mac_window
if mac_window.is_frontmost():
mac_window.activate(focus)
time.sleep(delay) # let the selection settle and focus come back
down = services.CGEventCreateKeyboardEvent(None, keycode, True)
@@ -330,6 +354,169 @@ def _macos_press(shortcut, delay):
core.CFRelease(up)
def _win_keys(shortcut):
"""'Ctrl+V' -> [0x11, 0x56]: Windows virtual-key codes, modifiers first."""
codes = []
for key in _keys(shortcut):
if key not in WIN_KEYCODES:
raise PasteError(t("Unknown key: {key}", key=key))
codes.append(WIN_KEYCODES[key])
return codes
# Windows virtual-key codes (winuser.h). Like Apple's, they say where the key
# sits rather than what a layout prints on it.
WIN_KEYCODES = {
"ctrl": 0x11, "control": 0x11, "shift": 0x10, "alt": 0x12,
"super": 0x5B, "meta": 0x5B,
"v": 0x56, "insert": 0x2D, "enter": 0x0D, "return": 0x0D,
}
_WIN_KEYUP = 0x0002 # KEYEVENTF_KEYUP
_WIN_CF_UNICODETEXT = 13 # what the clipboard calls UTF-16 text
_WIN_GMEM_MOVEABLE = 0x0002
@functools.lru_cache(maxsize=1)
def _win_api():
"""user32 and kernel32 with their prototypes spelled out.
The default return type is a 32-bit int, which silently truncates the
64-bit handles and pointers every one of these calls trades in.
"""
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
user32.OpenClipboard.argtypes = [ctypes.c_void_p]
user32.GetClipboardData.restype = ctypes.c_void_p
user32.GetClipboardData.argtypes = [ctypes.c_uint]
user32.SetClipboardData.restype = ctypes.c_void_p
user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
kernel32.GlobalAlloc.restype = ctypes.c_void_p
kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
return user32, kernel32
def _win_error():
"""GetLastError where it exists, so the failure paths run under any test."""
return getattr(ctypes, "get_last_error", lambda: 0)()
def _win_open_clipboard(user32):
"""The clipboard is a lock another program may hold for a moment."""
for _ in range(10):
if user32.OpenClipboard(None):
return True
time.sleep(0.01)
return False
def _win_read_text():
"""The clipboard's text, '' when it holds none, None when it cannot be read."""
user32, kernel32 = _win_api()
if not _win_open_clipboard(user32):
return None
try:
handle = user32.GetClipboardData(_WIN_CF_UNICODETEXT)
if not handle:
return ""
pointer = kernel32.GlobalLock(handle)
if not pointer:
return None
try:
return ctypes.wstring_at(pointer)
finally:
kernel32.GlobalUnlock(handle)
finally:
user32.CloseClipboard()
def _win_write_text(text):
user32, kernel32 = _win_api()
payload = str(text).encode("utf-16-le") + b"\x00\x00"
# Filled before the clipboard is opened at all. EmptyClipboard is what
# throws away whatever was there, and a failure after it and before the
# SetClipboardData would leave the clipboard holding nothing: the one way
# this function could lose what it was called to put back.
handle = kernel32.GlobalAlloc(_WIN_GMEM_MOVEABLE, len(payload))
pointer = kernel32.GlobalLock(handle) if handle else None
if not pointer:
if handle:
kernel32.GlobalFree(handle)
raise PasteError(t("Could not copy to clipboard: {error}",
error="out of memory"))
ctypes.memmove(pointer, payload, len(payload))
kernel32.GlobalUnlock(handle)
if not _win_open_clipboard(user32):
kernel32.GlobalFree(handle)
raise PasteError(t("Could not copy to clipboard: {error}",
error="the clipboard is held by another program"))
try:
user32.EmptyClipboard()
if not user32.SetClipboardData(_WIN_CF_UNICODETEXT, handle):
raise PasteError(t("Could not copy to clipboard: {error}",
error=f"error {_win_error()}"))
handle = None # the clipboard owns it now
finally:
if handle:
kernel32.GlobalFree(handle)
user32.CloseClipboard()
class _WinKeybdInput(ctypes.Structure):
_fields_ = [("wVk", ctypes.c_ushort), ("wScan", ctypes.c_ushort),
("dwFlags", ctypes.c_ulong), ("time", ctypes.c_ulong),
("dwExtraInfo", ctypes.c_size_t)]
class _WinMouseInput(ctypes.Structure):
_fields_ = [("dx", ctypes.c_long), ("dy", ctypes.c_long),
("mouseData", ctypes.c_ulong), ("dwFlags", ctypes.c_ulong),
("time", ctypes.c_ulong), ("dwExtraInfo", ctypes.c_size_t)]
class _WinInputUnion(ctypes.Union):
_fields_ = [("mi", _WinMouseInput), ("ki", _WinKeybdInput)]
class _WinInput(ctypes.Structure):
# The union carries the mouse shape too: SendInput sizes its argument by
# the biggest member whether or not it is the one being sent.
_fields_ = [("type", ctypes.c_ulong), ("union", _WinInputUnion)]
def _win_press(shortcut, delay, _focus=None):
"""Post the presses and releases straight into the input queue.
No permission stands in front of SendInput the way Accessibility does on
macOS: whatever window has focus receives the combination.
Nothing here takes the front from the window being dictated into, so the
remembered process id has nothing to hand back to: it is a macOS concern.
"""
codes = _win_keys(shortcut)
user32, _ = _win_api()
time.sleep(delay) # let the selection settle and focus come back
events = ([(code, 0) for code in codes]
+ [(code, _WIN_KEYUP) for code in reversed(codes)])
inputs = (_WinInput * len(events))()
for entry, (code, flags) in zip(inputs, events):
entry.type = 1 # INPUT_KEYBOARD
entry.union.ki = _WinKeybdInput(code, 0, flags, 0, 0)
sent = user32.SendInput(len(inputs), inputs, ctypes.sizeof(_WinInput))
if sent != len(inputs):
raise PasteError(t("Could not run {tool}: {error}", tool="SendInput",
error=f"error {_win_error()}"))
def _win_ready():
return True
# --- which of them is here -------------------------------------------------
Desktop = collections.namedtuple(
@@ -362,6 +549,17 @@ X11 = Desktop(
**_program_keyboard("xdotool", _xdotool_command),
)
WINDOWS = Desktop(
clipboard="", # no program: both directions are calls into the system
packages="",
read_command=[],
copy_command=[],
shortcuts=["ctrl+v", "ctrl+shift+v", "shift+insert"],
keyboard="",
ready=_win_ready,
press=_win_press,
)
MACOS = Desktop(
clipboard="pbcopy",
packages="", # both are part of macOS; there is nothing to install
@@ -383,6 +581,8 @@ def desktop():
"""
if sys.platform == "darwin":
return MACOS
if sys.platform == "win32":
return WINDOWS
if os.environ.get("XDG_SESSION_TYPE") == "x11":
return X11
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
@@ -427,6 +627,9 @@ def _macos_restore(snapshot):
def read_clipboard():
here = desktop()
if here is WINDOWS:
text = _win_read_text()
return None if text is None else text.encode("utf-8")
if here is MACOS and shutil.which("osascript"):
snapshot = _macos_snapshot()
if snapshot is not None:
@@ -454,6 +657,9 @@ def _run_copy(payload):
def copy(text):
here = desktop()
if here is WINDOWS:
_win_write_text(text)
return
if not shutil.which(here.clipboard):
raise PasteError(
t("{tool} not found. Install {packages}.",
@@ -473,7 +679,15 @@ def copy_bytes(data):
if isinstance(data, _MAC_SNAPSHOT):
_macos_restore(data)
return
if data is None or not shutil.which(desktop().clipboard):
if data is None:
return
if desktop() is WINDOWS:
try:
_win_write_text(data.decode("utf-8", "replace"))
except PasteError:
pass
return
if not shutil.which(desktop().clipboard):
return
try:
_run_copy(data)
@@ -488,7 +702,11 @@ def paste_ready():
return desktop().ready()
def press(shortcut="", delay=0.12):
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own."""
def press(shortcut="", delay=0.12, focus=None):
"""Press a paste combination, e.g. 'ctrl+v', or this desktop's own.
`focus` is the process the keys are meant for, remembered when the
recording started: see the macOS press for what is done with it.
"""
here = desktop()
here.press(shortcut or here.shortcuts[0], delay)
here.press(shortcut or here.shortcuts[0], delay, focus)
+13 -5
View File
@@ -16,7 +16,8 @@ import pathlib
import sys
def _xdg(var, default):
def _env(var, default):
"""The directory a variable names, or the one it stands in for."""
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
@@ -24,13 +25,20 @@ def directories(platform=None):
"""(settings, data), in the two places this system keeps them.
macOS keeps both in the one directory a Mac user's backup already knows
about. Everywhere else they are separate and follow the XDG variables.
about. Windows keeps them apart on purpose: settings roam with the account,
and several gigabytes of models are exactly what a roaming profile must not
carry. Everywhere else they are separate and follow the XDG variables.
"""
if (platform or sys.platform) == "darwin":
here = platform or sys.platform
if here == "darwin":
support = pathlib.Path.home() / "Library/Application Support/Dikte"
return support, support
return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte",
_xdg("XDG_DATA_HOME", "~/.local/share") / "dikte")
if here == "win32":
roaming = _env("APPDATA", "~/AppData/Roaming")
local = _env("LOCALAPPDATA", "~/AppData/Local")
return roaming / "Dikte", local / "Dikte"
return (_env("XDG_CONFIG_HOME", "~/.config") / "dikte",
_env("XDG_DATA_HOME", "~/.local/share") / "dikte")
CONFIG_DIR, DATA_DIR = directories()
+89
View File
@@ -13,6 +13,7 @@ from PyQt6.QtWidgets import (
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
from . import __version__
from . import api
from . import assistant
from . import audio
@@ -21,10 +22,12 @@ from . import config as cfg
from . import filetranscribe
from . import ggml
from . import hotkey
from . import hub
from . import i18n
from . import ipc
from . import meeting
from . import paste
from . import update
from .filetranscribe import FileTranscriber
from .i18n import t
@@ -517,11 +520,16 @@ class SettingsWindow(QDialog):
# 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)
_models_loaded = pyqtSignal(list, str)
_transcribe_models_loaded = pyqtSignal(list, str)
# Which key was tested, whether it worked, and what to write under it.
_test_done = pyqtSignal(str, bool, str)
# The release that was found, or None, and what went wrong instead.
_update_checked = pyqtSignal(object, str)
def __init__(self, conf, meetings=None, parent=None):
super().__init__(parent)
@@ -540,6 +548,9 @@ class SettingsWindow(QDialog):
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.setWindowTitle(t("Dikte Settings"))
@@ -574,6 +585,7 @@ class SettingsWindow(QDialog):
self._models_loaded.connect(self._on_models_loaded)
self._transcribe_models_loaded.connect(self._on_transcribe_models_loaded)
self._test_done.connect(self._on_test_done)
self._update_checked.connect(self._on_update_checked)
self.transcriber.progress.connect(self._on_file_progress)
self.transcriber.finished.connect(self._on_file_finished)
self.transcriber.failed.connect(self._on_file_failed)
@@ -702,6 +714,23 @@ class SettingsWindow(QDialog):
t("Keep audio files ({path})", path=str(cfg.RECORDINGS_DIR))
)
form.addRow("", self.keep_audio)
self.update_check = QCheckBox(t("Look for a newer version once a day"))
self.update_check.setToolTip(
t("Dikte only looks. What it finds opens the release page in your "
"browser; it downloads and installs nothing by itself.")
)
form.addRow(t("Updates"), self.update_check)
self.update_status = WrappedLabel("")
self.update_page = QPushButton(t("Open the release page"))
self.update_page.clicked.connect(
lambda: QDesktopServices.openUrl(QUrl(self._release_url))
)
self.update_now = QPushButton(t("Check now"))
self.update_now.clicked.connect(self._check_for_update)
form.addRow("", self._row(self.update_status, self.update_page,
self.update_now))
return page
def _api_tab(self):
@@ -1104,6 +1133,18 @@ class SettingsWindow(QDialog):
))
mac_note.setWordWrap(True)
sources_form.addRow(mac_note)
elif not audio.sound().meetings:
# Windows is the system this is written for: it offers nothing that
# captures what the speakers are playing, and there is no driver to
# install that would put an entry in the list above. Left unsaid,
# the box is simply empty and the Record button fails at the press.
nothing_note = QLabel(t(
"This system offers nothing that records what the speakers are "
"playing, so a meeting cannot be recorded on it. Dictation and "
"transcribing a file are unaffected."
))
nothing_note.setWordWrap(True)
sources_form.addRow(nothing_note)
note = QLabel(t(
"Wear headphones if you can. Through speakers your microphone hears "
@@ -1373,6 +1414,12 @@ class SettingsWindow(QDialog):
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
elif hotkey.backend() == hotkey.WINDOWS:
explanation = t(
"Dikte asks Windows for these combinations itself, while it is "
"running. Nothing is installed, and no other application receives "
"them in the meantime."
)
else:
# The desktops nobody writes a backend for. Saying "installed" here
# would be the old bug in words: there is no registry, the listener
@@ -1554,6 +1601,8 @@ class SettingsWindow(QDialog):
self.silence_db.setValue(int(conf["silence_db"]))
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
self.keep_audio.setChecked(conf["keep_audio"])
self.update_check.setChecked(conf["update_check"])
self._show_update(update.pending())
for name, who in cfg.TRANSCRIBERS.items():
self._key_fields[name].setText(conf[who.key])
@@ -1647,6 +1696,7 @@ class SettingsWindow(QDialog):
conf["silence_db"] = float(self.silence_db.value())
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
conf["keep_audio"] = self.keep_audio.isChecked()
conf["update_check"] = self.update_check.isChecked()
provider = self.transcribe_provider.currentData() or "local"
if provider in self._models:
@@ -1884,6 +1934,45 @@ class SettingsWindow(QDialog):
button.setEnabled(True)
answer.setText(("" if ok else "") + message)
# ---- updates ---------------------------------------------------------
def _check_for_update(self):
"""The button, which asks GitHub whatever the daily clock says."""
self.update_now.setEnabled(False)
self.update_status.setText(t("Looking…"))
def work():
try:
self._update_checked.emit(update.check(force=True), "")
except hub.HubError as exc:
self._update_checked.emit(None, str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_update_checked(self, release, error):
self.update_now.setEnabled(True)
if error:
self.update_status.setText(error)
return
self._show_update(release, asked=True)
if release is not None:
self.update_found.emit(release)
def _show_update(self, release, asked=False):
"""What the line under the checkbox says, and whether the page button
is on it. `release` is None when this build is the newest one, and
`asked` is what tells "nothing new" from "nobody has looked yet"."""
self.update_page.setVisible(release is not None)
if release is None:
self.update_status.setText(
t("Dikte {version} is the newest release.", version=__version__)
if asked else t("This is Dikte {version}.", version=__version__))
return
self._release_url = release.url
self.update_status.setText(
t("Dikte {version} is out; this is {current}.",
version=release.version, current=__version__))
# ---- audio file ------------------------------------------------------
def _choose_file(self):
+47 -6
View File
@@ -23,9 +23,10 @@ outwards, which stands out on a dark bar and stays readable on a light one.
"""
import pathlib
import struct
import sys
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtCore import QBuffer, QPointF, QRectF, Qt
from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
QPen, QPixmap)
@@ -210,6 +211,9 @@ APP_ICON_SIZES = (16, 32, 128, 256, 512)
# What an XDG icon theme is asked for: a menu wants 48, a task bar 22 or 24, a
# file dialog 16, and something scaling for a HiDPI panel wants the big ones.
HICOLOR_SIZES = (16, 22, 24, 32, 48, 64, 128, 256)
# What goes into the .ico: the Windows shell picks the nearest of these itself,
# and 256 is the one the large view in Explorer and the setup program read.
ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
def app_pixmap(size):
@@ -292,26 +296,63 @@ def write_hicolor(directory, name="dikte"):
return written
def write_ico(path):
"""Write the Windows icon, every size in the one file. The path it wrote.
An .ico is a directory of images and a run of image data after it, and
since Vista each image may be a PNG rather than the bitmap-and-mask pair
the format started with. PNGs are what Qt can already produce, so the
twenty bytes of header per size are the whole of the work, and it saves
both a build dependency and an icon file in the repository.
"""
path = pathlib.Path(path)
images = []
for size in ICO_SIZES:
buffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.WriteOnly)
app_pixmap(size).save(buffer, "PNG")
images.append((size, bytes(buffer.data())))
buffer.close()
# 0, then 1 for an icon rather than a cursor, then the count.
header = struct.pack("<HHH", 0, 1, len(images))
offset = len(header) + 16 * len(images)
entries, data = b"", b""
for size, png in images:
# A side of 256 is written as 0: the field is one byte, and the format
# spends it on the sizes below that rather than on the one above.
entries += struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0,
1, 32, len(png), offset)
offset += len(png)
data += png
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(header + entries + data)
return path
def _main(argv):
"""`trayicon.py <path>.iconset` for install-mac.sh, `--hicolor <dir>` for
install.sh.
install.sh, `--ico <path>` for the Windows build.
A QGuiApplication has to exist before a QPixmap can, and offscreen because
this runs from a shell script with no window to open.
"""
hicolor = len(argv) == 3 and argv[1] == "--hicolor"
if not hicolor and len(argv) != 2:
flag = argv[1] if len(argv) == 3 else ""
if flag not in ("--hicolor", "--ico") and len(argv) != 2:
print("usage: trayicon.py <directory>.iconset\n"
" trayicon.py --hicolor <icon directory>", file=sys.stderr)
" trayicon.py --hicolor <icon directory>\n"
" trayicon.py --ico <path>.ico", file=sys.stderr)
return 2
from PyQt6.QtGui import QGuiApplication
QGuiApplication.setAttribute(
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
try:
if hicolor:
if flag == "--hicolor":
for path in write_hicolor(argv[2]):
print(path)
elif flag == "--ico":
print(write_ico(argv[2]))
else:
print(write_iconset(argv[1]))
finally:
+158
View File
@@ -0,0 +1,158 @@
"""Whether a newer Dikte has been published, and where to get it.
GitHub is asked for the newest release, its number is held against the one this
build carries, and that is where it stops. Nothing is downloaded and nothing is
replaced. The four downloads are installed in four different ways, and three of
those belong to the platform rather than to Dikte: a Mac bundle is dragged into
Applications and cannot rewrite itself while it is running, the Windows setup
is an installer with an uninstall entry of its own, an AppImage is a single
file kept wherever its owner keeps it, and a checkout is updated with git. A
program that guessed at all four would be wrong on at least one of them, and
being wrong there means an installation somebody has to repair by hand. So the
answer ends in a browser, on the release page, where the same download that was
installed the first time is waiting.
The clock is kept in a file of its own rather than in the settings. A check
runs while the settings window may be open, and a background write into
config.json is exactly what would overwrite a setting somebody is in the middle
of changing.
Nothing here imports Qt or the rest of the application: `dikte update` at a
terminal and the timer behind the tray icon ask the same three questions of the
same module.
"""
import collections
import itertools
import json
import time
from . import __version__
from . import hub
from . import paths
REPO = "yusufipk/dikte"
# Where somebody is sent. GitHub redirects this to whatever the newest release
# is, so it stays right without anybody writing a number into it.
RELEASES_PAGE = f"https://github.com/{REPO}/releases/latest"
# Once a day. A release happens every few weeks at best, and a question nobody
# is waiting on is not one to ask GitHub on every start.
INTERVAL = 24 * 3600
# When the last check was, what it found, and which version has already been
# announced. In the data directory rather than the config one: it is not a
# setting, nobody edits it, and losing it costs one extra request.
STATE_FILE = paths.DATA_DIR / "update.json"
Release = collections.namedtuple("Release", "version url published")
def _numbers(version):
"""(1, 0, 2) for "v1.0.2", "1.0.2" and "1.0.2-dev.abc1234" alike.
Empty for anything that does not start with a number, which is what a tag
naming something other than a version comes back as.
"""
number = str(version or "").strip().lstrip("vV").split("-")[0].split("+")[0]
parts = []
for piece in number.split("."):
digits = "".join(itertools.takewhile(str.isdigit, piece))
if not digits:
break
parts.append(int(digits))
return tuple((parts + [0, 0, 0])[:3]) if parts else ()
def newer(there, here=""):
"""Whether the release numbered `there` is one this build has not got.
Only the numbers are compared, and what follows them is dropped. A build
off master carries the released number with its commit after it
(1.0.1-dev.abc1234), and that build is ahead of 1.0.1 rather than behind
it; read as a version suffix it would be behind, and every nightly would be
told to update to the release it was already past.
"""
theirs = _numbers(there)
return bool(theirs) and theirs > _numbers(here or __version__)
def state():
"""What the last check wrote down; empty when there has never been one."""
try:
stored = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return stored if isinstance(stored, dict) else {}
def _store(**changes):
stored = state()
stored.update(changes)
try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(stored), encoding="utf-8")
except OSError:
pass # a check that cannot be written down still happened
return stored
def due(now=0):
"""Whether a day has gone by since the last time anybody asked."""
return (now or time.time()) - float(state().get("checked") or 0) >= INTERVAL
def latest(refresh=False):
"""The newest published release, asked for outright. Raises HubError."""
tag, url, published = hub.newest_release(REPO, refresh=refresh)
return Release(tag.lstrip("vV"), url or RELEASES_PAGE, published)
def remember(release):
"""Write down that a check has just happened, and what it found."""
_store(checked=time.time(), version=release.version, url=release.url,
published=release.published)
def pending():
"""The newer release the last check found, without asking anybody.
What the tray icon is built from: the answer has to be there the moment it
appears, and a request on the way to the screen is a request nobody has
time for.
"""
stored = state()
version = stored.get("version") or ""
if not newer(version):
return None
return Release(version, stored.get("url") or RELEASES_PAGE,
stored.get("published") or "")
def check(force=False):
"""A newer release, or None when there is nothing to say.
The scheduled half: it asks only when a day has gone by, and answers from
what the last check found in between. `force` is the button in Settings and
the command line, which ask whatever the clock says.
The clock here is the only throttle. Once it has decided to ask, it asks
for real rather than reading hub.py's few hours of cache, which is there to
keep a settings window from fetching the same model list twice in an
evening and would only ever answer this with something it already knew.
"""
if not force and not due():
return pending()
release = latest(refresh=True)
remember(release)
return release if newer(release.version) else None
def announced():
"""The version somebody has already been shown a notification about."""
return state().get("announced") or ""
def mark_announced(version):
"""Said once. A daily check must not be a daily interruption."""
_store(announced=version)
+10 -5
View File
@@ -50,16 +50,20 @@ class Pipeline(QObject):
def busy(self):
return self._thread is not None and self._thread.is_alive()
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None):
def run(self, wav_path, duration, rms_values=(), ask=False, paste=None,
focus=None):
"""`paste` overrides the setting for this one run, which is what a
dictation asked for from a terminal wants: the text comes back down the
socket, and pasting it into whatever had focus is nobody's intention."""
socket, and pasting it into whatever had focus is nobody's intention.
`focus` is the application that was in front when the recording began,
as a process id, and is where the paste is meant to land."""
if self.busy:
return
self._stop.clear()
self._thread = threading.Thread(
target=self._work,
args=(wav_path, duration, list(rms_values), ask, paste),
args=(wav_path, duration, list(rms_values), ask, paste, focus),
daemon=True,
)
self._thread.start()
@@ -73,7 +77,8 @@ class Pipeline(QObject):
"""
self._stop.set()
def _work(self, wav_path, duration, rms_values, ask, paste_override=None):
def _work(self, wav_path, duration, rms_values, ask, paste_override=None,
focus=None):
conf = self.conf
started = time.monotonic()
raw = ""
@@ -144,7 +149,7 @@ class Pipeline(QObject):
paste.copy(text)
if wants_paste:
self.stage.emit(t("Pasting…"))
paste.press(conf["paste_shortcut"])
paste.press(conf["paste_shortcut"], focus=focus)
finally:
if previous is not None:
# Let the focused application consume the temporary
+82
View File
@@ -0,0 +1,82 @@
# Installs Dikte for this Windows user: a Start Menu entry, an optional
# autostart entry, and a `dikte` command that works from any terminal.
#
# powershell -ExecutionPolicy Bypass -File install.ps1 # install
# powershell -ExecutionPolicy Bypass -File install.ps1 -Autostart # + start at sign-in
# powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # remove
param(
[switch]$Autostart,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$repo = $PSScriptRoot
# The one file that starts the application, whoever is asking: the Start Menu
# entry, the autostart entry and the dikte command all name it.
$entry = Join-Path $repo "dikte\__main__.py"
$startMenu = [Environment]::GetFolderPath("Programs")
$startup = [Environment]::GetFolderPath("Startup")
$shortcut = Join-Path $startMenu "Dikte.lnk"
$autostartLink = Join-Path $startup "Dikte.lnk"
# WindowsApps is already on the user PATH, so a dikte.cmd left there runs from
# any terminal without a PATH edit and without an administrator.
$cmdShim = Join-Path $env:LOCALAPPDATA "Microsoft\WindowsApps\dikte.cmd"
if ($Uninstall) {
foreach ($path in @($shortcut, $autostartLink, $cmdShim)) {
if (Test-Path $path) { Remove-Item $path -Force; Write-Host "removed: $path" }
}
Write-Host "Dikte's shortcuts are gone. The repository and your settings are not."
exit 0
}
# --- what it needs ----------------------------------------------------------
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) {
Write-Error "No python found. Install it with: winget install Python.Python.3.12"
}
$version = & python -c "import sys; print('%d.%d' % sys.version_info[:2])"
if ([version]$version -lt [version]"3.11") {
Write-Error "Python 3.11 or newer is needed, and this one is $version."
}
& python -c "import PyQt6.QtWidgets" 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host "Installing PyQt6..."
& python -m pip install PyQt6
if ($LASTEXITCODE -ne 0) { Write-Error "PyQt6 would not install." }
}
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Warning "No ffmpeg found. Recording needs it: winget install Gyan.FFmpeg"
}
# pythonw.exe runs the same program without a console window behind it.
$pythonw = Join-Path (Split-Path $python.Source) "pythonw.exe"
if (-not (Test-Path $pythonw)) { $pythonw = $python.Source }
# --- the Start Menu entry ---------------------------------------------------
$shell = New-Object -ComObject WScript.Shell
foreach ($path in @($shortcut) + $(if ($Autostart) { @($autostartLink) } else { @() })) {
$link = $shell.CreateShortcut($path)
$link.TargetPath = $pythonw
$link.Arguments = "`"$entry`" --gui"
$link.WorkingDirectory = $repo
$link.Description = "Dikte: dictation"
$link.Save()
Write-Host "shortcut: $path"
}
# --- the dikte command ------------------------------------------------------
# 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.
$shimDir = Split-Path $cmdShim
if (Test-Path $shimDir) {
"@echo off`r`n`"$($python.Source)`" `"$entry`" %*" |
Out-File $cmdShim -Encoding ascii
Write-Host "command: dikte ($cmdShim)"
} else {
Write-Warning "No $shimDir on this machine, so there is no dikte command. Run it as: python `"$entry`""
}
Write-Host ""
Write-Host "Installed. Start it from the Start Menu as 'Dikte', or type 'dikte' in a terminal."
Write-Host "The Settings window opens on the first run: download a model there and pick the shortcut (Ctrl+Space by default)."
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env pwsh
# The Windows download: one setup program, carrying everything Dikte needs to
# record and to be started again after a sign-in.
#
# Run from anywhere; it works in build\ at the top of the checkout and leaves
# the finished .exe in dist\. x64 only, because that is what the PyQt6 wheel and
# whisper.cpp both publish for Windows; a Windows on ARM machine runs it under
# the emulation it runs everything else under.
#
# powershell -ExecutionPolicy Bypass -File packaging\build-windows.ps1
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$build = Join-Path $root "build"
$out = Join-Path $root "dist"
$dist = Join-Path $build "dist\dikte"
$env:PYTHONPATH = $root
$version = & python -c "import dikte; print(dikte.__version__)"
if ($LASTEXITCODE -ne 0) { throw "could not read the version out of dikte/__init__.py" }
# A pinned tag and a checksum rather than "whatever is newest": this binary goes
# out inside something people run, so what it is has to be decided here and not
# by whoever pushes to that repository next. The same release the disk image
# takes its ffmpeg from, which is gyan.dev's essentials build repackaged, and
# dshow is in it, which is the one part of ffmpeg recording here goes through.
$ffmpegTag = "b6.1.1"
$ffmpegAsset = "ffmpeg-win32-x64.gz"
$ffmpegSha = "8883A3DFFBD0A16CF4EF95206EA05283F78908DBFB118F73C83F4951DCC06D77"
if (Test-Path $build) { Remove-Item $build -Recurse -Force }
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
New-Item -ItemType Directory -Path $build, $out | Out-Null
# 1. The icon ---------------------------------------------------------------
# Drawn by Dikte itself, offscreen, which is why there is no image file in the
# repository. Before the application, because PyInstaller writes it into the
# executable rather than beside it, and the setup program uses the same file.
$icon = Join-Path $build "Dikte.ico"
$env:QT_QPA_PLATFORM = "offscreen"
& python -m dikte.trayicon --ico $icon
if ($LASTEXITCODE -ne 0) { throw "the icon would not draw" }
Remove-Item Env:\QT_QPA_PLATFORM
$env:DIKTE_ICO = $icon
# 2. The application --------------------------------------------------------
# Two executables in the one directory: Dikte.exe, which is windowed and is
# what a shortcut starts, and dikte-cli.exe, which has a console and is what
# the `dikte` command runs. Their names differ by more than case on purpose;
# Windows would otherwise keep one file for both.
& python -m PyInstaller (Join-Path $root "packaging\dikte.spec") `
--distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") `
--noconfirm --clean
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" }
# And the check that the two are really two. Windows matches a filename
# without regard to its case, so a rename that leaves them a case apart puts
# one file in the directory and whichever was written second is what both
# names find. It cost a windowed build once, silently: the machines the
# packaging is otherwise checked on all have case-sensitive filesystems, so
# this only ever shows up here. Subsystem 2 is windowed, 3 is a console, and
# it is the field the loader reads to decide which to give.
function Get-PeSubsystem($path) {
$bytes = [System.IO.File]::ReadAllBytes($path)
$header = [BitConverter]::ToInt32($bytes, 0x3C)
return [BitConverter]::ToUInt16($bytes, $header + 0x5C)
}
foreach ($pair in @(@("Dikte.exe", 2), @("dikte-cli.exe", 3))) {
$subsystem = Get-PeSubsystem (Join-Path $dist $pair[0])
if ($subsystem -ne $pair[1]) {
throw "$($pair[0]) is subsystem $subsystem, expected $($pair[1]): the two executables collided"
}
}
# 3. ffmpeg -----------------------------------------------------------------
# Recording on Windows goes through ffmpeg's DirectShow input, and Windows
# ships nothing like it, so without this the download would be an application
# that cannot record until the person who downloaded it installs one. bin\
# beside the executables, because integrate.py puts that directory in front of
# PATH at startup and everything reaching for ffmpeg goes through shutil.which.
$archive = Join-Path $build $ffmpegAsset
Invoke-WebRequest -UseBasicParsing -OutFile $archive `
"https://github.com/eugeneware/ffmpeg-static/releases/download/$ffmpegTag/$ffmpegAsset"
$got = (Get-FileHash $archive -Algorithm SHA256).Hash
if ($got -ne $ffmpegSha) { throw "ffmpeg checksum: expected $ffmpegSha, got $got" }
$bin = Join-Path $dist "bin"
New-Item -ItemType Directory -Path $bin | Out-Null
$compressed = [System.IO.File]::OpenRead($archive)
$stream = New-Object System.IO.Compression.GzipStream(
$compressed, [System.IO.Compression.CompressionMode]::Decompress)
$binary = [System.IO.File]::Create((Join-Path $bin "ffmpeg.exe"))
try { $stream.CopyTo($binary) } finally { $binary.Dispose(); $stream.Dispose(); $compressed.Dispose() }
# 4. The setup program ------------------------------------------------------
# Inno Setup comes with the GitHub runner. On a machine that has not got it:
# winget install JRSoftware.InnoSetup
$iscc = (Get-Command iscc -ErrorAction SilentlyContinue).Source
if (-not $iscc) {
$iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"
}
if (-not (Test-Path $iscc)) {
throw "no Inno Setup found. Install it with: winget install JRSoftware.InnoSetup"
}
& $iscc "/DVersion=$version" "/DSource=$dist" "/DIcon=$icon" `
(Join-Path $root "packaging\dikte.iss")
if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed" }
Write-Host "dist\Dikte-$version-x64-setup.exe"
+117
View File
@@ -0,0 +1,117 @@
; What the Windows download is: the directory PyInstaller built, wrapped in the
; setup program Windows expects. Run it through build-windows.ps1, which draws
; the icon, builds that directory, puts an ffmpeg in it and passes the version
; in; ISCC on its own has none of that.
;
; Per user rather than per machine. It keeps the whole thing out of the way of
; the administrator prompt, which for something a person is trying out is the
; difference between a download and a phone call to whoever owns the laptop,
; and nothing here writes outside the account anyway.
#ifndef Version
#define Version "0.0.0"
#endif
#ifndef Source
#define Source "..\build\dist\dikte"
#endif
#ifndef Icon
#define Icon "..\build\Dikte.ico"
#endif
[Setup]
; The identifier Add/Remove Programs files this under, and what an update
; recognises the older installation by. The same one the Mac's login item and
; the bundle use, and like those it never changes.
AppId=io.github.yusufipk.dikte
AppName=Dikte
AppVersion={#Version}
AppPublisher=Yusuf Ipek
AppSupportURL=https://github.com/yusufipk/dikte
DefaultDirName={localappdata}\Programs\Dikte
DefaultGroupName=Dikte
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=..\dist
OutputBaseFilename=Dikte-{#Version}-x64-setup
SetupIconFile={#Icon}
UninstallDisplayIcon={app}\Dikte.exe
WizardStyle=modern
; Most of the download is Qt and ffmpeg, both of which compress well, and the
; slower setting is a minute of a build machine's time against a smaller file
; for everybody who downloads it.
Compression=lzma2/max
SolidCompression=yes
; An update over a running Dikte would otherwise fail on the executable it
; cannot replace. Restart Manager closes it and starts it again afterwards.
CloseApplications=yes
RestartApplications=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
; On by default: Dikte is a tray application holding a global shortcut, and one
; that is not running when you press the key is one that does nothing.
Name: "autostart"; Description: "Start Dikte when I sign in"
[Files]
Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
[Icons]
Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe"
[Registry]
; Starting at sign-in, as a registry value rather than a shortcut in the
; Startup folder: it is the one place the setup program, the uninstaller and
; `dikte integrate` can all read and write without a COM library between them.
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
ValueType: string; ValueName: "Dikte"; ValueData: """{app}\Dikte.exe"""; \
Flags: uninsdeletevalue; Tasks: autostart
; And taking it away again, for an update where the box was unticked. Both
; lines delete on uninstall, so an entry `dikte integrate` wrote later goes
; too, whichever way it got there.
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
ValueType: none; ValueName: "Dikte"; \
Flags: deletevalue uninsdeletevalue; Tasks: not autostart
[Run]
Filename: "{app}\Dikte.exe"; Description: "Start Dikte"; \
Flags: nowait postinstall skipifsilent
[Code]
{ The `dikte` command. WindowsApps is already on the user's PATH, so a .cmd
left there runs from any terminal without touching the PATH and without an
administrator; the alternative is an environment variable edit that every
open terminal misses. It names dikte-cli.exe, the console executable, which
is the one that can print to the terminal it was typed in. }
function ShimDir(): String;
begin
Result := ExpandConstant('{localappdata}\Microsoft\WindowsApps');
end;
function ShimPath(): String;
begin
Result := ShimDir() + '\dikte.cmd';
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
Shim: String;
begin
if CurStep = ssPostInstall then begin
if DirExists(ShimDir()) then begin
Shim := '@echo off' + #13#10
+ '"' + ExpandConstant('{app}\dikte-cli.exe') + '" %*' + #13#10;
SaveStringToFile(ShimPath(), Shim, False);
end;
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usUninstall then
DeleteFile(ShimPath());
end;
+40 -10
View File
@@ -1,12 +1,14 @@
# PyInstaller's description of the build, shared by the AppImage and the disk
# image. Run it through build-appimage.sh or build-dmg.sh rather than by hand:
# each of those has a few steps of its own on either side of this.
# PyInstaller's description of the build, shared by the AppImage, the disk
# image and the Windows setup. Run it through build-appimage.sh, build-dmg.sh
# or build-windows.ps1 rather than by hand: each of those has a few steps of
# its own on either side of this.
#
# A directory rather than a single file, on both platforms. Onefile unpacks
# itself into /tmp on every start, which for something a global shortcut is
# meant to bring up is a second of nothing happening, and for the AppImage it
# would be an unpacking inside an unpacking. The single file people download is
# the AppImage and the .dmg; this only has to be tidy inside them.
# A directory rather than a single file, on all three. Onefile unpacks itself
# into a temporary directory on every start, which for something a global
# shortcut is meant to bring up is a second of nothing happening, and for the
# AppImage it would be an unpacking inside an unpacking. The single file people
# download is the AppImage, the .dmg and the setup program; this only has to be
# tidy inside them.
import os
import pathlib
@@ -24,6 +26,7 @@ __version__ = re.search(r'^__version__ = "(.*)"$',
re.M).group(1)
MACOS = sys.platform == "darwin"
WINDOWS = sys.platform == "win32"
BUNDLE_ID = "io.github.yusufipk.dikte"
# PyQt6's wheel is most of the build, and most of the wheel is modules nothing
@@ -60,19 +63,46 @@ executable = EXE( # noqa: F821
analysis.scripts,
[],
exclude_binaries=True,
name="Dikte" if MACOS else "dikte",
name="Dikte" if MACOS or WINDOWS else "dikte",
console=False,
# Both platforms use whatever the machine is, because neither build is
# Every platform uses whatever the machine is, because no build here is
# cross-compiled: the workflow runs one job per architecture.
target_arch=None,
# Ad-hoc, and only on a Mac, where an arm64 binary that carries no
# signature at all is refused by the kernel rather than merely warned
# about. build-dmg.sh signs the finished bundle over the top of this.
codesign_identity="-" if MACOS else None,
# Windows keeps the icon inside the executable, and build-windows.ps1 draws
# it from the same shapes the tray uses. A Mac reads the one BUNDLE names
# below, and the AppImage installs PNGs into the icon theme instead.
icon=os.environ.get("DIKTE_ICO") or None,
)
# The same program a second time, as a console application, and only on
# Windows. A windowed executable there is one the loader gives no console and
# no standard output at all, so `dikte doctor` started from a terminal would
# print nothing to it and answer nothing to a script. Everywhere else the one
# executable does both jobs: a terminal that started it keeps its output, and
# nothing opens a window nobody asked for.
#
# Named apart from the windowed one rather than `dikte` beside `Dikte`.
# Windows matches a filename without regard to its case, so those two are one
# file in one directory: whichever PyInstaller writes second is the only one
# installed, and a build made on a case-sensitive filesystem never sees it.
console_executable = EXE( # noqa: F821
archive,
analysis.scripts,
[],
exclude_binaries=True,
name="dikte-cli",
console=True,
target_arch=None,
icon=os.environ.get("DIKTE_ICO") or None,
) if WINDOWS else None
collection = COLLECT( # noqa: F821
executable,
*([console_executable] if WINDOWS else []),
analysis.binaries,
analysis.datas,
name="dikte",
+3 -2
View File
@@ -1,11 +1,12 @@
"""What the AppImage and the disk image start.
"""What the AppImage, the disk image and the Windows setup start.
dikte/__main__.py is written for a checkout: it puts the directory above the
package on the import path, which a build has neither the need for nor a
directory to point at. What is left over is one thing a checkout never sees.
The Finder hands a double-clicked application a -psn_0_ argument naming the
process serial number, which argparse reads as a flag it has never heard of and
exits over, and no one clicking an icon would ever find out why.
exits over, and no one clicking an icon would ever find out why. Nothing else
here is one platform's: the same file is both Windows executables as well.
The three environment lines have to run before anything starts a process,
opens a connection or reaches for ffmpeg, and before is easier to be sure of
+16 -1
View File
@@ -25,6 +25,7 @@ from unittest import mock
from dikte import assistant
from dikte import config as cfg
from dikte import i18n
from dikte import update
# What the application is, rather than what it does: PipeWire, wl-clipboard,
# ydotool, KDE's shortcut file, /dev/input. A port to another desktop replaces
@@ -40,6 +41,18 @@ linux_only = unittest.skipUnless(
"covers the Linux desktop stack (PipeWire, wl-clipboard, ydotool, KDE)",
)
# The launchers a downloaded build writes for itself. There are two downloads,
# an AppImage and a disk image, so `integrate` has a Linux half and a macOS half
# and no third one, and the tests that pin them stand in a home laid out the way
# those two systems lay one out: paths that start at the root, a $HOME the
# library reads, a symlink for the command. None of that is a Windows machine,
# where the same code never runs. A Windows build would add an entry there and
# take the mark off these.
posix_only = unittest.skipIf(
sys.platform == "win32",
"covers what an AppImage and a .app write into the desktop they landed on",
)
def _no_network(*args, **kwargs):
raise AssertionError(
@@ -74,8 +87,10 @@ class DikteTest(unittest.TestCase):
MEETINGS_FILE=data_dir / "meetings.jsonl",
)
# Resolved from cfg.DATA_DIR when assistant was imported, so it needs
# moving on its own.
# moving on its own. The same goes for where the update check writes
# down when it last ran.
self.patch_attr(assistant, "SESSION_FILE", data_dir / "assistant.json")
self.patch_attr(update, "STATE_FILE", data_dir / "update.json")
i18n.set_language("en")
self.addCleanup(i18n.set_language, "en")
+27
View File
@@ -79,6 +79,33 @@ class Explain(DikteTest):
def test_the_status_is_carried_through(self):
self.assertEqual(self.error(429).status, 429)
def test_so_is_whether_it_is_worth_asking_again(self):
self.assertTrue(self.error(502).retryable)
self.assertFalse(self.error(401).retryable)
class Retryable(unittest.TestCase):
"""Which failures a second try can fix, and which will fail the same way."""
def test_a_gateway_that_gave_up_waiting(self):
for status in (408, 429, 500, 502, 503, 504):
with self.subTest(status=status):
self.assertTrue(api.ApiError("x", status).retryable)
def test_a_request_that_was_wrong(self):
for status in (400, 401, 402, 403, 404, 413, 422):
with self.subTest(status=status):
self.assertFalse(api.ApiError("x", status).retryable)
def test_an_error_of_our_own_is_not_the_network(self):
self.assertFalse(api.ApiError("Transcript came back empty.").retryable)
def test_a_connection_that_dropped_is_worth_a_second_try(self):
with fake_urlopen(url_error("connection reset")):
with self.assertRaises(api.ApiError) as caught:
api._request("https://example.test", b"{}", {})
self.assertTrue(caught.exception.retryable)
class ExtractError(unittest.TestCase):
def test_the_usual_shape(self):
+196
View File
@@ -887,5 +887,201 @@ class MacRecordingCommand(OnMacOS, DikteTest):
self.assertFalse(recorder.active)
class NoFarSideToRecord(DikteTest):
"""Two different answers, and the table is what tells them apart.
A sound system that records the far side has a device this machine could
not pick out, and Settings is where to choose one. A sound system that does
not had nothing to offer there in the first place, and "pick one" would
send somebody to an empty box and an installation that cannot help.
"""
def failure(self, meetings):
recorder = audio.MeetingRecorder()
failures = []
recorder.failed.connect(failures.append)
with only_these_tools("ffmpeg"), \
mock.patch.object(audio, "default_monitor", return_value=""), \
mock.patch.object(audio, "sound",
return_value=audio.PULSE._replace(
meetings=meetings)):
recorder.start(str(self.path("meeting.wav")))
self.assertFalse(recorder.active)
return failures[0]
def test_a_system_that_records_the_far_side_sends_you_to_settings(self):
self.assertIn("Settings", self.failure(True))
def test_a_system_that_does_not_says_that_instead(self):
message = self.failure(False)
self.assertIn("nothing that records what the speakers", message)
self.assertNotIn("Settings", message)
def test_the_three_sound_systems_each_answer_the_question(self):
self.assertTrue(audio.PULSE.meetings)
self.assertTrue(audio.COREAUDIO.meetings)
self.assertFalse(audio.DSHOW.meetings)
class OnWindows:
"""A test that runs as if the machine ran Windows."""
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(sys, "platform", "win32"))
class WindowsDevices(OnWindows, DikteTest):
"""The one ffmpeg listing the device questions are answered from.
dshow names devices rather than numbering them, and the names carry
whatever alphabet the machine speaks, so the listing here does too.
"""
MIC = "@device_cm_{33D9A762}\\wave_{B1C2}"
LISTING = (
'[dshow @ 0000020c] "Integrated Camera" (video)\n'
'[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n'
'[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n'
f'[dshow @ 0000020c] Alternative name "{MIC}"\n'
'[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n'
'[dshow @ 0000020c] Could not find audio only device with name '
'"dummy" among source devices of type audio.\n'
"dummy: Immediate exit requested\n"
).encode("utf-8")
def setUp(self):
super().setUp()
# The listing is remembered between calls, so that a dictation does not
# run ffmpeg of its own. It cannot be remembered between tests.
audio._DSHOW_SEEN.clear()
self.addCleanup(audio._DSHOW_SEEN.clear)
@contextlib.contextmanager
def listing(self, stderr=None, tools=("ffmpeg",)):
completed = FakeCompleted(
returncode=1, stderr=self.LISTING if stderr is None else stderr)
with only_these_tools(*tools), \
mock.patch.object(subprocess, "run",
return_value=completed) as run:
yield run
def test_windows_records_through_dshow(self):
self.assertIs(audio.sound(), audio.DSHOW)
def test_the_audio_devices_are_the_only_ones_read(self):
with self.listing():
self.assertEqual(audio.list_sources(), [
(self.MIC, "Mikrofon Dizisi (Intel Smart Sound)"),
("Kulaklık (Soundcore Life Q30)",
"Kulaklık (Soundcore Life Q30)"),
])
def test_the_device_ffmpeg_could_not_open_is_not_one_of_them(self):
"""The command ends by quoting the name it was sent to look for."""
with self.listing():
self.assertNotIn("dummy", [name for _, name in audio.list_sources()])
def test_a_listing_from_ffmpeg_8_which_renamed_the_prefix(self):
"""ffmpeg 8 writes `[in#0 @ ...]` where older builds wrote `[dshow @ ...]`."""
listing = (
'[in#0 @ 00000238c3300ac0] "Integrated Camera" (video)\n'
'[in#0 @ 00000238c3300ac0] Alternative name "@device_pnp_\\..."\n'
'[in#0 @ 00000238c3300ac0] "OBS Virtual Camera" (none)\n'
'[in#0 @ 00000238c3300ac0] Alternative name "@device_sw_{860B}"\n'
'[in#0 @ 00000238c3300ac0] "Mikrofon Dizisi (Intel® Smart Sound)" (audio)\n'
'[in#0 @ 00000238c3300ac0] Alternative name "@device_cm_{33D9}"\n'
"Error opening input file dummy.\n"
).encode("utf-8")
with self.listing(stderr=listing):
self.assertEqual(audio.list_sources(),
[("@device_cm_{33D9}",
"Mikrofon Dizisi (Intel® Smart Sound)")])
def test_a_listing_from_an_ffmpeg_that_marks_nothing(self):
"""Older builds print a heading instead of an (audio) on every line."""
listing = (
'[dshow @ 0] DirectShow video devices\n'
'[dshow @ 0] "Integrated Camera"\n'
'[dshow @ 0] Alternative name "@device_pnp_\\..."\n'
'[dshow @ 0] DirectShow audio devices\n'
'[dshow @ 0] "Microphone (Realtek Audio)"\n'
'[dshow @ 0] Alternative name "@device_cm_{ABCD}"\n'
).encode("utf-8")
with self.listing(stderr=listing):
self.assertEqual(audio.list_sources(),
[("@device_cm_{ABCD}", "Microphone (Realtek Audio)")])
def test_two_devices_called_the_same_thing_stay_apart(self):
"""The normal state of a laptop with a headset plugged into it."""
listing = (
'[dshow @ 0] "Microphone" (audio)\n'
'[dshow @ 0] Alternative name "@device_cm_{ONE}"\n'
'[dshow @ 0] "Microphone" (audio)\n'
'[dshow @ 0] Alternative name "@device_cm_{TWO}"\n'
).encode("utf-8")
with self.listing(stderr=listing):
sources = audio.list_sources()
self.assertEqual([identifier for identifier, _ in sources],
["@device_cm_{ONE}", "@device_cm_{TWO}"])
self.assertEqual({name for _, name in sources}, {"Microphone"})
def test_no_ffmpeg_installed(self):
with only_these_tools():
self.assertEqual(audio.list_sources(), [])
self.assertEqual(audio.recording_command(), [])
def test_the_identifier_is_what_the_recorder_is_given_back(self):
with self.listing():
cmd = audio.recording_command(self.MIC)
self.assertEqual(cmd[cmd.index("-f") + 1], "dshow")
self.assertIn(f"audio={self.MIC}", cmd)
def test_no_microphone_named_means_the_first_one_listed(self):
"""dshow has no default device for an empty target to mean."""
with self.listing():
self.assertIn(f"audio={self.MIC}", audio.recording_command())
def test_a_dictation_does_not_run_a_listing_of_its_own(self):
"""Two hundred milliseconds of ffmpeg in front of every key press."""
with self.listing() as run:
audio.list_sources()
audio.recording_command()
audio.recording_command()
self.assertEqual(run.call_count, 1)
def test_opening_the_device_list_asks_again(self):
"""Which is what somebody who has just plugged one in does."""
with self.listing() as run:
audio.list_sources()
audio.list_sources()
self.assertEqual(run.call_count, 2)
def test_a_machine_with_no_microphone_at_all(self):
with self.listing(stderr=b'[dshow @ 0] "Integrated Camera" (video)\n'):
self.assertEqual(audio.recording_command(), [])
def test_an_ffmpeg_that_will_not_run(self):
with only_these_tools("ffmpeg"), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
self.assertEqual(audio.list_sources(), [])
def test_nothing_offers_the_far_side_of_a_meeting(self):
"""What the speakers play is not a capture device Windows hands out."""
with self.listing():
self.assertEqual(audio.list_monitors(), [])
self.assertEqual(audio.default_monitor(), "")
self.assertEqual(audio.meeting_commands("mic", "sys"), [])
def test_a_meeting_says_what_is_wrong_rather_than_where_to_look(self):
recorder = audio.MeetingRecorder()
failures = []
recorder.failed.connect(failures.append)
with self.listing():
recorder.start(str(self.path("meeting.wav")))
self.assertIn("nothing that records what the speakers", failures[0])
self.assertFalse(recorder.active)
if __name__ == "__main__":
unittest.main()
+135 -1
View File
@@ -10,13 +10,20 @@ import contextlib
import io
import json
import unittest
import webbrowser
from typing import ClassVar
from unittest import mock
from dikte import audio
from dikte import cli
from dikte import config as cfg
from dikte import ggml
from dikte import hotkey
from dikte import hub
from dikte import ipc
from tests.support import DikteTest, fake_urlopen
from dikte import paste
from dikte import update
from tests.support import DikteTest, fake_urlopen, only_these_tools, url_error
class Options:
@@ -417,6 +424,68 @@ class Providers(DikteTest):
self.assertIn("Groq", out)
class Updates(DikteTest):
"""`dikte update` looks, says what it found, and installs nothing."""
RELEASE: ClassVar[dict] = {
"tag_name": "v9.9.9",
"html_url": "https://github.com/yusufipk/dikte/releases/tag/v9.9.9",
}
def setUp(self):
super().setUp()
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
# Nothing here may reach a browser, whatever the answer turns out to be.
self.opened = []
self.patch_attr(webbrowser, "open", self.opened.append)
def run_update(self, reply, **values):
with fake_urlopen(reply), captured() as (out, err):
code = cli.cmd_update(Options(open=False, **values))
return code, out.getvalue(), err.getvalue()
def test_a_newer_release_is_named_with_its_page(self):
code, out, _ = self.run_update(self.RELEASE)
self.assertEqual(code, 0)
self.assertIn("9.9.9", out)
self.assertIn(self.RELEASE["html_url"], out)
def test_this_build_being_the_newest_is_not_a_failure(self):
code, out, _ = self.run_update({"tag_name": f"v{cli.__version__}"})
self.assertEqual(code, 0)
self.assertIn("newest", out)
def test_the_json_answer_says_both_numbers(self):
code, out, _ = self.run_update(self.RELEASE, json=True)
answer = json.loads(out)
self.assertTrue(answer["update"])
self.assertEqual(answer["latest"], "9.9.9")
self.assertEqual(answer["current"], cli.__version__)
def test_github_being_unreachable_is_a_failure_with_a_reason(self):
with fake_urlopen(url_error("no route to host")), captured() as (_, err):
code = cli.cmd_update(Options(open=False))
self.assertEqual(code, 1)
self.assertIn("api.github.com", err.getvalue())
def test_the_browser_is_opened_only_when_asked_and_only_when_there_is_one(self):
self.run_update(self.RELEASE)
self.assertEqual(self.opened, [])
with fake_urlopen({"tag_name": f"v{cli.__version__}"}), captured():
cli.cmd_update(Options(open=True))
self.assertEqual(self.opened, [])
with fake_urlopen(self.RELEASE), captured():
cli.cmd_update(Options(open=True))
self.assertEqual(self.opened, [self.RELEASE["html_url"]])
def test_the_answer_is_written_down_for_the_application(self):
"""A check at a terminal is a check; the tray must not go and ask the
same question an hour later."""
self.run_update(self.RELEASE)
self.assertEqual(update.state()["version"], "9.9.9")
self.assertFalse(update.due())
class Doctor(DikteTest):
"""One pass over everything the settings window checks behind its buttons."""
@@ -434,6 +503,30 @@ class Doctor(DikteTest):
self.assertIn("OpenRouter key, cleaning up on some/model",
self.run_doctor(as_json=False, cleanup_model="some/model"))
def test_it_asks_after_the_programs_this_desktop_actually_uses(self):
"""A missing ydotool on a Mac is a red mark with nothing behind it."""
with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS):
mac = self.run_doctor()["programs"]
with mock.patch.object(cli.paste, "desktop", return_value=paste.WAYLAND):
wayland = self.run_doctor()["programs"]
self.assertIn("pbcopy", mac)
self.assertNotIn("ydotool", mac)
self.assertIn("ydotool", wayland)
self.assertIn("ffmpeg", mac) # the one every system records through
def test_a_system_that_shells_out_for_neither_half_is_asked_for_neither(self):
# shutil.which is faked as well as the platform: the real one reads
# sys.platform too, and reaches for a Windows API this machine has not
# got the moment it is told it is on Windows.
with mock.patch.object(cli.paste, "desktop", return_value=paste.WINDOWS), \
only_these_tools("ffmpeg"), \
mock.patch.object(cli.sys, "platform", "win32"):
programs = self.run_doctor()["programs"]
self.assertNotIn("", programs)
self.assertEqual([name for name in ("wl-copy", "ydotool", "pactl",
"pw-record", "kwriteconfig6")
if name in programs], [])
def test_cleanup_on_a_cli_is_a_question_about_the_program(self):
reply = self.run_doctor(cleanup_provider="codex",
cleanup_codex_model="gpt-5.4")
@@ -445,6 +538,25 @@ class Doctor(DikteTest):
cleanup_codex_model="gpt-5.4"))
class Devices(DikteTest):
def test_a_machine_with_nothing_names_its_own_missing_program(self):
"""The Windows README sends people here, and pactl is not on it."""
for here, expected in ((audio.DSHOW, "ffmpeg"),
(audio.PULSE, "pulseaudio-utils")):
with self.subTest(sound=expected):
with mock.patch.object(cli.audio, "sound", return_value=here), \
mock.patch.object(cli.audio, "list_sources",
return_value=[]), \
mock.patch.object(cli.audio, "list_monitors",
return_value=[]), \
mock.patch.object(cli.audio, "default_monitor",
return_value=""), \
captured() as (out, _err):
code = cli.cmd_devices(Options(json=True))
self.assertEqual(code, 1)
self.assertIn(expected, json.loads(out.getvalue())["error"])
class Finding(DikteTest):
def test_no_history_at_all(self):
self.assertIsNone(cli._find_history("last"))
@@ -623,5 +735,27 @@ class Replies(DikteTest):
self.assertFalse(launched.called)
class TranscribeRunsHere(DikteTest):
"""`dikte transcribe` runs in this process, not in the instance."""
def test_the_local_servers_are_handed_the_settings_first(self):
# The GUI does this at startup; a CLI run has no GUI to have done it,
# and without it the whisper server holds an empty model name.
wav = self.path("clip.wav")
wav.write_bytes(b"RIFF not really audio")
self.write_config({"local_model": "ggml-base.bin"})
self.addCleanup(ggml.whisper.configure,
model="", threads=0, gpu=True, binary="")
opts = cli.build_parser().parse_args(["transcribe", str(wav)])
with mock.patch.object(cli.filetranscribe, "FileTranscriber"), \
mock.patch.object(cli, "_headless",
return_value={"error": "stopped"}), \
captured():
cli.cmd_transcribe(opts)
self.assertEqual(ggml.whisper.settings()["model"], "ggml-base.bin")
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -8,6 +8,7 @@ config and now shadows the default.
import json
import os
import sys
import unittest
from unittest import mock
@@ -89,6 +90,8 @@ class Saving(DikteTest):
cfg.Config().save()
self.assertTrue(cfg.CONFIG_FILE.exists())
@unittest.skipIf(sys.platform == "win32",
"NTFS access is decided by ACLs, not by the mode bits")
def test_the_file_is_readable_by_nobody_else(self):
"""It holds two API keys."""
cfg.Config().save()
@@ -491,6 +494,9 @@ class ReadyToRun(DikteTest):
def setUp(self):
super().setUp()
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
# A machine Dikte is actually installed on would otherwise answer for
# the "missing program" below through the real install record.
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
def install(self, name):
path = ggml.whisper_model_path(name)
+68 -4
View File
@@ -214,10 +214,22 @@ class ChunkSeconds(DikteTest):
self.assertEqual(ft.chunk_seconds(self.file(1024), 600), 0.0)
def test_a_file_over_the_limit_is_cut_by_what_it_measured(self):
# Twice the limit over an hour, so a little under half an hour fits.
seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600)
self.assertGreater(seconds, 1500)
self.assertLess(seconds, 1800)
# Twice the limit over twenty minutes, so a little under ten fits.
seconds = ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 1200)
self.assertGreater(seconds, 500)
self.assertLess(seconds, 600)
def test_a_chunk_is_never_more_audio_than_a_request_can_outlive(self):
"""An hour in one request is a 502 from the gateway, whatever it weighs."""
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 3600),
ft.MAX_CHUNK_SECONDS)
def test_a_small_file_that_is_still_hours_long_is_cut_on_the_clock(self):
self.assertEqual(ft.chunk_seconds(self.file(1024), 7200),
ft.MAX_CHUNK_SECONDS)
def test_a_file_short_enough_on_both_counts_is_not_cut(self):
self.assertEqual(ft.chunk_seconds(self.file(1024), ft.MAX_CHUNK_SECONDS), 0.0)
def test_a_file_with_no_length_is_left_whole(self):
self.assertEqual(ft.chunk_seconds(self.file(ft.UPLOAD_LIMIT * 2), 0), 0.0)
@@ -378,6 +390,58 @@ class Transcriber(DikteTest):
worker.stop()
self.assertTrue(worker._abort.aborted)
def test_a_chunk_is_given_longer_to_answer_than_a_dictation(self):
"""A quarter hour of audio is not a sentence: the default would cut it off."""
worker = ft.FileTranscriber(self.conf)
with mock.patch.object(ft, "_to_wav", side_effect=lambda *a: self.source), \
mock.patch.object(ft, "_to_mp3",
side_effect=lambda path, *a, **k: path), \
mock.patch.object(ft.shutil, "which", return_value="/usr/bin/ffmpeg"), \
mock.patch.object(api, "transcribe", return_value="text") as call:
worker._work(self.source, False, False)
self.assertEqual(call.call_args.kwargs["timeout"], ft.HOSTED_TIMEOUT)
def test_a_gateway_having_a_bad_moment_is_asked_again(self):
with mock.patch.object(ft.FileTranscriber, "_wait"):
done, failures, progress, _ = self.run_chain(
fail=[api.ApiError("HTTP 502: timeout", 502), "raw text"])
self.assertEqual(failures, [])
self.assertEqual(done[0][0], "raw text")
self.assertTrue(any("Trying again" in message for message in progress))
def test_a_rejected_key_is_not_asked_again(self):
"""Trying again with the same key is only a slower way to fail."""
call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401))
with mock.patch.object(ft.FileTranscriber, "_wait"):
_, failures, _, _ = self.run_chain(fail=call)
self.assertEqual(call.call_count, 1)
self.assertIn("rejected", failures[0])
def test_a_chunk_is_given_up_on_after_the_last_try(self):
call = mock.Mock(side_effect=api.ApiError("HTTP 502: timeout", 502))
with mock.patch.object(ft.FileTranscriber, "_wait"):
_, failures, _, _ = self.run_chain(fail=call)
self.assertEqual(call.call_count, ft.RETRIES)
self.assertIn("502", failures[0])
def test_what_was_heard_before_the_failure_is_still_handed_over(self):
"""An hour already transcribed is not thrown away over the chunk after it."""
boom = api.ApiError("HTTP 502: timeout", 502)
with mock.patch.object(ft.FileTranscriber, "_wait"), \
mock.patch.object(ft.FileTranscriber, "_chunks",
side_effect=lambda wav, *a: [(wav, 0.0), (wav, 10.0)]):
done, failures, _, _ = self.run_chain(
fail=["first half"] + [boom] * ft.RETRIES)
self.assertEqual(done[0][0], "first half")
self.assertIn("502", failures[0])
def test_nothing_heard_at_all_is_a_plain_failure(self):
call = mock.Mock(side_effect=api.ApiError("rejected the API key", 401))
with mock.patch.object(ft.FileTranscriber, "_wait"):
done, failures, _, _ = self.run_chain(fail=call)
self.assertEqual(done, [])
self.assertEqual(failures[0], "rejected the API key")
def test_a_second_start_while_one_is_running_is_ignored(self):
worker = ft.FileTranscriber(self.conf)
worker._thread = mock.Mock(is_alive=lambda: True)
+94
View File
@@ -15,6 +15,7 @@ import tarfile
import textwrap
import threading
import time
import zipfile
from unittest import mock
from dikte import ggml
@@ -77,6 +78,15 @@ def tarball(entries):
return buf.getvalue()
def zipball(entries):
"""A .zip laid out the way the Windows releases are."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as bundle:
for name, content in entries.items():
bundle.writestr(name, content)
return buf.getvalue()
class Local(DikteTest):
"""A test with its own bin, models and cache directories."""
@@ -691,3 +701,87 @@ class Sizes(DikteTest):
self.assertEqual(ggml.human_size(512), "512 B")
self.assertEqual(ggml.human_size(574041195), "547.4 MB")
self.assertEqual(ggml.human_size(3_095_033_483), "2.9 GB")
# --- Windows ----------------------------------------------------------------
class WindowsAssets(Local):
"""Which archive a Windows machine is handed."""
def setUp(self):
super().setUp()
self.patch_attr(sys, "platform", "win32")
self.patch_attr(ggml, "_arch", lambda: "x64")
def test_whisper_prefers_the_blas_build(self):
# On a plain CPU it transcribes about twice as fast as the stock one.
self.assertEqual(ggml._wanted_assets(ggml.WHISPER),
("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip"))
def test_llama_takes_the_vulkan_build_when_there_is_a_loader(self):
self.patch_attr(ggml, "_has_vulkan", lambda: True)
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
("bin-win-vulkan-x64.zip", "bin-win-cpu-x64.zip"))
def test_llama_falls_back_to_the_cpu_build_without_one(self):
self.patch_attr(ggml, "_has_vulkan", lambda: False)
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
("bin-win-cpu-x64.zip",))
def test_an_arm_machine_is_not_handed_the_x64_build(self):
self.patch_attr(ggml, "_arch", lambda: "arm64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
self.assertEqual(ggml._wanted_assets(ggml.LLAMA),
("bin-win-cpu-arm64.zip",))
def test_an_arm_machine_is_handed_the_x64_whisper_anyway(self):
"""whisper.cpp publishes no arm64 build for Windows: the release has
Win32 and x64 and nothing else, so emulated is the only local option
a Snapdragon has. Pinned here so that a release which does start
publishing one is noticed rather than quietly ignored."""
self.patch_attr(ggml, "_arch", lambda: "arm64")
self.assertEqual(ggml._wanted_assets(ggml.WHISPER),
("whisper-blas-bin-x64.zip", "whisper-bin-x64.zip"))
class InstallOnWindows(Local):
"""The Windows releases are zips, and the binary carries .exe."""
def setUp(self):
super().setUp()
self.patch_attr(sys, "platform", "win32")
self.patch_attr(ggml, "_arch", lambda: "x64")
self.archive = zipball({
"Release/whisper-server.exe": b"MZ not really a program",
"Release/whisper.dll": b"not really a library",
})
def release(self, *names):
digest = hashlib.sha256(self.archive)
return {"tag_name": "v1.9.1", "assets": [
{"name": name, "browser_download_url": f"https://example.invalid/{name}",
"size": 10, "digest": "sha256:" + digest.hexdigest()}
for name in names]}
def test_the_zip_lands_and_the_exe_inside_it_is_found(self):
with serving(self.release("whisper-blas-bin-x64.zip"), self.archive):
path = ggml.install_program(ggml.WHISPER)
self.assertTrue(path.endswith("whisper-server.exe"))
self.assertTrue(os.path.isfile(path))
self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path),
"whisper.dll")))
self.assertEqual(ggml.installed_program(ggml.WHISPER), path)
def test_the_blas_build_is_the_one_fetched_when_both_are_offered(self):
listing = self.release("whisper-bin-x64.zip", "whisper-blas-bin-x64.zip")
with serving(listing, self.archive) as calls:
ggml.install_program(ggml.WHISPER)
urls = [call.args[0].full_url for call in calls.call_args_list]
self.assertTrue(urls[1].endswith("whisper-blas-bin-x64.zip"))
def test_a_release_with_nothing_for_windows_says_so(self):
with fake_urlopen(json_body(self.release("whisper-bin-ubuntu-x64.tar.gz"))):
with self.assertRaises(ggml.LocalError) as caught:
ggml.install_program(ggml.WHISPER)
self.assertIn("this machine", str(caught.exception))
+203
View File
@@ -2,10 +2,14 @@
import contextlib
import os
import queue
import subprocess
import time
import unittest
from unittest import mock
from PyQt6.QtCore import Qt
from dikte import config as cfg
from dikte import hotkey
from tests.support import DikteTest, FakeCompleted, linux_only
@@ -753,5 +757,204 @@ class MacChooser(DikteTest):
self.assertFalse(hotkey.valid_shortcut("Cmd+Space"))
# --- Windows ----------------------------------------------------------------
class ParseWindowsShortcut(unittest.TestCase):
def test_the_default(self):
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Space"),
(hotkey.WIN_MODS["ctrl"], 0x20))
def test_case_and_spacing_do_not_matter(self):
self.assertEqual(hotkey.parse_windows_shortcut(" ctrl + SPACE "),
hotkey.parse_windows_shortcut("Ctrl+Space"))
def test_several_modifiers_are_one_number(self):
modifiers, key = hotkey.parse_windows_shortcut("Ctrl+Shift+M")
self.assertEqual(modifiers,
hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"])
self.assertEqual(key, hotkey.WIN_KEYS["m"])
def test_the_synonyms_land_on_one_number(self):
for name in ("meta", "super", "win"):
with self.subTest(name=name):
self.assertEqual(hotkey.parse_windows_shortcut(f"{name}+space"),
(hotkey.WIN_MODS["win"], 0x20))
self.assertEqual(hotkey.parse_windows_shortcut("Control+Space"),
hotkey.parse_windows_shortcut("Ctrl+Space"))
def test_a_key_on_its_own(self):
self.assertEqual(hotkey.parse_windows_shortcut("F9"),
(0, hotkey.WIN_KEYS["f9"]))
def test_modifiers_with_no_key(self):
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+Alt"), (None, None))
def test_a_key_nobody_mapped(self):
self.assertEqual(hotkey.parse_windows_shortcut("Ctrl+F13"), (None, None))
def test_something_that_is_not_even_a_string(self):
self.assertEqual(hotkey.parse_windows_shortcut(None), (None, None))
class FakeWinHotkeys:
"""user32 and kernel32, as much of both as the listener calls.
The message queue is a real queue: GetMessageW blocks on it the way the
real one blocks on the thread's, so the listener runs its actual loop and
a test presses the key by posting the message a press would.
"""
def __init__(self):
self.registered = {} # identifier -> (modifiers, key)
self.refused = set() # (modifiers, key) another program holds
self.unregistered = []
self.queue = queue.Queue()
# --- user32
def RegisterHotKey(self, hwnd, identifier, modifiers, key):
if (modifiers & ~hotkey.WIN_MOD_NOREPEAT, key) in self.refused:
return 0
self.registered[identifier] = (modifiers, key)
return 1
def UnregisterHotKey(self, hwnd, identifier):
self.unregistered.append(identifier)
self.registered.pop(identifier, None)
return 1
def PeekMessageW(self, reference, hwnd, low, high, remove):
return 0
def GetMessageW(self, reference, hwnd, low, high):
kind, wparam = self.queue.get()
if kind == hotkey.WM_QUIT:
return 0
message = reference._obj
message.message = kind
message.wParam = wparam
return 1
def PostThreadMessageW(self, thread_id, message, wparam, lparam):
self.queue.put((message, wparam))
return 1
# --- kernel32
def GetCurrentThreadId(self):
return 1
# --- the keyboard
def press(self, identifier):
self.queue.put((hotkey.WM_HOTKEY, identifier))
class WinListener(DikteTest):
"""What the listener asks Windows for, without a Windows to ask."""
def setUp(self):
super().setUp()
self.api = FakeWinHotkeys()
self.patch_attr(hotkey, "_win_input", lambda: (self.api, self.api))
self.addCleanup(hotkey._REGISTERED.clear)
self.listener = hotkey.WinHotkey()
self.addCleanup(self.listener.stop)
self.failures = []
# Direct, because the emits come from the listener's own thread and
# there is no event loop here to carry a queued one across.
self.listener.failed.connect(self.failures.append,
Qt.ConnectionType.DirectConnection)
@staticmethod
def settles(seen, count=1):
"""The signals arrive from the listener's own thread, not this one."""
deadline = time.monotonic() + 2
while len(seen) < count and time.monotonic() < deadline:
time.sleep(0.01)
return seen
def test_every_binding_is_registered_with_its_modifiers(self):
self.assertTrue(self.listener.start({"toggle": "Ctrl+Space",
"cancel": "Ctrl+Shift+Space"}))
norepeat = hotkey.WIN_MOD_NOREPEAT
self.assertEqual(self.api.registered, {
1: (hotkey.WIN_MODS["ctrl"] | norepeat, 0x20),
2: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"] | norepeat, 0x20),
})
def test_what_landed_is_what_the_status_line_shows(self):
self.listener.start({"toggle": "Ctrl+Space"})
self.assertEqual(hotkey._REGISTERED,
{hotkey.DESKTOP_ID: "Ctrl+Space"})
def test_a_press_arrives_under_the_name_it_was_registered_as(self):
seen = []
self.listener.triggered.connect(seen.append,
Qt.ConnectionType.DirectConnection)
self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"})
self.api.press(2)
self.assertEqual(self.settles(seen), ["cancel"])
def test_a_held_combination_is_reported_and_the_rest_still_land(self):
self.api.refused = {(hotkey.WIN_MODS["ctrl"], 0x20)}
started = self.listener.start({"toggle": "Ctrl+Space",
"cancel": "Ctrl+Shift+Space"})
self.assertTrue(started)
self.assertIn("Ctrl+Space", self.settles(self.failures)[0])
self.assertEqual(list(self.api.registered), [2])
def test_an_unparsable_binding_is_reported(self):
self.assertFalse(self.listener.start({"toggle": "Ctrl+F13"}))
self.assertIn("Ctrl+F13", self.failures[0])
def test_nothing_but_empty_bindings_does_not_start(self):
self.assertFalse(self.listener.start({"toggle": "", "cancel": ""}))
self.assertFalse(self.listener.running)
def test_stop_lets_go_of_everything(self):
self.listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Shift+Space"})
self.listener.stop()
self.assertEqual(self.api.registered, {})
self.assertEqual(hotkey._REGISTERED, {})
self.assertFalse(self.listener.running)
def test_a_second_start_is_a_clean_slate(self):
self.listener.start({"toggle": "Ctrl+Space"})
self.assertTrue(self.listener.start({"toggle": "Ctrl+Shift+Space"}))
self.assertEqual(self.api.registered,
{1: (hotkey.WIN_MODS["ctrl"] | hotkey.WIN_MODS["shift"]
| hotkey.WIN_MOD_NOREPEAT, 0x20)})
class WindowsChooser(DikteTest):
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(hotkey.sys, "platform", "win32"))
self.addCleanup(hotkey._REGISTERED.clear)
def test_the_listener_is_the_windows_hotkey_service(self):
self.assertIsInstance(hotkey.listener(), hotkey.WinHotkey)
def test_a_combination_is_checked_against_the_windows_table(self):
self.assertTrue(hotkey.valid_shortcut("Ctrl+Space"))
self.assertFalse(hotkey.valid_shortcut("Ctrl+F13"))
def test_no_registry_to_write_into_and_no_restart_to_wait_for(self):
self.assertFalse(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
self.assertEqual(hotkey.desktop_name(), "Windows")
def test_installing_records_it_rather_than_writing_anything(self):
with mock.patch.object(hotkey.subprocess, "run") as run:
ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
run.assert_not_called()
self.assertTrue(ok)
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space")
hotkey.remove_shortcut()
self.assertIsNone(hotkey.shortcut_status())
def test_no_list_of_conflicts_to_read(self):
"""Not even KDE's file, which a dual-boot home directory could hold."""
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -9,12 +9,14 @@ has to notice AppImageLauncher's entry, which is not under the name ours is.
import os
import pathlib
import plistlib
import re
import sys
import tempfile
import unittest
from unittest import mock
from dikte import integrate
from tests.support import posix_only
class Frozen:
@@ -75,6 +77,7 @@ class WhatToStart(unittest.TestCase):
def test_a_checkout_names_this_interpreter_and_the_entry_point(self):
self.assertFalse(integrate.packaged())
@posix_only
def test_an_appimage_names_the_file_and_not_the_mount(self):
"""The mount is a fresh /tmp path every run; a shortcut written to it
would work until the next login and never again."""
@@ -83,6 +86,7 @@ class WhatToStart(unittest.TestCase):
self.assertEqual(str(integrate.target()),
"/home/someone/Downloads/Dikte.AppImage")
@posix_only
def test_a_mac_names_the_bundle_and_not_the_executable_inside_it(self):
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
platform="darwin"):
@@ -95,6 +99,7 @@ class WhatToStart(unittest.TestCase):
class BundledTools(unittest.TestCase):
"""The ffmpeg the disk image carries, and how anything finds it."""
@posix_only
def test_a_mac_looks_beside_the_bundle_not_beside_the_executable(self):
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
platform="darwin"):
@@ -213,6 +218,7 @@ class Certificates(unittest.TestCase):
self.assertIsNone(integrate.use_system_certificates())
@posix_only
class Linux(Home):
def install(self, appimage, force=False):
with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),
@@ -348,6 +354,7 @@ class Linux(Home):
self.assertEqual(integrate.ensure(), [])
@posix_only
class MacOS(Home):
def agent(self):
return self.home / "Library/LaunchAgents/io.github.yusufipk.dikte.plist"
@@ -358,6 +365,12 @@ class MacOS(Home):
mock.patch.object(integrate, "_launchctl_reload"):
return integrate.install(force=force)
def remove(self):
with Frozen("/Applications/Dikte.app/Contents/MacOS/Dikte",
home=self.home, platform="darwin"), \
mock.patch("subprocess.run"):
return integrate.remove()
def test_it_writes_a_login_item_and_the_command(self):
app = self.home / "Applications" / "Dikte.app"
(app / "Contents/MacOS").mkdir(parents=True)
@@ -419,6 +432,123 @@ class MacOS(Home):
self.install(app)
self.assertIn("install-mac.sh", command.read_text())
def test_removing_takes_back_the_login_item_and_command_it_wrote(self):
app = self.home / "Applications" / "Dikte.app"
(app / "Contents/MacOS").mkdir(parents=True)
self.install(app)
command = self.home / ".local/bin/dikte"
self.assertEqual(self.remove(), [self.agent(), command])
self.assertFalse(self.agent().exists())
self.assertFalse(command.exists())
def test_removing_leaves_another_installers_command_alone(self):
command = self.home / ".local/bin/dikte"
command.parent.mkdir(parents=True)
command.write_text("#!/bin/sh\n# Written by install-mac.sh.\n"
"exec /usr/local/bin/python3 /src/__main__.py \"$@\"\n")
self.assertEqual(self.remove(), [])
self.assertIn("install-mac.sh", command.read_text())
class Windows(unittest.TestCase):
"""The half of the Windows install the setup program cannot do.
It writes the Start Menu entry, the command and the uninstaller as it runs,
and asks once whether Dikte should start at sign-in. Changing that answer
afterwards is what is left here, and the value is faked rather than the
registry, so that all of it is read on every platform the tests run on.
"""
def setUp(self):
self.value = ""
for name, function in (("_run_entry", lambda: self.value),
("_write_run_entry", self._write),
("_delete_run_entry", self._delete)):
patch = mock.patch.object(integrate, name, function)
patch.start()
self.addCleanup(patch.stop)
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.installed = pathlib.Path(self.tmp.name).resolve()
self.app = self.installed / "Dikte.exe"
self.app.write_text("")
def _write(self, command):
self.value = command
def _delete(self):
there, self.value = bool(self.value), ""
return there
def install(self, force=False):
with Frozen(str(self.app), platform="win32"):
return integrate.install(force=force)
def remove(self):
with Frozen(str(self.app), platform="win32"):
return integrate.remove()
def test_the_windowed_executable_is_what_starts_at_sign_in(self):
"""The console one is what the `dikte` command runs, and a sign-in that
started that would open a console window nobody asked for."""
with Frozen(str(self.installed / "dikte-cli.exe"), platform="win32"):
self.assertEqual(integrate.target(), self.app)
def test_a_start_does_not_turn_it_on_for_somebody_who_said_no(self):
self.assertEqual(self.install(), [])
self.assertEqual(self.value, "")
def test_typing_it_turns_starting_at_sign_in_on(self):
self.assertEqual(len(self.install(force=True)), 1)
self.assertEqual(self.value, f'"{self.app}"')
def test_an_installation_that_moved_is_pointed_at_where_it_is_now(self):
self.value = '"D:\\Dikte\\Dikte.exe"'
self.assertEqual(len(self.install()), 1)
self.assertEqual(self.value, f'"{self.app}"')
def test_running_it_again_changes_nothing(self):
self.install(force=True)
self.assertEqual(self.install(), [])
def test_removing_stops_it_starting_and_says_so_once(self):
self.install(force=True)
self.assertEqual(len(self.remove()), 1)
self.assertEqual(self.value, "")
self.assertEqual(self.remove(), [])
class WindowsExecutableNames(unittest.TestCase):
"""The two Windows executables, read out of the files that name them.
Windows matches a filename without regard to its case, so Dikte.exe and
dikte.exe are one file in one directory and whichever was written second is
the only one installed. Nothing else here would catch that: these tests and
the builds that check the packaging both run on filesystems where the two
names are two files.
"""
root = pathlib.Path(__file__).resolve().parent.parent
def executables(self):
"""What the spec calls each one, in the order it builds them."""
spec = (self.root / "packaging" / "dikte.spec").read_text()
return [re.search(r'name="(.*?)"', block).group(1)
for block in spec.split("EXE(")[1:]]
def test_the_two_are_more_than_a_case_apart(self):
windowed, console = self.executables()
self.assertNotEqual(windowed.lower(), console.lower())
def test_the_command_runs_the_console_one(self):
"""The setup writes the shim, so it is the setup that has to be right."""
console = self.executables()[1]
setup = (self.root / "packaging" / "dikte.iss").read_text()
self.assertIn(f"{{app}}\\{console}.exe", setup)
if __name__ == "__main__":
unittest.main()
+10 -4
View File
@@ -7,6 +7,8 @@ answers by saying nothing at all.
import json
import os
import pathlib
import shlex
import sys
import unittest
from unittest import mock
@@ -57,13 +59,17 @@ class FakeSocket:
class Paths(unittest.TestCase):
def test_script_path_points_at_dikte(self):
self.assertTrue(ipc.script_path().endswith("dikte/__main__.py"))
# By its parts rather than as a string: the separator is a backslash on
# Windows, and the path is what a shortcut there runs too.
path = pathlib.Path(ipc.script_path())
self.assertEqual(path.parts[-2:], ("dikte", "__main__.py"))
self.assertTrue(os.path.exists(ipc.script_path()))
def test_the_shortcut_command_runs_it_with_this_interpreter(self):
command = ipc.command_for("toggle")
self.assertTrue(command.startswith(sys.executable))
self.assertTrue(command.endswith(" toggle"))
# Read back through the same quoting it went out with: a Windows path
# is spelled with backslashes and comes out of the join quoted.
self.assertEqual(shlex.split(ipc.command_for("toggle")),
[sys.executable, ipc.script_path(), "toggle"])
def test_a_packaged_build_names_itself_and_no_interpreter(self):
"""There is no __main__.py on disk in one, and sys.executable is the
+187
View File
@@ -13,6 +13,7 @@ is checked on a Mac and the macOS half on Linux, and a change to the chooser
cannot quietly break the platform nobody is sitting at.
"""
import ctypes
import os
import pathlib
import subprocess
@@ -56,6 +57,9 @@ class Chooser(DikteTest):
def test_a_mac(self):
self.assertIs(self.under("darwin"), paste.MACOS)
def test_windows(self):
self.assertIs(self.under("win32"), paste.WINDOWS)
def test_a_mac_running_an_x_server_is_still_a_mac(self):
"""XQuartz sets DISPLAY, and none of X's programs are what pastes here."""
self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS)
@@ -440,6 +444,45 @@ class MacOS(ClipboardContract, DikteTest):
self.assertFalse(paste.paste_ready())
class MacPasteGoesWhereTheDictationStarted(MacOS):
"""The keys land in the frontmost window, so the front is what decides
where a transcript ends up."""
def setUp(self):
super().setUp()
from dikte import mac_window
self.mac_window = mac_window
self.activated = []
self.patch_attr(mac_window, "activate", self.activated.append)
def frontmost(self, dikte_is):
self.patch_attr(self.mac_window, "is_frontmost", lambda: dikte_is)
def test_a_dikte_that_took_the_front_hands_it_back_before_pressing(self):
self.frontmost(True)
paste.press("cmd+v", focus=4242)
self.assertEqual(self.activated, [4242])
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
def test_another_application_in_front_is_where_the_user_went_and_is_left(self):
self.frontmost(False)
paste.press("cmd+v", focus=4242)
self.assertEqual(self.activated, [])
def test_a_run_that_remembered_nobody_asks_nothing(self):
self.frontmost(True)
paste.press("cmd+v")
self.assertEqual(self.activated, [])
def test_the_front_is_handed_back_only_once_macos_trusts_dikte(self):
"""Pulling the user out of their window and then failing to type would
be the worst of both."""
self.frontmost(True)
self.api.trusted = False
with self.assertRaises(paste.PasteError):
paste.press("cmd+v", focus=4242)
self.assertEqual(self.activated, [])
class MacClipboardSnapshot(DikteTest):
def test_every_native_type_is_restored_and_the_files_are_removed(self):
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
@@ -465,5 +508,149 @@ class MacClipboardSnapshot(DikteTest):
self.assertFalse(os.path.exists(directory))
class FakeWin32:
"""user32 and kernel32, as much of both as paste.py calls.
The clipboard is a string held here. A read materialises it as this
machine's own wide characters, which is what wstring_at reads wherever the
test runs; a write arrives as the UTF-16 the real clipboard is handed, so
what the code sent is exactly what is checked.
"""
def __init__(self):
self.text = None
self.buffers = {}
self.next_handle = 1
self.pressed = [] # (virtual key, flags), in the order sent
self.send_result = None # None: report every event as delivered
self.held = False # another program has the clipboard open
self.out_of_memory = False
def _keep(self, buffer):
handle = self.next_handle
self.next_handle += 1
self.buffers[handle] = buffer
return handle
# --- user32
def OpenClipboard(self, owner):
return 0 if self.held else 1
def CloseClipboard(self):
return 1
def EmptyClipboard(self):
self.text = None
return 1
def GetClipboardData(self, fmt):
if self.text is None:
return 0
return self._keep(ctypes.create_unicode_buffer(self.text))
def SetClipboardData(self, fmt, handle):
raw = self.buffers[handle].raw
self.text = raw.decode("utf-16-le").split("\x00", 1)[0]
return handle
def SendInput(self, count, inputs, size):
self.pressed.extend((entry.union.ki.wVk, entry.union.ki.dwFlags)
for entry in inputs)
return count if self.send_result is None else self.send_result
# --- kernel32
def GlobalAlloc(self, flags, size):
if self.out_of_memory:
return 0
return self._keep(ctypes.create_string_buffer(size))
def GlobalLock(self, handle):
buffer = self.buffers.get(handle)
return ctypes.addressof(buffer) if buffer else 0
def GlobalUnlock(self, handle):
return 1
def GlobalFree(self, handle):
self.buffers.pop(handle, None)
return 1
class Windows(Standing, DikteTest):
"""Windows shells out to nothing: both halves are calls into the system."""
platform = "win32"
here = paste.WINDOWS
def setUp(self):
super().setUp()
self.api = FakeWin32()
self.patch_attr(paste, "_win_api", lambda: (self.api, self.api))
self.patch_attr(paste.time, "sleep", lambda seconds: None)
def test_what_is_copied_is_what_reads_back(self):
paste.copy("ığüşöç İ")
self.assertEqual(paste.read_clipboard(), "ığüşöç İ".encode("utf-8"))
def test_an_empty_clipboard_reads_as_empty_text(self):
self.assertEqual(paste.read_clipboard(), b"")
def test_what_was_saved_goes_back_after_the_paste(self):
paste.copy("mine")
saved = paste.read_clipboard()
paste.copy("the dictation")
paste.copy_bytes(saved)
self.assertEqual(self.api.text, "mine")
def test_a_copy_that_fails_leaves_what_was_there(self):
"""EmptyClipboard is the point of no return, so nothing runs after it."""
paste.copy("mine")
for failure in ("out_of_memory", "held"):
with self.subTest(failure=failure):
setattr(self.api, failure, True)
with self.assertRaises(paste.PasteError):
paste.copy("the dictation")
self.assertEqual(self.api.text, "mine")
setattr(self.api, failure, False)
def test_the_handle_is_not_leaked_when_the_copy_fails(self):
self.api.held = True
with self.assertRaises(paste.PasteError):
paste.copy("the dictation")
self.assertEqual(self.api.buffers, {})
def test_readiness_asks_for_no_program_and_no_permission(self):
with only_these_tools():
self.assertTrue(paste.paste_ready())
def test_the_keys_go_down_in_order_and_up_in_reverse(self):
paste.press("ctrl+v")
keyup = 0x0002
self.assertEqual(self.api.pressed,
[(0x11, 0), (0x56, 0), (0x56, keyup), (0x11, keyup)])
def test_three_keys(self):
paste.press("ctrl+shift+v")
self.assertEqual([code for code, _ in self.api.pressed],
[0x11, 0x10, 0x56, 0x56, 0x10, 0x11])
def test_the_other_spellings_land_on_the_same_keys(self):
paste.press("super+enter")
first, self.api.pressed = self.api.pressed, []
paste.press("meta+return")
self.assertEqual(self.api.pressed, first)
def test_a_key_nobody_mapped_is_refused_before_anything_is_sent(self):
with self.assertRaises(paste.PasteError):
paste.press("ctrl+f13")
self.assertEqual(self.api.pressed, [])
def test_a_press_the_system_did_not_take_says_so(self):
self.api.send_result = 0
with self.assertRaises(paste.PasteError) as caught:
paste.press("ctrl+v")
self.assertIn("SendInput", str(caught.exception))
if __name__ == "__main__":
unittest.main()
+27 -6
View File
@@ -15,29 +15,50 @@ from dikte import paths
class Directories(unittest.TestCase):
"""Spelled with forward slashes throughout.
A backslash separates on Windows only, and every one of these runs on all
three systems: `as_posix()` is the one spelling they can all be read in.
"""
def test_linux_keeps_them_apart_and_follows_xdg(self):
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c",
"XDG_DATA_HOME": "/d"}):
config_dir, data_dir = paths.directories("linux")
self.assertEqual(str(config_dir), "/c/dikte")
self.assertEqual(str(data_dir), "/d/dikte")
self.assertEqual(config_dir.as_posix(), "/c/dikte")
self.assertEqual(data_dir.as_posix(), "/d/dikte")
def test_linux_without_the_variables_set(self):
with mock.patch.dict(os.environ, {}, clear=True):
config_dir, data_dir = paths.directories("linux")
self.assertTrue(str(config_dir).endswith("/.config/dikte"))
self.assertTrue(str(data_dir).endswith("/.local/share/dikte"))
self.assertTrue(config_dir.as_posix().endswith("/.config/dikte"))
self.assertTrue(data_dir.as_posix().endswith("/.local/share/dikte"))
def test_a_mac_keeps_both_in_application_support(self):
config_dir, data_dir = paths.directories("darwin")
self.assertEqual(config_dir, data_dir)
self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte"))
self.assertTrue(config_dir.as_posix()
.endswith("/Library/Application Support/Dikte"))
def test_a_mac_does_not_read_the_xdg_variables(self):
"""A Mac with them set from some other tool still stores in one place."""
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
config_dir, _ = paths.directories("darwin")
self.assertNotIn("/c", str(config_dir))
self.assertNotIn("/c", config_dir.as_posix())
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
"""Settings roam with the account; several gigabytes must not."""
with mock.patch.dict(os.environ, {"APPDATA": "C:/roam",
"LOCALAPPDATA": "C:/local"}):
config_dir, data_dir = paths.directories("win32")
self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte")
self.assertEqual(data_dir.as_posix(), "C:/local/Dikte")
def test_windows_without_the_variables_set(self):
with mock.patch.dict(os.environ, {}, clear=True):
config_dir, data_dir = paths.directories("win32")
self.assertTrue(config_dir.as_posix().endswith("/AppData/Roaming/Dikte"))
self.assertTrue(data_dir.as_posix().endswith("/AppData/Local/Dikte"))
class OnePlace(unittest.TestCase):
+22
View File
@@ -7,6 +7,8 @@ test blends each icon onto a bar of its own and asks whether anything of it
survives, once over black and once over white.
"""
import pathlib
import struct
import sys
import tempfile
import unittest
@@ -150,6 +152,26 @@ class ApplicationIcon(DikteTest):
self.assertFalse(icon.isNull())
self.assertIn(48, [size.width() for size in icon.availableSizes()])
def test_the_windows_icon_is_one_file_holding_every_size(self):
"""Written by hand, so the header is what a test can be wrong about:
Windows reads the sizes out of the directory at the front rather than
out of the images, and a 256 is written there as a zero."""
with tempfile.TemporaryDirectory() as root:
path = trayicon.write_ico(pathlib.Path(root) / "Dikte.ico")
data = path.read_bytes()
reserved, kind, count = struct.unpack_from("<HHH", data)
self.assertEqual((reserved, kind), (0, 1))
self.assertEqual(count, len(trayicon.ICO_SIZES))
for index, size in enumerate(trayicon.ICO_SIZES):
with self.subTest(size=size):
(width, height, _, _, _, _,
length, offset) = struct.unpack_from("<BBBBHHII", data,
6 + 16 * index)
self.assertEqual((width, height), (size % 256, size % 256))
image = QImage.fromData(data[offset:offset + length])
self.assertEqual((image.width(), image.height()),
(size, size))
if __name__ == "__main__":
unittest.main()
+385 -3
View File
@@ -16,12 +16,16 @@ from PyQt6.QtCore import QPoint, QPointF, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QApplication, QMessageBox
from dikte import audio
from dikte import cleanup
from dikte import config as cfg
from dikte import ggml
from dikte import hotkey
from dikte import ipc
from dikte import overlay as overlay_module
from dikte import paste
from dikte import settings_ui
from dikte import update
from tests.support import DikteTest, only_these_tools
# One application for the whole run; Qt allows no second one.
@@ -100,6 +104,7 @@ CHANGED = {
"pause_shortcut": "Meta+P",
"evdev_hotkey": True,
"history_limit": 50,
"update_check": False,
}
@@ -249,6 +254,31 @@ class Settings(DikteTest):
self.assertEqual(shown, [provider])
self.assertFalse(box.isHidden())
def test_the_update_line_names_the_version_that_is_running(self):
window = self.window(cfg.Config())
self.assertIn(settings_ui.__version__, window.update_status.text())
# Nothing to open until a check has found something to open.
self.assertTrue(window.update_page.isHidden())
def test_a_newer_release_puts_the_page_button_on_screen(self):
window = self.window(cfg.Config())
told = []
window.update_found.connect(told.append)
release = update.Release("9.9.9", "https://example.invalid/9.9.9", "")
window._on_update_checked(release, "")
self.assertIn("9.9.9", window.update_status.text())
self.assertFalse(window.update_page.isHidden())
self.assertEqual(window._release_url, release.url)
# And the tray hears about it from here rather than waiting a day.
self.assertEqual(told, [release])
def test_a_check_that_failed_says_why_and_hands_the_button_back(self):
window = self.window(cfg.Config())
window.update_now.setEnabled(False)
window._on_update_checked(None, "api.github.com answered HTTP 403.")
self.assertIn("403", window.update_status.text())
self.assertTrue(window.update_now.isEnabled())
def test_the_settings_the_window_does_not_show_are_left_alone(self):
"""A tab nobody wrote must not reset what the command line set."""
self.write_config({"silence_db": -42.0, "speech_margin_db": 15.0,
@@ -281,7 +311,7 @@ class Settings(DikteTest):
text = self.shortcut_tab_text(window)
self.assertIn("i3 keeps no shortcut registry", text)
self.assertNotIn("KWin", text)
self.assertIn("__main__.py toggle", text)
self.assertIn(ipc.command_for("toggle"), text)
# Not a choice to offer where it is the only mechanism there is.
self.assertTrue(window.evdev_enabled.isHidden())
self.assertFalse([button for button in
@@ -458,7 +488,7 @@ class MacSettings(Settings):
text = self.shortcut_tab_text(window)
self.assertIn("Dikte asks macOS for these combinations", text)
self.assertNotIn("KWin", text)
self.assertNotIn("__main__.py toggle", text)
self.assertNotIn(ipc.command_for("toggle"), text)
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config())
@@ -482,12 +512,327 @@ class KdeSettings(Settings):
self.assertIn("KWin only reads shortcut settings at startup", text)
self.assertIn("Install as a KDE shortcut", text)
self.assertNotIn("keeps no shortcut registry", text)
self.assertNotIn("__main__.py toggle", text)
self.assertNotIn(ipc.command_for("toggle"), text)
# Here it is a choice: the wait for the next login, or the key press
# reaching the focused application as well.
self.assertFalse(window.evdev_enabled.isHidden())
class FakeAppKit:
"""The Objective-C runtime, answering rather than being asked.
Stands in for what mac_window._appkit() loads, so what the indicator's
window would have been sent can be read off `told` on any machine. The
selectors come back as the names they were registered under, which is what
lets the tests below name the message they mean.
"""
def __init__(self, window=4242, panel=True, mask=0xe):
self.objc = self
self.window, self.panel, self.mask = window, panel, mask
self.told = {}
def objc_getClass(self, name):
return 1 if name == b"NSPanel" else 0
def selector(self, name):
return name.decode()
def shared(self, _class_name, _selector):
return 1
def ask(self, _to, selector):
return self.window if selector == "window" else 0
def ask_unsigned(self, _to, _selector):
return self.mask
def ask_of_class(self, _to, _selector, _klass):
return self.panel
def tell_bool(self, _to, selector, value):
self.told[selector] = value
tell_unsigned = tell_bool
class MacIndicatorWindow(DikteTest):
"""Which of the three AppKit settings the indicator's window is sent.
The nonactivating bit is the one worth a test of its own: it is legal on an
NSPanel and nowhere else, and sending it to a plain NSWindow raises an
Objective-C exception, which through ctypes is not something Python can
catch. It takes the whole process down. `_is_panel` is the only thing
standing between the two, so what it answers has to decide what is sent.
"""
def setUp(self):
super().setUp()
from dikte import mac_window
self.mac_window = mac_window
self.patch_attr(mac_window.QGuiApplication, "platformName",
staticmethod(lambda: "cocoa"))
def told(self, **kwargs):
"""What keep_on_screen sends an indicator, against this AppKit."""
appkit = FakeAppKit(**kwargs)
self.patch_attr(self.mac_window, "_appkit", lambda: appkit)
widget = overlay_module.Overlay()
self.addCleanup(widget.deleteLater)
self.addCleanup(widget.close)
self.answered = self.mac_window.keep_on_screen(widget)
return appkit.told
def test_a_window_that_is_not_a_panel_is_never_sent_the_style_mask(self):
"""The message that would kill the process. The other two still go."""
told = self.told(panel=False)
self.assertTrue(self.answered)
self.assertNotIn("setStyleMask:", told)
self.assertIs(told["setHidesOnDeactivate:"], False)
self.assertEqual(told["setCollectionBehavior:"],
self.mac_window.BEHAVIOUR)
def test_a_panel_without_the_bit_is_sent_the_mask_with_it_added(self):
told = self.told(panel=True, mask=0xe)
self.assertEqual(told["setStyleMask:"],
0xe | self.mac_window.NONACTIVATING_PANEL)
def test_a_panel_that_already_has_the_bit_is_left_alone(self):
"""Every dictation runs this again, and a mask Cocoa did not need is a
window it rebuilds underneath the indicator."""
told = self.told(panel=True,
mask=0xe | self.mac_window.NONACTIVATING_PANEL)
self.assertNotIn("setStyleMask:", told)
def test_a_window_the_view_does_not_have_yet_is_not_messaged(self):
self.assertEqual(self.told(window=0), {})
self.assertFalse(self.answered)
def test_nothing_is_sent_anywhere_but_cocoa(self):
"""Every other platform hands out a winId that means something else
entirely, and messaging it crashes the run."""
self.patch_attr(self.mac_window.QGuiApplication, "platformName",
staticmethod(lambda: "offscreen"))
self.assertEqual(self.told(), {})
self.assertFalse(self.answered)
class GivingTheFrontBack(DikteTest):
"""Opening the microphone brings Dikte to the front, and the window the
user was dictating into goes inactive with the caret in it. Nothing can be
asked of the capture session, so the front is put back afterwards.
The watch is driven by hand here: what matters is what it decides, not how
long Qt takes to tick.
"""
def setUp(self):
super().setUp()
from dikte import app as dikte_module
from dikte import mac_window
self.dikte = dikte_module
self.activated = []
self.patch_attr(mac_window, "activate", self.activate)
self.ticks = []
outer = self
class FakeTimer:
"""Records what it was asked to do and hands over the tick."""
def __init__(self, _parent):
self.interval = None
self.running = False
outer.ticks.append(self)
def setInterval(self, milliseconds):
self.interval = milliseconds
def start(self):
self.running = True
def stop(self):
self.running = False
@property
def timeout(self):
return self
def connect(self, slot):
self.tick = slot
self.patch_attr(dikte_module, "QTimer", FakeTimer)
class BareDikte:
"""As much of the application as this one method touches."""
app = None
_front_watch = None
_the_front = dikte_module.Dikte._the_front
_give_the_front_back = dikte_module.Dikte._give_the_front_back
_stop_watching_the_front = dikte_module.Dikte._stop_watching_the_front
self.bare = BareDikte
def activate(self, pid):
self.activated.append(pid)
return True
def watching(self, was_in_front, dikte_in_front, on=None):
from dikte import mac_window
self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front)
dikte = on if on is not None else self.bare()
dikte._give_the_front_back(was_in_front)
return self.ticks[-1] if self.ticks else None
def test_the_front_goes_back_to_whoever_had_it(self):
watch = self.watching(4242, dikte_in_front=True)
watch.tick()
self.assertEqual(self.activated, [4242])
self.assertTrue(watch.running) # accepted is not the same as landed
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False)
watch.tick()
self.assertFalse(watch.running)
def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self):
watch = self.watching(4242, dikte_in_front=True)
watch.tick()
watch.tick()
self.assertEqual(self.activated, [4242])
self.assertTrue(watch.running)
def test_an_accepted_restore_that_never_lands_still_times_out(self):
watch = self.watching(4242, dikte_in_front=True)
watch.tick()
with mock.patch.object(self.dikte.time, "monotonic",
return_value=self.dikte.time.monotonic() + 60):
watch.tick()
self.assertFalse(watch.running)
self.assertEqual(self.activated, [4242])
def test_a_restore_the_system_refused_is_retried(self):
from dikte import mac_window
self.patch_attr(mac_window, "activate",
lambda pid: self.activated.append(pid) or False)
watch = self.watching(4242, dikte_in_front=True)
watch.tick()
watch.tick()
self.assertEqual(self.activated, [4242, 4242])
self.assertTrue(watch.running)
def test_a_front_that_was_never_taken_is_left_where_it_is(self):
"""The microphone does not always take it, and pulling an application
forward that is already there is one flicker for nothing."""
watch = self.watching(4242, dikte_in_front=False)
watch.tick()
self.assertEqual(self.activated, [])
self.assertTrue(watch.running) # still waiting for the moment
def test_it_gives_up_rather_than_watching_for_ever(self):
watch = self.watching(4242, dikte_in_front=False)
with mock.patch.object(self.dikte.time, "monotonic",
return_value=self.dikte.time.monotonic() + 60):
watch.tick()
self.assertFalse(watch.running)
self.assertEqual(self.activated, [])
def test_a_dictation_started_in_dikte_itself_watches_nothing(self):
"""Settings is a window of ours, and the front is already where it
belongs."""
self.assertIsNone(self.watching(os.getpid(), dikte_in_front=True))
def test_a_second_recording_calls_off_the_watch_the_first_one_left(self):
"""The older watch remembers where the older recording started, and by
now that is the wrong window to be pulling forward."""
dikte = self.bare()
first = self.watching(4242, dikte_in_front=False, on=dikte)
second = self.watching(1111, dikte_in_front=False, on=dikte)
self.assertFalse(first.running)
self.assertTrue(second.running)
second.tick() # and the survivor is the new one
self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: True)
second.tick()
self.assertEqual(self.activated, [1111])
def test_a_recording_nobody_needs_watching_for_still_calls_off_the_old_one(self):
"""Starting the next one from Dikte's own window is not a reason to
leave the last one's watch running."""
dikte = self.bare()
first = self.watching(4242, dikte_in_front=False, on=dikte)
self.watching(os.getpid(), dikte_in_front=False, on=dikte)
self.assertFalse(first.running)
self.assertIsNone(dikte._front_watch)
def test_nobody_in_front_is_nobody_to_go_back_to(self):
self.assertIsNone(self.watching(None, dikte_in_front=True))
def mac_window_module(self):
from dikte import mac_window
return mac_window
class EveryRecordingProtectsTheFront(DikteTest):
"""Three ways in, dictation, agent and meeting, and all three open the same
avfoundation capture, so all three take the front the same way. What is
checked here is the order: the front has to be noted before the microphone
is opened, and the watch armed after, or there is nothing to go back to.
"""
def setUp(self):
super().setUp()
from dikte import app as dikte_module
self.dikte = dikte_module
self.order = []
def app(self, **attributes):
"""A Dikte that records the order it does things in, and nothing else.
The methods under test are called unbound against it, so the stand-ins
go on the object rather than on the class.
"""
dikte = mock.Mock(**attributes)
dikte._run_id = 0
dikte.conf = {"mic_target": "", "max_seconds": 60,
"meeting_mic_target": "", "meeting_system_target": "",
"meeting_max_seconds": 60}
dikte._the_front.side_effect = lambda: self.order.append("noted") or 4242
dikte._give_the_front_back.side_effect = (
lambda pid: self.order.append(f"watching {pid}"))
dikte._begin_recording = (
lambda owner: self.dikte.Dikte._begin_recording(dikte, owner))
dikte.recorder.start.side_effect = (
lambda *_a: self.order.append("microphone"))
dikte.meeting_recorder.start.side_effect = (
lambda *_a: self.order.append("microphone"))
return dikte
def test_a_dictation_notes_the_front_before_the_indicator_is_even_shown(self):
dikte = self.app(state=self.dikte.IDLE, recording=False)
dikte.overlay.show_recording.side_effect = (
lambda *_a: self.order.append("indicator"))
self.dikte.Dikte.start(dikte)
self.assertEqual(self.order,
["noted", "indicator", "microphone", "watching 4242"])
def test_the_agent_does_the_same(self):
dikte = self.app(ask_state=self.dikte.IDLE, recording=False)
self.dikte.Dikte.start_ask(dikte)
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
def test_a_meeting_does_the_same(self):
"""The one most worth protecting: the user is in a call."""
dikte = self.app(meeting_state=self.dikte.M_IDLE)
dikte.meeting_recorder.active = True
self.dikte.Dikte.start_meeting(dikte)
self.assertEqual(self.order, ["noted", "microphone", "watching 4242"])
def test_a_meeting_whose_microphone_never_opened_watches_nothing(self):
dikte = self.app(meeting_state=self.dikte.M_IDLE)
dikte.meeting_recorder.active = False
self.dikte.Dikte.start_meeting(dikte)
self.assertEqual(self.order, ["noted", "microphone"])
class Overlay(DikteTest):
def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs)
@@ -610,6 +955,36 @@ class Overlay(DikteTest):
self.assertFalse(widget.muted)
class MeetingSources(DikteTest):
"""What the Meeting tab says about the far side, per sound system.
The box that picks it is empty on a system that cannot record it, and an
empty box with nothing next to it reads as a list that has not loaded yet.
"""
def notes(self, meetings):
with mock.patch.object(audio, "sound",
return_value=audio.PULSE._replace(
meetings=meetings)), \
only_these_tools(), \
mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \
mock.patch.object(settings_ui.SettingsWindow,
"_load_transcribe_models"):
window = settings_ui.SettingsWindow(cfg.Config())
self.addCleanup(window.deleteLater)
self.addCleanup(window.close)
return " ".join(label.text()
for label in window.findChildren(settings_ui.QLabel))
def test_a_system_that_cannot_record_the_far_side_says_so(self):
self.assertIn("nothing that records what the speakers",
self.notes(meetings=False))
def test_a_system_that_can_says_nothing_of_the_sort(self):
self.assertNotIn("nothing that records what the speakers",
self.notes(meetings=True))
if __name__ == "__main__":
unittest.main()
@@ -617,6 +992,13 @@ if __name__ == "__main__":
class LocalModels(DikteTest):
"""The download boxes, without a network and without either program."""
def setUp(self):
super().setUp()
# A machine Dikte is actually installed on would otherwise answer the
# "nothing can transcribe" question from its real binary and model.
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
self.patch_attr(ggml, "MODELS_DIR", self.path("models"))
def window(self, conf):
window = settings_ui.SettingsWindow(conf)
self.addCleanup(window.deleteLater)
+175
View File
@@ -0,0 +1,175 @@
"""Whether a newer release is one worth telling somebody about.
Two things carry the weight here. A version is compared by its numbers alone,
because a build off master carries the released number with its commit after
it and is ahead of that release rather than behind it. And the clock lives in a
file, so a day of asking nobody has to survive a restart.
"""
import json
import time
from dikte import hub
from dikte import update
from tests.support import DikteTest, fake_urlopen, url_error
RELEASE = {
"tag_name": "v1.4.0",
"html_url": "https://github.com/yusufipk/dikte/releases/tag/v1.4.0",
"published_at": "2026-08-01T10:00:00Z",
}
class Numbers(DikteTest):
def test_a_tag_and_a_bare_number_read_the_same(self):
self.assertEqual(update._numbers("v1.4.0"), (1, 4, 0))
self.assertEqual(update._numbers("1.4.0"), (1, 4, 0))
def test_a_short_number_is_filled_out(self):
self.assertEqual(update._numbers("2"), (2, 0, 0))
self.assertEqual(update._numbers("2.1"), (2, 1, 0))
def test_what_follows_the_number_is_dropped(self):
self.assertEqual(update._numbers("1.0.1-dev.abc1234"), (1, 0, 1))
self.assertEqual(update._numbers("1.0.1+build7"), (1, 0, 1))
def test_something_that_is_not_a_version_is_no_version(self):
self.assertEqual(update._numbers("latest"), ())
self.assertEqual(update._numbers(""), ())
self.assertEqual(update._numbers(None), ())
class Newer(DikteTest):
def test_a_higher_number_is_newer(self):
self.assertTrue(update.newer("1.4.0", "1.3.9"))
self.assertTrue(update.newer("v2.0.0", "1.9.9"))
def test_the_same_number_is_not(self):
self.assertFalse(update.newer("1.4.0", "1.4.0"))
self.assertFalse(update.newer("1.3.0", "1.4.0"))
def test_a_build_off_master_is_ahead_of_the_release_it_names(self):
"""1.0.1-dev.abc1234 was built after 1.0.1 went out, not before it.
Read as a version suffix it would be older, and every nightly would be
told to go back to the release it had already passed."""
self.assertFalse(update.newer("1.0.1", "1.0.1-dev.abc1234"))
self.assertTrue(update.newer("1.0.2", "1.0.1-dev.abc1234"))
def test_a_tag_that_is_not_a_version_is_never_newer(self):
self.assertFalse(update.newer("nightly", "1.0.0"))
class Asking(DikteTest):
def setUp(self):
super().setUp()
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
self.patch_attr(update, "__version__", "1.0.0")
def test_the_newest_release_comes_back_with_its_page(self):
with fake_urlopen(RELEASE) as calls:
release = update.latest()
self.assertEqual(release.version, "1.4.0")
self.assertEqual(release.url, RELEASE["html_url"])
self.assertEqual(
calls[0].full_url,
"https://api.github.com/repos/yusufipk/dikte/releases/latest")
def test_a_release_with_no_page_falls_back_to_the_redirect(self):
with fake_urlopen({"tag_name": "v1.4.0"}):
release = update.latest()
self.assertEqual(release.url, update.RELEASES_PAGE)
def test_a_repository_with_no_release_is_an_error(self):
with fake_urlopen({"message": "Not Found"}):
with self.assertRaises(hub.HubError):
update.latest()
def test_a_check_answers_with_the_newer_release(self):
with fake_urlopen(RELEASE):
release = update.check()
self.assertEqual(release.version, "1.4.0")
def test_a_check_that_finds_nothing_new_answers_with_nothing(self):
self.patch_attr(update, "__version__", "1.4.0")
with fake_urlopen(RELEASE):
self.assertIsNone(update.check())
def test_a_second_check_the_same_day_asks_nobody(self):
with fake_urlopen(RELEASE) as calls:
update.check()
release = update.check()
self.assertEqual(len(calls), 1)
# And still says what the first one found, since it is still true.
self.assertEqual(release.version, "1.4.0")
def test_a_day_later_it_asks_again(self):
with fake_urlopen(RELEASE) as calls:
update.check()
update._store(checked=time.time() - update.INTERVAL - 60)
update.check()
self.assertEqual(len(calls), 2)
def test_the_button_asks_whatever_the_clock_says(self):
with fake_urlopen(RELEASE) as calls:
update.check()
update.check(force=True)
self.assertEqual(len(calls), 2)
def test_a_check_that_cannot_reach_github_says_so(self):
with fake_urlopen(url_error("no route to host")):
with self.assertRaises(hub.HubError):
update.check()
class Remembering(DikteTest):
def setUp(self):
super().setUp()
self.patch_attr(hub, "CACHE_DIR", self.path("cache"))
self.patch_attr(update, "__version__", "1.0.0")
def test_what_the_last_check_found_survives_a_restart(self):
with fake_urlopen(RELEASE):
update.check()
release = update.pending()
self.assertEqual(release.version, "1.4.0")
self.assertEqual(release.url, RELEASE["html_url"])
def test_nothing_was_ever_checked(self):
self.assertIsNone(update.pending())
self.assertEqual(update.state(), {})
self.assertTrue(update.due())
def test_a_release_that_is_no_longer_newer_is_not_pending(self):
"""The state file outlives the build that wrote it: an update that was
found and then installed must not still be waiting afterwards."""
update._store(version="1.4.0")
self.patch_attr(update, "__version__", "1.4.0")
self.assertIsNone(update.pending())
def test_a_version_is_announced_once(self):
self.assertEqual(update.announced(), "")
update.mark_announced("1.4.0")
self.assertEqual(update.announced(), "1.4.0")
def test_a_state_file_that_is_rubbish_is_no_state_at_all(self):
update.STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
update.STATE_FILE.write_text("half a {", encoding="utf-8")
self.assertEqual(update.state(), {})
self.assertIsNone(update.pending())
def test_a_state_file_that_cannot_be_written_is_not_a_failure(self):
self.patch_attr(update, "STATE_FILE",
self.path("nope") / "deeper" / "update.json")
self.path("nope").write_text("a file where a directory would go")
with fake_urlopen(RELEASE):
release = update.check()
self.assertEqual(release.version, "1.4.0")
def test_the_clock_is_kept_out_of_the_settings(self):
"""A background check writes while the settings window may be open, and
a write into config.json there would undo whatever it holds."""
with fake_urlopen(RELEASE):
update.check()
stored = json.loads(update.STATE_FILE.read_text(encoding="utf-8"))
self.assertEqual(stored["version"], "1.4.0")
self.assertGreater(stored["checked"], 0)
+13 -3
View File
@@ -32,7 +32,8 @@ class Chain(DikteTest):
transcript="uh, book it for Thursday",
cleaned="Book it for Thursday.",
cleanup_error=None, answer=("Booked.", ""), rms=None,
clipboard=b"what was there before", paste_error=None):
clipboard=b"what was there before", paste_error=None,
focus=None):
pipeline = worker.Pipeline(self.conf)
done, failures, stages, cancels = [], [], [], []
pipeline.finished.connect(lambda *args: done.append(args))
@@ -60,7 +61,8 @@ class Chain(DikteTest):
"copy": copy, "copy_bytes": copy_bytes, "press": press,
"read_clipboard": read_clipboard}
pipeline._work(self.wav, duration,
self.rms if rms is None else rms, ask, paste_override)
self.rms if rms is None else rms, ask, paste_override,
focus)
return {"done": done, "failures": failures, "stages": stages,
"cancelled": cancels, **calls}
@@ -72,7 +74,15 @@ class Chain(DikteTest):
self.assertEqual(run["done"][0],
("uh, book it for Thursday", "Book it for Thursday.", ""))
run["copy"].assert_called_once_with("Book it for Thursday.")
run["press"].assert_called_once_with(self.conf["paste_shortcut"])
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
focus=None)
def test_the_paste_is_told_where_the_dictation_started(self):
"""Whoever was in front when the recording began is where the keys are
meant to go, and the press is the only part that can act on it."""
run = self.run_chain(focus=4242)
run["press"].assert_called_once_with(self.conf["paste_shortcut"],
focus=4242)
def test_the_stages_are_named_as_they_happen(self):
run = self.run_chain()