mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge master into the Windows port
Three of the four collisions were the same one: master moved the directory rule into paths.py while this branch was adding a Windows case to the copy in config.py and the second copy in ggml.py. The case moves to paths.py with the rest of it, and the directories test moves to tests/test_paths.py where master put its neighbours. The fourth is MeetingRecorder, which now starts a process per capture device. Windows keeps its two lines there: no console window for either process, and a stop that terminates rather than sending a signal the platform does not have.
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -38,3 +38,39 @@ jobs:
|
|||||||
# tests reach neither the network nor a sound device.
|
# tests reach neither the network nor a sound device.
|
||||||
- name: Run the tests
|
- name: Run the tests
|
||||||
run: python -m unittest discover --verbose
|
run: python -m unittest discover --verbose
|
||||||
|
|
||||||
|
# The macOS backends are written to be checked from either side, so most of
|
||||||
|
# what runs here also runs above. What only this job can catch is the half
|
||||||
|
# that reads the real platform: the Carbon and CoreGraphics libraries have to
|
||||||
|
# be there to be opened, the paths under ~/Library have to be the ones macOS
|
||||||
|
# actually uses, and the Linux tests are skipped rather than failed.
|
||||||
|
macos:
|
||||||
|
runs-on: macos-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 frameworks it needs on a Mac.
|
||||||
|
- name: Install PyQt6
|
||||||
|
run: python -m pip install --quiet PyQt6
|
||||||
|
|
||||||
|
- name: Run the tests
|
||||||
|
run: python -m unittest discover --verbose
|
||||||
|
|
||||||
|
# The installer is most of what is new on a Mac, and a syntax error in it
|
||||||
|
# would otherwise only turn up on somebody's machine. Running it is not on
|
||||||
|
# the table here: it wants a microphone, a login session and a keychain.
|
||||||
|
- name: Check the installer parses
|
||||||
|
run: |
|
||||||
|
bash -n install.sh
|
||||||
|
bash -n install-mac.sh
|
||||||
|
bash -n update.sh
|
||||||
|
bash -n uninstall.sh
|
||||||
|
|||||||
@@ -55,11 +55,32 @@ tools instead:
|
|||||||
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
macOS has no shortcut registry for `install.sh` to install into, so the listener
|
On macOS the same `./install.sh` runs and hands over to `install-mac.sh`, which
|
||||||
that catches the keys is the mechanism there and there is nothing to run:
|
puts down a `Dikte.app` in `~/Applications`, the `dikte` command and a
|
||||||
`brew install ffmpeg`, `pip install PyQt6`, then `python dikte.py`. A meeting
|
LaunchAgent:
|
||||||
needs BlackHole or Loopback, because nothing else offers what the speakers are
|
|
||||||
playing.
|
```sh
|
||||||
|
brew install pyqt ffmpeg # pyqt brings a Python newer than Apple's 3.9 with it
|
||||||
|
|
||||||
|
./install.sh # or: ./install.sh "Ctrl+Option+Space" "Ctrl+Option+D"
|
||||||
|
open -a Dikte
|
||||||
|
```
|
||||||
|
|
||||||
|
The bundle is what macOS files the **Microphone** and **Accessibility**
|
||||||
|
permissions against, and it asks for each the first time it needs one. The
|
||||||
|
default there is `Ctrl+Option+Space`, since macOS keeps `Ctrl+Space` for the
|
||||||
|
input-source switch, and nothing needs a logout: Dikte holds the combination
|
||||||
|
itself while it runs. PyQt6 comes from brew rather than pip because Homebrew's
|
||||||
|
Python refuses to be installed into, and `DIKTE_PYTHON=…/venv/bin/python
|
||||||
|
./install.sh` points the installer at a virtualenv instead.
|
||||||
|
|
||||||
|
Local speech to text is the one piece that has to be built by hand there:
|
||||||
|
whisper.cpp publishes no macOS binary and Homebrew's is configured with
|
||||||
|
`WHISPER_BUILD_SERVER=OFF`, so it installs `whisper-cli` and not the server
|
||||||
|
Dikte talks to. Build it (`cmake -B build -DWHISPER_BUILD_SERVER=ON
|
||||||
|
-DGGML_METAL=ON && cmake --build build -j`) and give Settings → API the path, or
|
||||||
|
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
|
Windows works the same way, holding the keys through the system's own hotkey
|
||||||
service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`,
|
service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`,
|
||||||
@@ -195,8 +216,9 @@ vad.py deciding whether a recording holds speech at all
|
|||||||
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
filetranscribe.py file transcription: ffmpeg, chunking, timestamps
|
||||||
overlay.py the corner indicator
|
overlay.py the corner indicator
|
||||||
settings_ui.py settings window
|
settings_ui.py settings window
|
||||||
hotkey.py KDE shortcut installation and the evdev listener
|
hotkey.py KDE shortcut installation, the evdev listener, Carbon on a Mac
|
||||||
paste.py wl-clipboard and ydotool wrappers
|
paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics
|
||||||
|
trayicon.py the tray icons, drawn where there is no icon theme
|
||||||
i18n.py the string table
|
i18n.py the string table
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+29
-6
@@ -54,10 +54,32 @@ araçlarıyla çalışır:
|
|||||||
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
macOS'ta `install.sh`'ın kuracağı bir kısayol kaydı yok, tuşları yakalayan
|
macOS'ta da aynı `./install.sh` çalışır, işi `install-mac.sh`'a devreder;
|
||||||
dinleyici orada mekanizmanın kendisi, dolayısıyla kurulacak bir şey de yok:
|
`~/Applications` içine bir `Dikte.app`, `dikte` komutunu ve bir LaunchAgent
|
||||||
`brew install ffmpeg`, `pip install PyQt6`, sonra `python dikte.py`. Toplantı
|
kurar:
|
||||||
için BlackHole ya da Loopback gerekiyor, hoparlörden çıkanı kimse vermiyor.
|
|
||||||
|
```sh
|
||||||
|
brew install pyqt ffmpeg # pyqt Apple'ın 3.9'undan yeni bir Python'ı da getirir
|
||||||
|
|
||||||
|
./install.sh # ya da: ./install.sh "Ctrl+Option+Space" "Ctrl+Option+D"
|
||||||
|
open -a Dikte
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS **Mikrofon** ve **Erişilebilirlik** izinlerini uygulama paketinin kimliğine
|
||||||
|
yazıyor, ikisini de ilk gerektiğinde soruyor. Varsayılan kısayol orada
|
||||||
|
`Ctrl+Option+Space`, çünkü `Ctrl+Space` giriş kaynağını değiştirmeye ayrılmış; ve
|
||||||
|
hiçbir şey için oturumu kapatman gerekmez, kombinasyonu Dikte çalışırken kendisi
|
||||||
|
tutar. PyQt6 pip'ten değil brew'dan geliyor, Homebrew'un Python'u içine
|
||||||
|
kurulmayı reddediyor; sanal ortam kullanacaksan
|
||||||
|
`DIKTE_PYTHON=…/venv/bin/python ./install.sh`.
|
||||||
|
|
||||||
|
Orada elle derlenmesi gereken tek parça yerel transkripsiyon: whisper.cpp'nin
|
||||||
|
macOS sürümü yok, Homebrew'unki de `WHISPER_BUILD_SERVER=OFF` ile derleniyor,
|
||||||
|
yani `whisper-cli` kuruluyor, Dikte'nin konuştuğu sunucu değil. Kendin derle
|
||||||
|
(`cmake -B build -DWHISPER_BUILD_SERVER=ON -DGGML_METAL=ON && cmake --build
|
||||||
|
build -j`) ve yolunu Ayarlar → API'ye yaz, ya da buluta çevir. Toplantı için
|
||||||
|
BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için
|
||||||
|
gerekmiyor.
|
||||||
|
|
||||||
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
|
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
|
||||||
başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh`
|
başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh`
|
||||||
@@ -186,8 +208,9 @@ vad.py kayıtta gerçekten konuşma var mı kararı
|
|||||||
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
|
||||||
overlay.py köşedeki gösterge
|
overlay.py köşedeki gösterge
|
||||||
settings_ui.py ayarlar penceresi
|
settings_ui.py ayarlar penceresi
|
||||||
hotkey.py KDE kısayol kurulumu ve evdev dinleyici
|
hotkey.py KDE kısayol kurulumu, evdev dinleyici, Mac'te Carbon
|
||||||
paste.py wl-clipboard ve ydotool sarmalayıcıları
|
paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics
|
||||||
|
trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş
|
||||||
i18n.py metin tablosu
|
i18n.py metin tablosu
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
Dictation records one source. A meeting records two of them at once, the
|
Dictation records one source. A meeting records two of them at once, the
|
||||||
microphone and what comes out of the speakers, and for that it goes through
|
microphone and what comes out of the speakers, and for that it goes through
|
||||||
ffmpeg: one process reading both devices and merging them into the two channels
|
ffmpeg. PulseAudio hands both devices to a single process, which merges them
|
||||||
of a single stream, which is the only way the two stay aligned with each other
|
into the two channels of one stream and keeps them aligned itself. AVFoundation
|
||||||
over an hour.
|
cannot be asked the same: two of its sessions inside one process starve each
|
||||||
|
other, so a Mac captures each device on its own and the two mono streams are
|
||||||
|
interleaved here as they arrive.
|
||||||
|
|
||||||
Which programs do the capturing is a property of the machine, not of the code
|
Which programs do the capturing is a property of the machine, not of the code
|
||||||
above: PulseAudio or PipeWire on Linux, AVFoundation through ffmpeg on macOS.
|
above: PulseAudio or PipeWire on Linux, AVFoundation through ffmpeg on macOS.
|
||||||
@@ -17,6 +19,7 @@ import collections
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
@@ -42,6 +45,21 @@ CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS
|
|||||||
CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000)
|
CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000)
|
||||||
MIN_FRAMES = int(RATE * 0.25)
|
MIN_FRAMES = int(RATE * 0.25)
|
||||||
|
|
||||||
|
# A capture process hands over a block every CHUNK_LATENCY_MS. One that has said
|
||||||
|
# nothing for this long has stopped rather than fallen behind, and the meeting
|
||||||
|
# ends and says so instead of sitting on a read that will never return.
|
||||||
|
STALL_SECONDS = 5.0
|
||||||
|
# Room for a whole stall of the other stream, so the side still delivering is
|
||||||
|
# never the one left waiting.
|
||||||
|
QUEUE_BLOCKS = int(STALL_SECONDS * RATE / CHUNK_FRAMES) + 8
|
||||||
|
|
||||||
|
# Exact zeroes are not quiet, they are nothing: a microphone that is really in
|
||||||
|
# the room has a noise floor. This much of a recording that long means it handed
|
||||||
|
# nothing over, which is worth saying once the meeting is over and nothing can
|
||||||
|
# be done about it any more.
|
||||||
|
QUIET_MIC_SECONDS = 10
|
||||||
|
QUIET_MIC_SHARE = 0.5
|
||||||
|
|
||||||
|
|
||||||
def _interrupt(proc):
|
def _interrupt(proc):
|
||||||
"""Ask a recorder process to end.
|
"""Ask a recorder process to end.
|
||||||
@@ -80,7 +98,11 @@ class Recorder(QObject):
|
|||||||
def start(self, target="", max_seconds=300):
|
def start(self, target="", max_seconds=300):
|
||||||
if self.active:
|
if self.active:
|
||||||
return
|
return
|
||||||
cmd = recording_command(target)
|
try:
|
||||||
|
cmd = recording_command(target)
|
||||||
|
except AudioDeviceError as exc:
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
return
|
||||||
if not cmd:
|
if not cmd:
|
||||||
self.failed.emit(t(sound().missing))
|
self.failed.emit(t(sound().missing))
|
||||||
return
|
return
|
||||||
@@ -203,11 +225,21 @@ def recording_command(target=""):
|
|||||||
return sound().record(target)
|
return sound().record(target)
|
||||||
|
|
||||||
|
|
||||||
def meeting_command(mic_target, system_target):
|
def meeting_commands(mic_target, system_target):
|
||||||
"""One ffmpeg reading both devices and merging them into two channels."""
|
"""The capture processes that produce one stereo meeting stream.
|
||||||
|
|
||||||
|
One of them on PulseAudio, which merges both inputs itself; one per device
|
||||||
|
on a Mac, because two AVFoundation sessions in a process starve each other.
|
||||||
|
Which of the two it is stays in the table with everything else the sound
|
||||||
|
system decides, and MeetingRecorder reads the count rather than the machine.
|
||||||
|
"""
|
||||||
return sound().meeting(mic_target, system_target)
|
return sound().meeting(mic_target, system_target)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioDeviceError(RuntimeError):
|
||||||
|
"""A saved capture device can no longer be selected safely."""
|
||||||
|
|
||||||
|
|
||||||
class MeetingRecorder(QObject):
|
class MeetingRecorder(QObject):
|
||||||
"""Microphone and speaker output into one stereo file: left is you, right is
|
"""Microphone and speaker output into one stereo file: left is you, right is
|
||||||
everyone else.
|
everyone else.
|
||||||
@@ -221,16 +253,19 @@ class MeetingRecorder(QObject):
|
|||||||
levels = pyqtSignal(float, float) # mine, theirs
|
levels = pyqtSignal(float, float) # mine, theirs
|
||||||
stopped = pyqtSignal(str, float) # wav path, duration (s)
|
stopped = pyqtSignal(str, float) # wav path, duration (s)
|
||||||
died = pyqtSignal() # ffmpeg quit on its own
|
died = pyqtSignal() # ffmpeg quit on its own
|
||||||
|
warned = pyqtSignal(str) # recorded, but something was wrong
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._proc = None
|
self._procs = []
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._wav = None
|
self._wav = None
|
||||||
self._log = None
|
self._logs = []
|
||||||
self._path = ""
|
self._path = ""
|
||||||
self._frames = 0
|
self._frames = 0
|
||||||
|
self._mic_zero_frames = 0
|
||||||
|
self._split_inputs = False
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
self._stopping = False
|
self._stopping = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
@@ -252,7 +287,11 @@ class MeetingRecorder(QObject):
|
|||||||
"Pick one in Settings → Meeting."))
|
"Pick one in Settings → Meeting."))
|
||||||
return
|
return
|
||||||
|
|
||||||
cmd = meeting_command(mic_target, system_target)
|
try:
|
||||||
|
commands = meeting_commands(mic_target, system_target)
|
||||||
|
except AudioDeviceError as exc:
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
@@ -262,12 +301,18 @@ class MeetingRecorder(QObject):
|
|||||||
self._wav.setframerate(RATE)
|
self._wav.setframerate(RATE)
|
||||||
# ffmpeg keeps talking to stderr for as long as it runs; a pipe
|
# ffmpeg keeps talking to stderr for as long as it runs; a pipe
|
||||||
# nobody drains would eventually block it, so it writes to a file.
|
# nobody drains would eventually block it, so it writes to a file.
|
||||||
self._log = tempfile.TemporaryFile()
|
self._logs = [tempfile.TemporaryFile() for _ in commands]
|
||||||
self._proc = subprocess.Popen(
|
self._procs = []
|
||||||
cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0,
|
for command, log in zip(commands, self._logs):
|
||||||
creationflags=NO_WINDOW,
|
self._procs.append(subprocess.Popen(
|
||||||
)
|
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
|
||||||
|
creationflags=NO_WINDOW,
|
||||||
|
))
|
||||||
except (OSError, wave.Error) as exc:
|
except (OSError, wave.Error) as exc:
|
||||||
|
# One of two capture processes may already be running, and a Mac
|
||||||
|
# left holding an open AVFoundation session records nothing else.
|
||||||
|
self._terminate_processes()
|
||||||
|
self._procs = []
|
||||||
self._close_file()
|
self._close_file()
|
||||||
self._drop_log()
|
self._drop_log()
|
||||||
try:
|
try:
|
||||||
@@ -279,6 +324,8 @@ class MeetingRecorder(QObject):
|
|||||||
|
|
||||||
self._path = path
|
self._path = path
|
||||||
self._frames = 0
|
self._frames = 0
|
||||||
|
self._mic_zero_frames = 0
|
||||||
|
self._split_inputs = len(self._procs) == 2
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
self._stopping = False
|
self._stopping = False
|
||||||
self._max_frames = int(max_seconds * RATE)
|
self._max_frames = int(max_seconds * RATE)
|
||||||
@@ -286,38 +333,83 @@ class MeetingRecorder(QObject):
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _pump(self):
|
def _pump(self):
|
||||||
stdout = self._proc.stdout
|
if self._split_inputs:
|
||||||
block = CHUNK_FRAMES * SAMPLE_WIDTH * 2
|
self._pump_split()
|
||||||
try:
|
else:
|
||||||
while True:
|
self._pump_merged()
|
||||||
chunk = stdout.read(block)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
mine, theirs = stereo_levels(chunk)
|
|
||||||
with self._lock:
|
|
||||||
if self._wav is None:
|
|
||||||
break
|
|
||||||
self._wav.writeframes(chunk)
|
|
||||||
self._frames += len(chunk) // (SAMPLE_WIDTH * 2)
|
|
||||||
too_long = self._frames >= self._max_frames
|
|
||||||
self.levels.emit(mine, theirs)
|
|
||||||
if too_long:
|
|
||||||
self._terminate()
|
|
||||||
break
|
|
||||||
except (OSError, ValueError, wave.Error):
|
|
||||||
pass
|
|
||||||
# Nobody asked it to end: the sound device went away, or ffmpeg fell
|
# Nobody asked it to end: the sound device went away, or ffmpeg fell
|
||||||
# over. An hour into a meeting that has to be said out loud rather than
|
# over. An hour into a meeting that has to be said out loud rather than
|
||||||
# discovered afterwards.
|
# discovered afterwards.
|
||||||
if not self._stopping:
|
if not self._stopping:
|
||||||
self.died.emit()
|
self.died.emit()
|
||||||
|
|
||||||
|
def _pump_merged(self):
|
||||||
|
stdout = self._procs[0].stdout
|
||||||
|
block = CHUNK_FRAMES * SAMPLE_WIDTH * 2
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = stdout.read(block)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if not self._write_chunk(chunk):
|
||||||
|
break
|
||||||
|
except (OSError, ValueError, wave.Error):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _pump_split(self):
|
||||||
|
# A reader thread per process. Taking turns on the two pipes from one
|
||||||
|
# thread would let a starved microphone hold up the far side too: its
|
||||||
|
# blocks would sit unread until the pipe filled and its ffmpeg stopped
|
||||||
|
# writing, and an hour of meeting would freeze with nothing said. Each
|
||||||
|
# stream is read as fast as it arrives, and a side that goes quiet for
|
||||||
|
# STALL_SECONDS ends the recording rather than hanging it.
|
||||||
|
block = CHUNK_FRAMES * SAMPLE_WIDTH
|
||||||
|
streams = [queue.Queue(maxsize=QUEUE_BLOCKS) for _ in self._procs]
|
||||||
|
for proc, blocks in zip(self._procs, streams):
|
||||||
|
threading.Thread(target=_read_blocks, daemon=True,
|
||||||
|
args=(proc.stdout, blocks, block)).start()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
mine = _next_block(streams[0])
|
||||||
|
theirs = _next_block(streams[1])
|
||||||
|
if not mine or not theirs:
|
||||||
|
break
|
||||||
|
frames = min(len(mine), len(theirs)) // SAMPLE_WIDTH
|
||||||
|
mine = mine[:frames * SAMPLE_WIDTH]
|
||||||
|
theirs = theirs[:frames * SAMPLE_WIDTH]
|
||||||
|
self._mic_zero_frames += _zero_samples(mine)
|
||||||
|
if not self._write_chunk(interleave_mono(mine, theirs)):
|
||||||
|
break
|
||||||
|
except (OSError, ValueError, wave.Error):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _write_chunk(self, chunk):
|
||||||
|
mine, theirs = stereo_levels(chunk)
|
||||||
|
with self._lock:
|
||||||
|
if self._wav is None:
|
||||||
|
return False
|
||||||
|
self._wav.writeframes(chunk)
|
||||||
|
self._frames += len(chunk) // (SAMPLE_WIDTH * 2)
|
||||||
|
too_long = self._frames >= self._max_frames
|
||||||
|
self.levels.emit(mine, theirs)
|
||||||
|
if too_long:
|
||||||
|
self._terminate()
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def _terminate(self):
|
def _terminate(self):
|
||||||
self._stopping = True
|
self._stopping = True
|
||||||
proc = self._proc
|
self._terminate_processes()
|
||||||
if proc and proc.poll() is None:
|
|
||||||
|
def _terminate_processes(self):
|
||||||
|
running = [proc for proc in self._procs if proc.poll() is None]
|
||||||
|
for proc in running:
|
||||||
try:
|
try:
|
||||||
_interrupt(proc)
|
_interrupt(proc)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for proc in running:
|
||||||
|
try:
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
try:
|
||||||
@@ -335,23 +427,26 @@ class MeetingRecorder(QObject):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _error_tail(self):
|
def _error_tail(self):
|
||||||
if self._log is None:
|
tails = []
|
||||||
return ""
|
for log in self._logs:
|
||||||
try:
|
try:
|
||||||
self._log.seek(0)
|
log.seek(0)
|
||||||
text = self._log.read().decode("utf-8", "replace").strip()
|
text = log.read().decode("utf-8", "replace").strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
return ""
|
continue
|
||||||
lines = [line for line in text.splitlines() if line.strip()]
|
lines = [line for line in text.splitlines() if line.strip()]
|
||||||
return lines[-1] if lines else ""
|
if lines:
|
||||||
|
tails.append(lines[-1])
|
||||||
|
return " | ".join(tails)
|
||||||
|
|
||||||
def _finish_process(self):
|
def _finish_process(self):
|
||||||
self._terminate()
|
self._terminate()
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=3)
|
self._thread.join(timeout=3)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
code = self._proc.poll() if self._proc else 0
|
codes = [proc.poll() for proc in self._procs]
|
||||||
self._proc = None
|
code = next((value for value in codes if value), 0)
|
||||||
|
self._procs = []
|
||||||
self._close_file()
|
self._close_file()
|
||||||
return code
|
return code
|
||||||
|
|
||||||
@@ -365,7 +460,7 @@ class MeetingRecorder(QObject):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
if not self._proc:
|
if not self._procs:
|
||||||
return
|
return
|
||||||
# The count is read after the join: the pump thread is still appending
|
# The count is read after the join: the pump thread is still appending
|
||||||
# the last blocks up to the moment it ends.
|
# the last blocks up to the moment it ends.
|
||||||
@@ -390,15 +485,27 @@ class MeetingRecorder(QObject):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
self._drop_log()
|
self._drop_log()
|
||||||
|
# A microphone that handed nothing over costs the left channel, and the
|
||||||
|
# recording is kept anyway: the right one is everyone else, and an hour
|
||||||
|
# of them is worth more than an empty channel costs. Only the split
|
||||||
|
# capture can starve a device this way; one ffmpeg reading both cannot.
|
||||||
|
if (self._split_inputs and frames >= RATE * QUIET_MIC_SECONDS
|
||||||
|
and self._mic_zero_frames / frames > QUIET_MIC_SHARE):
|
||||||
|
self.warned.emit(t(
|
||||||
|
"The microphone handed over almost nothing ({percent}% of the "
|
||||||
|
"recording was empty), so your own side of the meeting will be "
|
||||||
|
"mostly missing. Check the device before the next one.",
|
||||||
|
percent=round(self._mic_zero_frames / frames * 100),
|
||||||
|
))
|
||||||
self.stopped.emit(self._path, frames / RATE)
|
self.stopped.emit(self._path, frames / RATE)
|
||||||
|
|
||||||
def _drop_log(self):
|
def _drop_log(self):
|
||||||
if self._log is not None:
|
for log in self._logs:
|
||||||
try:
|
try:
|
||||||
self._log.close()
|
log.close()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
self._log = None
|
self._logs = []
|
||||||
|
|
||||||
|
|
||||||
def chunk_levels(chunk):
|
def chunk_levels(chunk):
|
||||||
@@ -424,6 +531,62 @@ def stereo_levels(chunk):
|
|||||||
return _peak(left), _peak(right)
|
return _peak(left), _peak(right)
|
||||||
|
|
||||||
|
|
||||||
|
def interleave_mono(left, right):
|
||||||
|
"""Two mono-s16 buffers into one stereo-s16 buffer, the shorter one setting
|
||||||
|
the length."""
|
||||||
|
left_samples, right_samples = _samples(left), _samples(right)
|
||||||
|
frames = min(len(left_samples), len(right_samples))
|
||||||
|
stereo_samples = array.array("h", bytes(frames * 2 * SAMPLE_WIDTH))
|
||||||
|
stereo_samples[0::2] = left_samples[:frames]
|
||||||
|
stereo_samples[1::2] = right_samples[:frames]
|
||||||
|
return stereo_samples.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_blocks(stream, blocks, size):
|
||||||
|
"""One stream's blocks onto its queue, ending with the empty one.
|
||||||
|
|
||||||
|
A queue that stays full is the pump having given up on this recording, and
|
||||||
|
then there is nobody left to hand anything to.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
block = _read_exact(stream, size)
|
||||||
|
blocks.put(block, timeout=STALL_SECONDS)
|
||||||
|
if not block:
|
||||||
|
return
|
||||||
|
except (OSError, ValueError, queue.Full):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _next_block(blocks):
|
||||||
|
"""The next block of a stream, empty once it ends or falls silent."""
|
||||||
|
try:
|
||||||
|
return blocks.get(timeout=STALL_SECONDS)
|
||||||
|
except queue.Empty:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_exact(stream, size):
|
||||||
|
"""Read one meter-sized block, tolerating short unbuffered pipe reads."""
|
||||||
|
out = bytearray()
|
||||||
|
while len(out) < size:
|
||||||
|
chunk = stream.read(size - len(out))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.extend(chunk)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _zero_samples(chunk):
|
||||||
|
return _samples(chunk).count(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _samples(chunk):
|
||||||
|
samples = array.array("h")
|
||||||
|
samples.frombytes(chunk[:len(chunk) - len(chunk) % SAMPLE_WIDTH])
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
def _peak(samples):
|
def _peak(samples):
|
||||||
if not samples:
|
if not samples:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -432,8 +595,9 @@ def _peak(samples):
|
|||||||
|
|
||||||
# --- the sound system, one group per machine -------------------------------
|
# --- the sound system, one group per machine -------------------------------
|
||||||
|
|
||||||
# Both meeting commands merge the same way: each input down to mono at our own
|
# How the one PulseAudio process merges: each input down to mono at our own
|
||||||
# rate, then the two of them into the left and right of one stream.
|
# rate, then the two of them into the left and right of one stream. A Mac does
|
||||||
|
# the first half per process and the second half itself, in interleave_mono().
|
||||||
MERGE_FILTER = (
|
MERGE_FILTER = (
|
||||||
f"[0:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];"
|
f"[0:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];"
|
||||||
f"[1:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];"
|
f"[1:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];"
|
||||||
@@ -497,13 +661,14 @@ def _pw_record_raw_option():
|
|||||||
|
|
||||||
|
|
||||||
def _pulse_meeting(mic_target, system_target):
|
def _pulse_meeting(mic_target, system_target):
|
||||||
return [
|
"""One process for both devices: PulseAudio keeps them aligned itself."""
|
||||||
|
return [[
|
||||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default",
|
"-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default",
|
||||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", system_target,
|
"-f", "pulse", "-thread_queue_size", "4096", "-i", system_target,
|
||||||
"-filter_complex", MERGE_FILTER, "-map", "[out]",
|
"-filter_complex", MERGE_FILTER, "-map", "[out]",
|
||||||
"-f", "s16le", "-ar", str(RATE), "-",
|
"-f", "s16le", "-ar", str(RATE), "-",
|
||||||
]
|
]]
|
||||||
|
|
||||||
|
|
||||||
def _pactl_sources():
|
def _pactl_sources():
|
||||||
@@ -561,6 +726,7 @@ LOOPBACK_DEVICES = ("blackhole", "loopback", "soundflower")
|
|||||||
def _avfoundation_record(target):
|
def _avfoundation_record(target):
|
||||||
if not shutil.which("ffmpeg"):
|
if not shutil.which("ffmpeg"):
|
||||||
return []
|
return []
|
||||||
|
target = _resolve_avfoundation_target(target)
|
||||||
return [
|
return [
|
||||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||||
# AVFoundation names an input "video:audio", so the empty half in front
|
# AVFoundation names an input "video:audio", so the empty half in front
|
||||||
@@ -571,14 +737,26 @@ def _avfoundation_record(target):
|
|||||||
|
|
||||||
|
|
||||||
def _avfoundation_meeting(mic_target, system_target):
|
def _avfoundation_meeting(mic_target, system_target):
|
||||||
|
"""A process per device, both names read off the same device listing.
|
||||||
|
|
||||||
|
Asking ffmpeg what is plugged in costs a process of its own, and a listing
|
||||||
|
taken twice could renumber in between: the two targets have to be resolved
|
||||||
|
against the same one to name the same machine the user picked from.
|
||||||
|
"""
|
||||||
|
inputs = _avfoundation_inputs()
|
||||||
|
return [_avfoundation_meeting_capture(mic_target, inputs),
|
||||||
|
_avfoundation_meeting_capture(system_target, inputs)]
|
||||||
|
|
||||||
|
|
||||||
|
def _avfoundation_meeting_capture(target, inputs=None):
|
||||||
|
target = _resolve_avfoundation_target(target, inputs)
|
||||||
return [
|
return [
|
||||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||||
"-thread_queue_size", "4096",
|
"-thread_queue_size", "4096",
|
||||||
"-f", "avfoundation", "-i", f":{mic_target or 'default'}",
|
"-f", "avfoundation", "-i", f":{target or 'default'}",
|
||||||
"-thread_queue_size", "4096",
|
"-af", (f"aresample={RATE}:async=1:first_pts=0,"
|
||||||
"-f", "avfoundation", "-i", f":{system_target}",
|
"aformat=sample_fmts=s16:channel_layouts=mono"),
|
||||||
"-filter_complex", MERGE_FILTER, "-map", "[out]",
|
"-f", "s16le", "-ar", str(RATE), "-ac", "1", "-",
|
||||||
"-f", "s16le", "-ar", str(RATE), "-",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -615,10 +793,42 @@ def _avfoundation_inputs():
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def _avfoundation_named_inputs():
|
||||||
|
"""Stable settings values: the name is saved, never the moving index."""
|
||||||
|
return [(description, description)
|
||||||
|
for _index, description in _avfoundation_inputs()]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_avfoundation_target(target, inputs=None):
|
||||||
|
"""Resolve a stored device name to its current, positional ffmpeg index."""
|
||||||
|
if not target or target == "default":
|
||||||
|
return "default"
|
||||||
|
if str(target).isdigit():
|
||||||
|
raise AudioDeviceError(t(
|
||||||
|
"The saved macOS audio device uses an old numeric index. Open "
|
||||||
|
"Settings and select the device again before recording."
|
||||||
|
))
|
||||||
|
if inputs is None:
|
||||||
|
inputs = _avfoundation_inputs()
|
||||||
|
matches = [index for index, description in inputs
|
||||||
|
if description == target]
|
||||||
|
if not matches:
|
||||||
|
raise AudioDeviceError(t(
|
||||||
|
"The saved macOS audio device is no longer connected: {device}. "
|
||||||
|
"Open Settings and select another device.", device=target,
|
||||||
|
))
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise AudioDeviceError(t(
|
||||||
|
"More than one macOS audio device is named {device}. Disconnect the "
|
||||||
|
"duplicate or choose a different device.", device=target,
|
||||||
|
))
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
def _avfoundation_default_output():
|
def _avfoundation_default_output():
|
||||||
for name, description in _avfoundation_inputs():
|
for _index, description in _avfoundation_inputs():
|
||||||
if any(word in description.lower() for word in LOOPBACK_DEVICES):
|
if any(word in description.lower() for word in LOOPBACK_DEVICES):
|
||||||
return name
|
return description
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -689,9 +899,10 @@ def _dshow_no_default_output():
|
|||||||
|
|
||||||
Sound = collections.namedtuple(
|
Sound = collections.namedtuple(
|
||||||
"Sound",
|
"Sound",
|
||||||
# How to capture one source and two at once, the two device lists, which
|
# How to capture one source and how to capture two at once, that one as the
|
||||||
# device a meeting records the far side from, and what to say when the
|
# list of processes it takes, the two device lists, which device a meeting
|
||||||
# programs for any of it are not installed.
|
# 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",
|
"record meeting inputs outputs default_output missing",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -707,11 +918,11 @@ PULSE = Sound(
|
|||||||
COREAUDIO = Sound(
|
COREAUDIO = Sound(
|
||||||
record=_avfoundation_record,
|
record=_avfoundation_record,
|
||||||
meeting=_avfoundation_meeting,
|
meeting=_avfoundation_meeting,
|
||||||
inputs=_avfoundation_inputs,
|
inputs=_avfoundation_named_inputs,
|
||||||
# Every macOS capture device is offered as the far side of a meeting, the
|
# Every macOS capture device is offered as the far side of a meeting, the
|
||||||
# loopback driver among them: there is no way to tell them apart, and an
|
# loopback driver among them: there is no way to tell them apart, and an
|
||||||
# empty list would leave nothing to pick.
|
# empty list would leave nothing to pick.
|
||||||
outputs=_avfoundation_inputs,
|
outputs=_avfoundation_named_inputs,
|
||||||
default_output=_avfoundation_default_output,
|
default_output=_avfoundation_default_output,
|
||||||
missing="ffmpeg not found. Install it with: brew install ffmpeg",
|
missing="ffmpeg not found. Install it with: brew install ffmpeg",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -711,16 +711,27 @@ def cmd_test_key(opts):
|
|||||||
def cmd_shortcut(opts):
|
def cmd_shortcut(opts):
|
||||||
conf = cfg.Config()
|
conf = cfg.Config()
|
||||||
if opts.shortcut == "status":
|
if opts.shortcut == "status":
|
||||||
|
# The running instance is asked first. On KDE and GNOME this process
|
||||||
|
# could read the registry itself and get the same answer; on macOS
|
||||||
|
# there is no registry, the combination is held by that process and by
|
||||||
|
# nothing else, and asking here would report every shortcut as missing
|
||||||
|
# while they all work.
|
||||||
|
live = ipc.send("status") or {}
|
||||||
|
registered = live.get("shortcuts") or {}
|
||||||
rows = {}
|
rows = {}
|
||||||
for name, spec in hotkey.SHORTCUTS.items():
|
for name, spec in hotkey.SHORTCUTS.items():
|
||||||
rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id),
|
rows[name] = {
|
||||||
"configured": conf[spec.setting]}
|
"registered": registered.get(
|
||||||
|
name, hotkey.shortcut_status(spec.desktop_id)),
|
||||||
|
"configured": conf[spec.setting],
|
||||||
|
}
|
||||||
|
listener = live.get("listener", conf["evdev_hotkey"])
|
||||||
lines = [f"{name:8} {row['registered'] or '(not installed)':16} "
|
lines = [f"{name:8} {row['registered'] or '(not installed)':16} "
|
||||||
f"setting: {row['configured'] or '(none)'}"
|
f"setting: {row['configured'] or '(none)'}"
|
||||||
for name, row in rows.items()]
|
for name, row in rows.items()]
|
||||||
lines.append(f"built-in listener: {'on' if conf['evdev_hotkey'] else 'off'}")
|
lines.append(f"built-in listener: {'on' if listener else 'off'}")
|
||||||
return out(opts, {"ok": True, "shortcuts": rows,
|
return out(opts, {"ok": True, "shortcuts": rows,
|
||||||
"listener": conf["evdev_hotkey"]}, "\n".join(lines))
|
"listener": listener}, "\n".join(lines))
|
||||||
|
|
||||||
spec = hotkey.SHORTCUTS[opts.which]
|
spec = hotkey.SHORTCUTS[opts.which]
|
||||||
if opts.shortcut == "remove":
|
if opts.shortcut == "remove":
|
||||||
@@ -728,7 +739,8 @@ def cmd_shortcut(opts):
|
|||||||
return out(opts, {"ok": True, "removed": opts.which},
|
return out(opts, {"ok": True, "removed": opts.which},
|
||||||
f"Removed the {opts.which} shortcut.")
|
f"Removed the {opts.which} shortcut.")
|
||||||
|
|
||||||
combo = (opts.combo or conf[spec.setting] or spec.fallback).strip()
|
combo = (opts.combo or conf[spec.setting]
|
||||||
|
or hotkey.default_combo(opts.which)).strip()
|
||||||
if not combo:
|
if not combo:
|
||||||
return fail(opts, "no combination given and none stored; pass --combo", 2)
|
return fail(opts, "no combination given and none stored; pass --combo", 2)
|
||||||
if not hotkey.valid_shortcut(combo):
|
if not hotkey.valid_shortcut(combo):
|
||||||
|
|||||||
@@ -4,40 +4,21 @@ import collections
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import api
|
import api
|
||||||
import ggml
|
import ggml
|
||||||
import i18n
|
import i18n
|
||||||
import paste
|
import paste
|
||||||
|
import paths
|
||||||
from i18n import t
|
from i18n import t
|
||||||
|
|
||||||
|
|
||||||
def _xdg(var, default):
|
_MACOS = sys.platform == "darwin"
|
||||||
return pathlib.Path(os.environ.get(var) or os.path.expanduser(default))
|
|
||||||
|
|
||||||
|
# In paths.py rather than here, because ggml.py needs the same answer and
|
||||||
def _directories(platform=None):
|
# cannot ask this module: the import already runs the other way.
|
||||||
"""(settings, data), in the two places this system keeps them.
|
CONFIG_DIR, DATA_DIR = paths.CONFIG_DIR, paths.DATA_DIR
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
if (platform or sys.platform) == "darwin":
|
|
||||||
support = pathlib.Path.home() / "Library/Application Support/Dikte"
|
|
||||||
return support, support
|
|
||||||
if (platform or sys.platform) == "win32":
|
|
||||||
roaming = pathlib.Path(
|
|
||||||
os.environ.get("APPDATA") or pathlib.Path.home() / "AppData/Roaming")
|
|
||||||
local = pathlib.Path(
|
|
||||||
os.environ.get("LOCALAPPDATA") or pathlib.Path.home() / "AppData/Local")
|
|
||||||
return roaming / "Dikte", local / "Dikte"
|
|
||||||
return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte",
|
|
||||||
_xdg("XDG_DATA_HOME", "~/.local/share") / "dikte")
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_DIR, DATA_DIR = _directories()
|
|
||||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||||
HISTORY_FILE = DATA_DIR / "history.jsonl"
|
HISTORY_FILE = DATA_DIR / "history.jsonl"
|
||||||
RECORDINGS_DIR = DATA_DIR / "recordings"
|
RECORDINGS_DIR = DATA_DIR / "recordings"
|
||||||
@@ -444,11 +425,16 @@ DEFAULTS = {
|
|||||||
"speech_margin_db": 10.0, # how far speech must rise above the noise floor
|
"speech_margin_db": 10.0, # how far speech must rise above the noise floor
|
||||||
"min_voiced_seconds": 0.3,
|
"min_voiced_seconds": 0.3,
|
||||||
"filter_hallucinations": True,
|
"filter_hallucinations": True,
|
||||||
"shortcut": "Ctrl+Space",
|
# Ctrl+Space everywhere except a Mac, where macOS itself holds it for the
|
||||||
|
# input-source switch and Cmd+Space for Spotlight: neither is ours to take,
|
||||||
|
# so there Dikte starts on a combination a stock system leaves free.
|
||||||
|
"shortcut": "Ctrl+Option+Space" if _MACOS else "Ctrl+Space",
|
||||||
# Ctrl+Alt+Space rather than Escape: the combination the recording started
|
# Ctrl+Alt+Space rather than Escape: the combination the recording started
|
||||||
# with, one modifier along. Escape belongs to whatever window has focus, and
|
# with, one modifier along. Escape belongs to whatever window has focus, and
|
||||||
# while you are dictating something else usually has it.
|
# while you are dictating something else usually has it. On a Mac that same
|
||||||
"cancel_shortcut": "Ctrl+Alt+Space",
|
# trick lands on the toggle, Alt and Option being one key, so discarding
|
||||||
|
# gets a letter instead.
|
||||||
|
"cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space",
|
||||||
"evdev_hotkey": False,
|
"evdev_hotkey": False,
|
||||||
"overlay_corner": "bottom-left",
|
"overlay_corner": "bottom-left",
|
||||||
"keep_audio": False,
|
"keep_audio": False,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ command line says "there is no instance to talk to, so be one".
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
@@ -44,6 +46,7 @@ import hotkey # noqa: E402
|
|||||||
import i18n # noqa: E402
|
import i18n # noqa: E402
|
||||||
import ipc # noqa: E402
|
import ipc # noqa: E402
|
||||||
import meeting # noqa: E402
|
import meeting # noqa: E402
|
||||||
|
import trayicon # noqa: E402
|
||||||
from i18n import t # noqa: E402
|
from i18n import t # noqa: E402
|
||||||
from meeting import MeetingPipeline # noqa: E402
|
from meeting import MeetingPipeline # noqa: E402
|
||||||
from overlay import Overlay # noqa: E402
|
from overlay import Overlay # noqa: E402
|
||||||
@@ -124,6 +127,7 @@ class Dikte:
|
|||||||
self.meeting_recorder.levels.connect(self._on_meeting_levels)
|
self.meeting_recorder.levels.connect(self._on_meeting_levels)
|
||||||
self.meeting_recorder.stopped.connect(self._on_meeting_recorded)
|
self.meeting_recorder.stopped.connect(self._on_meeting_recorded)
|
||||||
self.meeting_recorder.died.connect(self._on_meeting_died)
|
self.meeting_recorder.died.connect(self._on_meeting_died)
|
||||||
|
self.meeting_recorder.warned.connect(self._on_meeting_warning)
|
||||||
self.meeting_recorder.failed.connect(self._on_meeting_error)
|
self.meeting_recorder.failed.connect(self._on_meeting_error)
|
||||||
self.meetings.progress.connect(self._on_meeting_progress)
|
self.meetings.progress.connect(self._on_meeting_progress)
|
||||||
self.meetings.finished.connect(self._on_meeting_finished)
|
self.meetings.finished.connect(self._on_meeting_finished)
|
||||||
@@ -219,7 +223,12 @@ class Dikte:
|
|||||||
self._toggle()
|
self._toggle()
|
||||||
|
|
||||||
def _set_icon(self, name):
|
def _set_icon(self, name):
|
||||||
|
# The theme first, so a Linux desktop keeps its own icons, then the ones
|
||||||
|
# drawn in trayicon.py. macOS has no theme at all and would otherwise be
|
||||||
|
# handed a null icon, which in a menu bar is an item you cannot see.
|
||||||
icon = QIcon.fromTheme(name)
|
icon = QIcon.fromTheme(name)
|
||||||
|
if icon.isNull():
|
||||||
|
icon = trayicon.icon(name)
|
||||||
if icon.isNull():
|
if icon.isNull():
|
||||||
icon = QIcon.fromTheme("audio-input-microphone")
|
icon = QIcon.fromTheme("audio-input-microphone")
|
||||||
self.tray.setIcon(icon)
|
self.tray.setIcon(icon)
|
||||||
@@ -474,6 +483,12 @@ class Dikte:
|
|||||||
"agent": assistant.display_name(self.conf),
|
"agent": assistant.display_name(self.conf),
|
||||||
"provider": assistant.provider(self.conf),
|
"provider": assistant.provider(self.conf),
|
||||||
"listener": self.evdev.running,
|
"listener": self.evdev.running,
|
||||||
|
# Asked here rather than by the command line, because on macOS
|
||||||
|
# there is no registry to read: a combination is held by this
|
||||||
|
# process and by nothing else, so this is the only process that
|
||||||
|
# can say whether it is.
|
||||||
|
"shortcuts": {name: hotkey.shortcut_status(spec.desktop_id)
|
||||||
|
for name, spec in hotkey.SHORTCUTS.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
def reload_settings(self):
|
def reload_settings(self):
|
||||||
@@ -722,6 +737,11 @@ class Dikte:
|
|||||||
self._settle(MEETING, {"ok": False, "error": message})
|
self._settle(MEETING, {"ok": False, "error": message})
|
||||||
self._on_error(message)
|
self._on_error(message)
|
||||||
|
|
||||||
|
def _on_meeting_warning(self, message):
|
||||||
|
"""It was recorded and it is being written up, but read this first."""
|
||||||
|
self.tray.showMessage("Dikte", message,
|
||||||
|
QSystemTrayIcon.MessageIcon.Warning, 12000)
|
||||||
|
|
||||||
def _on_meeting_died(self):
|
def _on_meeting_died(self):
|
||||||
if self.meeting_state != M_RECORDING:
|
if self.meeting_state != M_RECORDING:
|
||||||
return
|
return
|
||||||
@@ -974,6 +994,51 @@ def install_signal_handlers(app):
|
|||||||
return reader, writer, notifier
|
return reader, writer, notifier
|
||||||
|
|
||||||
|
|
||||||
|
def _stay_out_of_the_dock():
|
||||||
|
"""Ask macOS to treat this as a menu bar application, not a windowed one.
|
||||||
|
|
||||||
|
LSUIElement in the bundle says the same thing, but it is read for the
|
||||||
|
process LaunchServices started, and that is the launcher script rather than
|
||||||
|
the Python it runs: the interpreter is a child, and the child inherits the
|
||||||
|
registration without inheriting the policy. Said here it holds however Dikte
|
||||||
|
was started, including straight from a terminal.
|
||||||
|
|
||||||
|
Accessory rather than Prohibited: a prohibited application cannot put
|
||||||
|
anything in the menu bar, which is the whole interface.
|
||||||
|
"""
|
||||||
|
if sys.platform != "darwin":
|
||||||
|
return
|
||||||
|
NS_ACCESSORY = 1 # NSApplicationActivationPolicyAccessory
|
||||||
|
try:
|
||||||
|
objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc"))
|
||||||
|
objc.objc_getClass.restype = ctypes.c_void_p
|
||||||
|
objc.objc_getClass.argtypes = [ctypes.c_char_p]
|
||||||
|
objc.sel_registerName.restype = ctypes.c_void_p
|
||||||
|
objc.sel_registerName.argtypes = [ctypes.c_char_p]
|
||||||
|
# objc_msgSend is a trampoline with no signature of its own, and on
|
||||||
|
# arm64 the arguments have to be in the registers the real method
|
||||||
|
# expects, so each call gets a prototype of its own. Built from the
|
||||||
|
# address: handing CFUNCTYPE the imported function object would make a
|
||||||
|
# callback into it rather than a call through it, and the crash lands
|
||||||
|
# inside the Objective-C runtime with nothing to read.
|
||||||
|
send = ctypes.cast(objc.objc_msgSend, ctypes.c_void_p).value
|
||||||
|
shared = ctypes.CFUNCTYPE(
|
||||||
|
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
|
||||||
|
)(send)
|
||||||
|
policy = ctypes.CFUNCTYPE(
|
||||||
|
ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long,
|
||||||
|
)(send)
|
||||||
|
|
||||||
|
application = shared(objc.objc_getClass(b"NSApplication"),
|
||||||
|
objc.sel_registerName(b"sharedApplication"))
|
||||||
|
if application:
|
||||||
|
policy(application, objc.sel_registerName(b"setActivationPolicy:"),
|
||||||
|
NS_ACCESSORY)
|
||||||
|
except (OSError, AttributeError, TypeError):
|
||||||
|
# A Dock icon is a blemish, not a failure: everything still works.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def run_app(args):
|
def run_app(args):
|
||||||
command = args[0] if args else ""
|
command = args[0] if args else ""
|
||||||
|
|
||||||
@@ -981,6 +1046,7 @@ def run_app(args):
|
|||||||
app.setApplicationName("Dikte")
|
app.setApplicationName("Dikte")
|
||||||
app.setDesktopFileName("dikte")
|
app.setDesktopFileName("dikte")
|
||||||
app.setQuitOnLastWindowClosed(False)
|
app.setQuitOnLastWindowClosed(False)
|
||||||
|
_stay_out_of_the_dock()
|
||||||
# Before Dikte is built, because building it is what may start a server, and
|
# Before Dikte is built, because building it is what may start a server, and
|
||||||
# a signal arriving in the middle of that would otherwise take the default
|
# a signal arriving in the middle of that would otherwise take the default
|
||||||
# action and leave the server behind. A signal this early lands in the
|
# action and leave the server behind. A signal this early lands in the
|
||||||
|
|||||||
@@ -46,21 +46,17 @@ import urllib.request
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
import hub
|
import hub
|
||||||
|
import paths
|
||||||
from i18n import t
|
from i18n import t
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
HOST = "127.0.0.1"
|
||||||
# The path api.py asks for, so its URL and the server's line up.
|
# The path api.py asks for, so its URL and the server's line up.
|
||||||
INFERENCE_PATH = "/v1/audio/transcriptions"
|
INFERENCE_PATH = "/v1/audio/transcriptions"
|
||||||
|
|
||||||
def _data_dir():
|
# Not worked out here: a Mac keeps its data under ~/Library, and a copy of the
|
||||||
if sys.platform == "win32":
|
# rule that did not know that put several gigabytes of models somewhere no Mac
|
||||||
return pathlib.Path(os.environ.get("LOCALAPPDATA")
|
# user looks and uninstall.sh never deleted from.
|
||||||
or os.path.expanduser("~/AppData/Local")) / "Dikte"
|
DATA_DIR = paths.DATA_DIR
|
||||||
return pathlib.Path(os.environ.get("XDG_DATA_HOME")
|
|
||||||
or os.path.expanduser("~/.local/share")) / "dikte"
|
|
||||||
|
|
||||||
|
|
||||||
DATA_DIR = _data_dir()
|
|
||||||
BIN_DIR = DATA_DIR / "bin"
|
BIN_DIR = DATA_DIR / "bin"
|
||||||
MODELS_DIR = DATA_DIR / "models"
|
MODELS_DIR = DATA_DIR / "models"
|
||||||
|
|
||||||
@@ -358,10 +354,17 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
if item:
|
if item:
|
||||||
break
|
break
|
||||||
if item is None:
|
if item is None:
|
||||||
|
# Nothing to download and nothing to install for you: whisper.cpp
|
||||||
|
# publishes no macOS binary, and Homebrew's whisper-cpp is configured
|
||||||
|
# with WHISPER_BUILD_SERVER=OFF, so it is whisper-cli that lands and not
|
||||||
|
# the server Dikte talks to. Building it is a cmake line, and the
|
||||||
|
# binary is picked up from the PATH or from the box above, the same way
|
||||||
|
# a distribution's own build is on Linux.
|
||||||
if sys.platform == "darwin" and program is WHISPER:
|
if sys.platform == "darwin" and program is WHISPER:
|
||||||
raise LocalError(t(
|
raise LocalError(t(
|
||||||
"whisper.cpp publishes no macOS build. Install it with: "
|
"whisper.cpp has no macOS build, and Homebrew's leaves out the "
|
||||||
"brew install whisper-cpp"
|
"server. Build whisper-server yourself and give its path here, "
|
||||||
|
"or transcribe in the cloud. See the README."
|
||||||
))
|
))
|
||||||
raise LocalError(t("{repo} {tag} has no build for this machine.",
|
raise LocalError(t("{repo} {tag} has no build for this machine.",
|
||||||
repo=program.repo, tag=tag))
|
repo=program.repo, tag=tag))
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ SHORTCUTS = {
|
|||||||
"meeting_shortcut", ""),
|
"meeting_shortcut", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The fallbacks above are Linux's. macOS holds Ctrl+Space for the input-source
|
||||||
|
# switch, so asking for it there gets a combination that either loses to the
|
||||||
|
# system or fires while the keyboard layout changes underneath the dictation.
|
||||||
|
MACOS_FALLBACKS = {"toggle": "Ctrl+Option+Space"}
|
||||||
|
|
||||||
# --- evdev key codes (linux/input-event-codes.h) --------------------------
|
# --- evdev key codes (linux/input-event-codes.h) --------------------------
|
||||||
|
|
||||||
EV_KEY = 0x01
|
EV_KEY = 0x01
|
||||||
@@ -718,6 +723,18 @@ def listener(parent=None):
|
|||||||
return EvdevHotkey(parent)
|
return EvdevHotkey(parent)
|
||||||
|
|
||||||
|
|
||||||
|
def default_combo(which):
|
||||||
|
"""What to register for `which` when the setting has been cleared.
|
||||||
|
|
||||||
|
The one place the platforms disagree about a default, so that the command
|
||||||
|
line and the settings window cannot drift apart on it.
|
||||||
|
"""
|
||||||
|
if _macos():
|
||||||
|
return MACOS_FALLBACKS.get(which, "")
|
||||||
|
spec = SHORTCUTS.get(which)
|
||||||
|
return spec.fallback if spec else ""
|
||||||
|
|
||||||
|
|
||||||
def valid_shortcut(text):
|
def valid_shortcut(text):
|
||||||
"""Whether this machine can bind the combination as it was typed."""
|
"""Whether this machine can bind the combination as it was typed."""
|
||||||
if _macos():
|
if _macos():
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ TR = {
|
|||||||
"Could not connect: {reason}": "Bağlantı kurulamadı: {reason}",
|
"Could not connect: {reason}": "Bağlantı kurulamadı: {reason}",
|
||||||
"Could not parse the response: {error}": "Yanıt çözümlenemedi: {error}",
|
"Could not parse the response: {error}": "Yanıt çözümlenemedi: {error}",
|
||||||
|
|
||||||
|
"whisper.cpp has no macOS build, and Homebrew's leaves out the server. "
|
||||||
|
"Build whisper-server yourself and give its path here, or transcribe in "
|
||||||
|
"the cloud. See the README.":
|
||||||
|
"whisper.cpp'nin macOS sürümü yok, Homebrew'unki de sunucuyu dışarıda "
|
||||||
|
"bırakıyor. whisper-server'ı kendin derleyip yolunu buraya yaz, ya da "
|
||||||
|
"buluta çevir. README'ye bak.",
|
||||||
|
|
||||||
# --- settings: tabs and general ------------------------------------
|
# --- settings: tabs and general ------------------------------------
|
||||||
"Dikte Settings": "Dikte Ayarları",
|
"Dikte Settings": "Dikte Ayarları",
|
||||||
"General": "Genel",
|
"General": "Genel",
|
||||||
@@ -564,6 +571,24 @@ TR = {
|
|||||||
"Hangi ses çıkışının kaydedileceği anlaşılamadı. Ayarlar → Toplantı "
|
"Hangi ses çıkışının kaydedileceği anlaşılamadı. Ayarlar → Toplantı "
|
||||||
"sekmesinden seç.",
|
"sekmesinden seç.",
|
||||||
"Nothing was recorded: {error}": "Hiçbir şey kaydedilmedi: {error}",
|
"Nothing was recorded: {error}": "Hiçbir şey kaydedilmedi: {error}",
|
||||||
|
"The saved macOS audio device uses an old numeric index. Open Settings and "
|
||||||
|
"select the device again before recording.":
|
||||||
|
"Kayıtlı macOS ses aygıtı eski bir sayısal indeks kullanıyor. Kayıttan "
|
||||||
|
"önce Ayarlar'ı açıp aygıtı yeniden seç.",
|
||||||
|
"The saved macOS audio device is no longer connected: {device}. Open "
|
||||||
|
"Settings and select another device.":
|
||||||
|
"Kayıtlı macOS ses aygıtı artık bağlı değil: {device}. Ayarlar'ı açıp "
|
||||||
|
"başka bir aygıt seç.",
|
||||||
|
"More than one macOS audio device is named {device}. Disconnect the "
|
||||||
|
"duplicate or choose a different device.":
|
||||||
|
"Birden fazla macOS ses aygıtının adı {device}. Aynı adlı aygıtlardan "
|
||||||
|
"birini çıkar ya da başka bir aygıt seç.",
|
||||||
|
"The microphone handed over almost nothing ({percent}% of the recording was "
|
||||||
|
"empty), so your own side of the meeting will be mostly missing. Check the "
|
||||||
|
"device before the next one.":
|
||||||
|
"Mikrofon neredeyse hiçbir şey iletmedi (kaydın %{percent} kadarı boştu), "
|
||||||
|
"toplantının senin tarafın büyük ölçüde eksik olacak. Bir sonrakinden "
|
||||||
|
"önce aygıtı kontrol et.",
|
||||||
"Transcribing {side}: {index}/{count}…":
|
"Transcribing {side}: {index}/{count}…":
|
||||||
"{side} yazıya çevriliyor: {index}/{count}…",
|
"{side} yazıya çevriliyor: {index}/{count}…",
|
||||||
"you": "sen",
|
"you": "sen",
|
||||||
|
|||||||
Executable
+312
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Dikte on macOS: dependency check, the application bundle, the command, and
|
||||||
|
# the login item. install.sh hands over to this on a Mac; run it directly and
|
||||||
|
# it does the same thing.
|
||||||
|
#
|
||||||
|
# A Mac needs a bundle where Linux needs a .desktop file, and for the same
|
||||||
|
# reason plus one: it is what puts a name and an icon in the Finder, and it is
|
||||||
|
# also the identity macOS files the microphone and Accessibility permissions
|
||||||
|
# under. Run as a bare script instead, every permission is granted to whatever
|
||||||
|
# copy of python3 happened to run it, and it is granted again the next time
|
||||||
|
# Homebrew moves that copy.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_DIR="$HOME/Applications"
|
||||||
|
APP="$APP_DIR/Dikte.app"
|
||||||
|
BIN_DIR="$HOME/.local/bin"
|
||||||
|
AGENT_DIR="$HOME/Library/LaunchAgents"
|
||||||
|
AGENT_ID="io.github.yusufipk.dikte"
|
||||||
|
AGENT="$AGENT_DIR/$AGENT_ID.plist"
|
||||||
|
SHORTCUT="${1:-Ctrl+Option+Space}"
|
||||||
|
# Without the colon, so that a second argument given as "" stays empty. That is
|
||||||
|
# how update.sh says "this one was turned off", as against not saying anything.
|
||||||
|
CANCEL_SHORTCUT="${2-Ctrl+Option+D}"
|
||||||
|
|
||||||
|
# The two places Homebrew installs to, in front, for the same reason dikte.py
|
||||||
|
# puts them there: a shell that has not been logged into since Homebrew was
|
||||||
|
# installed does not have them, and this script would then report ffmpeg as
|
||||||
|
# missing while the application finds it perfectly well.
|
||||||
|
PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
say() { printf ' %s\n' "$1"; }
|
||||||
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||||
|
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
|
||||||
|
die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Installing Dikte"
|
||||||
|
echo "────────────────"
|
||||||
|
|
||||||
|
# 1. An interpreter new enough ---------------------------------------------
|
||||||
|
# The system python3 is 3.9 and will stay 3.9: Apple ships it for its own
|
||||||
|
# scripts, not for anybody's application, and Dikte needs 3.11. So the first
|
||||||
|
# question is not "is python3 there" but "which python3", and the answer is
|
||||||
|
# written into the bundle rather than looked up again at launch, because a
|
||||||
|
# bundle started from the Finder gets none of the shell's PATH.
|
||||||
|
find_python() {
|
||||||
|
local candidate
|
||||||
|
for candidate in \
|
||||||
|
"${DIKTE_PYTHON:-}" \
|
||||||
|
/opt/homebrew/bin/python3.14 /opt/homebrew/bin/python3.13 \
|
||||||
|
/opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 \
|
||||||
|
/usr/local/bin/python3.14 /usr/local/bin/python3.13 \
|
||||||
|
/usr/local/bin/python3.12 /usr/local/bin/python3.11 \
|
||||||
|
python3.14 python3.13 python3.12 python3.11 python3
|
||||||
|
do
|
||||||
|
[[ -n "$candidate" ]] || continue
|
||||||
|
candidate="$(command -v "$candidate" 2>/dev/null)" || continue
|
||||||
|
# Resolved, so that the bundle does not point at a Homebrew shim that a
|
||||||
|
# later `brew upgrade` repoints at a version Dikte cannot run on.
|
||||||
|
candidate="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")"
|
||||||
|
# Exit status, not printed output: a candidate that cannot run Python at
|
||||||
|
# all and one that is too old are the same answer here.
|
||||||
|
if "$candidate" -c 'import sys; raise SystemExit(sys.version_info < (3, 11))' 2>/dev/null; then
|
||||||
|
printf '%s' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
PY="$(find_python || true)"
|
||||||
|
if [[ -z "$PY" ]]; then
|
||||||
|
warn "No Python 3.11 or newer found (the one Apple ships is 3.9)."
|
||||||
|
say "brew install [email protected]"
|
||||||
|
die "Nothing was installed."
|
||||||
|
fi
|
||||||
|
ok "Python: $PY ($("$PY" -c 'import platform; print(platform.python_version())'))"
|
||||||
|
|
||||||
|
# 2. The rest of the dependencies ------------------------------------------
|
||||||
|
missing=()
|
||||||
|
"$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("PyQt6")
|
||||||
|
command -v ffmpeg >/dev/null || missing+=("ffmpeg")
|
||||||
|
|
||||||
|
if ((${#missing[@]})); then
|
||||||
|
warn "Missing: ${missing[*]}"
|
||||||
|
for item in "${missing[@]}"; do
|
||||||
|
case "$item" in
|
||||||
|
# Not pip: Homebrew's python is marked externally managed, so installing
|
||||||
|
# into it is refused. `brew install pyqt` puts PyQt6 where its own
|
||||||
|
# interpreter already looks. A virtualenv is the other answer, and then
|
||||||
|
# DIKTE_PYTHON is how this script is pointed at it.
|
||||||
|
PyQt6) say "PyQt6: brew install pyqt" ;;
|
||||||
|
ffmpeg) say "ffmpeg: brew install ffmpeg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# ffmpeg alone is survivable: it is what records, so nothing works without
|
||||||
|
# it, but the settings window opens and says so. PyQt6 is not: there is no
|
||||||
|
# window to say anything in.
|
||||||
|
if [[ " ${missing[*]} " == *" PyQt6 "* ]]; then
|
||||||
|
echo
|
||||||
|
die "PyQt6 is what the whole interface is; install it and run this again."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
else
|
||||||
|
ok "All dependencies present"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. The application bundle -------------------------------------------------
|
||||||
|
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||||
|
|
||||||
|
# The interpreter is copied in rather than run where it lies, and that copy is
|
||||||
|
# what the whole of macOS's idea of "which application is this" rests on.
|
||||||
|
#
|
||||||
|
# A bundle whose launcher runs an interpreter somewhere else is not that
|
||||||
|
# interpreter's application: the process is /opt/homebrew/…/python3.13, so that
|
||||||
|
# is the name in the microphone dialog, that is the row in Accessibility, and
|
||||||
|
# every application on the machine sharing that interpreter shares the
|
||||||
|
# permission. Copied to Contents/MacOS and exec'd from there, the running
|
||||||
|
# executable sits inside Dikte.app, macOS reads the Info.plist above it, and
|
||||||
|
# the permissions are Dikte's: its name, its icon, its row.
|
||||||
|
#
|
||||||
|
# The copy runs because the framework it links against is named by an absolute
|
||||||
|
# path; what it loses is the tree it was found in, which is what PYTHONHOME and
|
||||||
|
# PYTHONPATH below hand back. Both of those are what a `brew upgrade python`
|
||||||
|
# moves out from under it, so the launcher checks before it starts: a bundle
|
||||||
|
# that fails silently is a menu bar with nothing in it and no way to guess why.
|
||||||
|
PY_REAL="$("$PY" -c 'import os, sys; print(os.path.realpath(getattr(sys, "_base_executable", sys.executable)))')"
|
||||||
|
PY_HOME="$("$PY" -c 'import sys; print(sys.base_prefix)')"
|
||||||
|
PY_SITE="$("$PY" -c 'import site; print(site.getsitepackages()[0])')"
|
||||||
|
cp -f "$PY_REAL" "$APP/Contents/MacOS/python3"
|
||||||
|
|
||||||
|
# exec, and this time it is right. The earlier version of this script did not
|
||||||
|
# exec, because exec'ing an interpreter outside the bundle throws away the
|
||||||
|
# application registration LaunchServices handed to the process it started, and
|
||||||
|
# what is left answers the socket while drawing no menu bar icon at all. The
|
||||||
|
# target here is inside the bundle, so the registration survives it.
|
||||||
|
cat > "$APP/Contents/MacOS/Dikte" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
# Written by install-mac.sh. Edit that, not this.
|
||||||
|
HERE=\$(cd "\$(dirname "\$0")" && pwd)
|
||||||
|
export PYTHONHOME="$PY_HOME"
|
||||||
|
export PYTHONPATH="$PY_SITE"
|
||||||
|
# Started from the Finder there is no terminal to print to, so the one thing
|
||||||
|
# that can go wrong on its own says so in a dialog.
|
||||||
|
if [ ! -d "\$PYTHONHOME" ]; then
|
||||||
|
osascript -e 'display alert "Dikte" message "The Python this was installed against is gone, most likely after a brew upgrade. Run ./install.sh again."' >/dev/null 2>&1
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec "\$HERE/python3" "$DIR/dikte.py" --gui "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$APP/Contents/MacOS/Dikte"
|
||||||
|
|
||||||
|
# LSUIElement is the line that makes this a menu bar application: no Dock icon,
|
||||||
|
# no menu bar of its own, nothing in the app switcher. The usage strings are
|
||||||
|
# not decoration either, they are what the permission dialog reads out, and a
|
||||||
|
# bundle that asks for the microphone without one is killed rather than asked
|
||||||
|
# about.
|
||||||
|
version="$(cd "$DIR" && git describe --tags --always 2>/dev/null || echo 0)"
|
||||||
|
cat > "$APP/Contents/Info.plist" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key> <string>Dikte</string>
|
||||||
|
<key>CFBundleDisplayName</key> <string>Dikte</string>
|
||||||
|
<key>CFBundleIdentifier</key> <string>$AGENT_ID</string>
|
||||||
|
<key>CFBundleExecutable</key> <string>Dikte</string>
|
||||||
|
<key>CFBundleIconFile</key> <string>Dikte</string>
|
||||||
|
<key>CFBundlePackageType</key> <string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>$version</string>
|
||||||
|
<key>CFBundleVersion</key> <string>$version</string>
|
||||||
|
<key>LSMinimumSystemVersion</key> <string>11.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key> <true/>
|
||||||
|
<key>LSUIElement</key> <true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Dikte records what you dictate so that it can be transcribed.</string>
|
||||||
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
|
<string>Dikte puts the transcript on the clipboard and pastes it into the window you were typing in.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
printf 'APPL????' > "$APP/Contents/PkgInfo"
|
||||||
|
|
||||||
|
# The icon, drawn by trayicon.py so that there is no binary in the repository
|
||||||
|
# and no second place to change what Dikte looks like. Failing to draw it is
|
||||||
|
# not worth stopping for: a bundle with no icon gets the generic one.
|
||||||
|
iconset="$(mktemp -d)/Dikte.iconset"
|
||||||
|
if "$PY" "$DIR/trayicon.py" "$iconset" >/dev/null 2>&1 \
|
||||||
|
&& iconutil -c icns "$iconset" -o "$APP/Contents/Resources/Dikte.icns" 2>/dev/null; then
|
||||||
|
ok "Icon drawn"
|
||||||
|
else
|
||||||
|
warn "Could not build the icon; the bundle gets the generic one"
|
||||||
|
fi
|
||||||
|
rm -rf "$(dirname "$iconset")"
|
||||||
|
|
||||||
|
# Signed, ad-hoc, and this is the load-bearing step. macOS remembers a
|
||||||
|
# permission against a code signature; with none, it remembers a path and a
|
||||||
|
# hash of the bundle, and every reinstall is a bundle it has never seen, which
|
||||||
|
# means granting Accessibility and the microphone again each time. --force
|
||||||
|
# because a reinstall is signing over the last signature.
|
||||||
|
if codesign --force --sign - --identifier "$AGENT_ID" "$APP" 2>/dev/null; then
|
||||||
|
ok "Application bundle: $APP"
|
||||||
|
else
|
||||||
|
warn "Could not sign $APP; macOS will ask for its permissions again on every"
|
||||||
|
say "reinstall. Install the command line tools: xcode-select --install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# LaunchServices does not necessarily notice a bundle written under a directory
|
||||||
|
# it was not watching, and until it does, `open -a Dikte` cannot find it.
|
||||||
|
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
|
||||||
|
-f "$APP" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# 4. The command ------------------------------------------------------------
|
||||||
|
# A wrapper, where Linux gets a symlink to dikte.py. The shebang there is
|
||||||
|
# `env python3`, and on a Mac that is Apple's 3.9: the symlink would resolve to
|
||||||
|
# the one interpreter that cannot run this. Naming the interpreter here also
|
||||||
|
# gives update.sh and uninstall.sh somewhere to read it from, so that the three
|
||||||
|
# scripts cannot disagree about which Python this installation is on.
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
cat > "$BIN_DIR/dikte" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
# Written by install-mac.sh. Edit that, not this.
|
||||||
|
exec "$PY" "$DIR/dikte.py" "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$BIN_DIR/dikte" "$DIR/dikte.py"
|
||||||
|
ok "Command installed: $BIN_DIR/dikte"
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$BIN_DIR:"*) ;;
|
||||||
|
*) warn "$BIN_DIR is not on your PATH. Add it in ~/.zprofile:"
|
||||||
|
say " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 5. Starting at login ------------------------------------------------------
|
||||||
|
# A LaunchAgent rather than a Login Item: it is a file this script can write
|
||||||
|
# and uninstall.sh can delete, where a Login Item is a database only the user
|
||||||
|
# can edit by hand. KeepAlive is off on purpose: quitting from the tray menu
|
||||||
|
# should quit it, not restart it.
|
||||||
|
mkdir -p "$AGENT_DIR"
|
||||||
|
cat > "$AGENT" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key> <string>$AGENT_ID</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/open</string>
|
||||||
|
<string>-a</string>
|
||||||
|
<string>$APP</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key> <true/>
|
||||||
|
<key>KeepAlive</key> <false/>
|
||||||
|
<key>ProcessType</key> <string>Interactive</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
# Through `open` rather than running the launcher directly, so that the process
|
||||||
|
# is one LaunchServices started: that is what gives it the bundle's identity,
|
||||||
|
# and so the permissions granted to Dikte rather than to launchd.
|
||||||
|
launchctl bootout "gui/$(id -u)/$AGENT_ID" >/dev/null 2>&1 || true
|
||||||
|
if launchctl bootstrap "gui/$(id -u)" "$AGENT" >/dev/null 2>&1; then
|
||||||
|
ok "Will start automatically on login"
|
||||||
|
else
|
||||||
|
warn "Could not register the login item; add $APP under"
|
||||||
|
say "System Settings → General → Login Items instead."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. The shortcuts ----------------------------------------------------------
|
||||||
|
# Dikte registers them rather than this script writing a file: macOS keeps no
|
||||||
|
# shortcut registry at all, so "installed" means the running application is
|
||||||
|
# holding the combination, and the settings file is where it reads it from
|
||||||
|
# after a restart. Nothing here needs a logout, unlike KDE.
|
||||||
|
if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
|
||||||
|
warn "Both arguments are $SHORTCUT, so the discard key was left out."
|
||||||
|
say "Pass two different combinations, or set it in Settings → Shortcuts."
|
||||||
|
CANCEL_SHORTCUT=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
register() { # which combination label
|
||||||
|
if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then
|
||||||
|
ok "$3: $2"
|
||||||
|
else
|
||||||
|
warn "${out%%$'\n'*}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
register toggle "$SHORTCUT" "Start and stop"
|
||||||
|
if [[ -n "$CANCEL_SHORTCUT" ]]; then
|
||||||
|
register cancel "$CANCEL_SHORTCUT" "Discard the recording"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. What is left to do by hand ---------------------------------------------
|
||||||
|
# Two things this script cannot do, because macOS only takes them from the
|
||||||
|
# person at the keyboard.
|
||||||
|
echo
|
||||||
|
say "Two permissions have to be granted the first time, and macOS will ask:"
|
||||||
|
say " • Microphone, when the first recording starts"
|
||||||
|
say " • Accessibility, when the first transcript is pasted"
|
||||||
|
say "Both are under System Settings → Privacy & Security."
|
||||||
|
if ! system_profiler SPAudioDataType 2>/dev/null | grep -qi 'blackhole\|loopback\|soundflower'; then
|
||||||
|
echo
|
||||||
|
say "Recording a meeting also needs a loopback driver: macOS does not offer"
|
||||||
|
say "what the speakers are playing as something to record. Install one with:"
|
||||||
|
say " brew install blackhole-2ch"
|
||||||
|
say "Dictation does not need it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
ok "Done. Start it with: open -a Dikte"
|
||||||
|
say "The settings window opens on first run: download a speech model, or add"
|
||||||
|
say "an OpenAI or OpenRouter key instead."
|
||||||
|
echo
|
||||||
@@ -3,6 +3,15 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Nothing below this line is true on a Mac: no XDG directories, no .desktop
|
||||||
|
# files, no shortcut registry, and an application is a bundle rather than a
|
||||||
|
# path. That is a second script rather than a branch through this one, and
|
||||||
|
# update.sh reaches it through here without having to know which it is on.
|
||||||
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||||
|
exec "$DIR/install-mac.sh" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
PY="$(command -v python3)"
|
PY="$(command -v python3)"
|
||||||
BIN_DIR="$HOME/.local/bin"
|
BIN_DIR="$HOME/.local/bin"
|
||||||
APP_DIR="$HOME/.local/share/applications"
|
APP_DIR="$HOME/.local/share/applications"
|
||||||
|
|||||||
@@ -200,6 +200,14 @@ def _macos_api():
|
|||||||
tool="CoreGraphics", error=exc)) from exc
|
tool="CoreGraphics", error=exc)) from exc
|
||||||
services.AXIsProcessTrusted.argtypes = []
|
services.AXIsProcessTrusted.argtypes = []
|
||||||
services.AXIsProcessTrusted.restype = ctypes.c_bool
|
services.AXIsProcessTrusted.restype = ctypes.c_bool
|
||||||
|
services.AXIsProcessTrustedWithOptions.argtypes = [ctypes.c_void_p]
|
||||||
|
services.AXIsProcessTrustedWithOptions.restype = ctypes.c_bool
|
||||||
|
core.CFDictionaryCreate.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p),
|
||||||
|
ctypes.POINTER(ctypes.c_void_p), ctypes.c_long,
|
||||||
|
ctypes.c_void_p, ctypes.c_void_p,
|
||||||
|
]
|
||||||
|
core.CFDictionaryCreate.restype = ctypes.c_void_p
|
||||||
services.CGEventCreateKeyboardEvent.argtypes = [
|
services.CGEventCreateKeyboardEvent.argtypes = [
|
||||||
ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool,
|
ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool,
|
||||||
]
|
]
|
||||||
@@ -224,16 +232,60 @@ def _macos_trusted():
|
|||||||
_asked_for_permission = False
|
_asked_for_permission = False
|
||||||
|
|
||||||
|
|
||||||
def _ask_for_permission():
|
def _macos_prompt_options(services, core):
|
||||||
"""Open the one settings pane that grants it, and only the first time.
|
"""{kAXTrustedCheckOptionPrompt: true}, as a CFDictionary, or 0.
|
||||||
|
|
||||||
Every dictation would otherwise reopen it until the box is ticked, which is
|
Built by hand because there is no Objective-C bridge here and this is the
|
||||||
a window in the user's face on top of the paste that did not happen.
|
only dictionary Dikte ever makes. Its own function so that a test can hand
|
||||||
|
back something without a framework to read the constants out of.
|
||||||
|
"""
|
||||||
|
keys = (ctypes.c_void_p * 1)(
|
||||||
|
ctypes.c_void_p.in_dll(services, "kAXTrustedCheckOptionPrompt"))
|
||||||
|
values = (ctypes.c_void_p * 1)(
|
||||||
|
ctypes.c_void_p.in_dll(core, "kCFBooleanTrue"))
|
||||||
|
return core.CFDictionaryCreate(
|
||||||
|
None, keys, values, 1,
|
||||||
|
ctypes.byref(ctypes.c_void_p.in_dll(core, "kCFTypeDictionaryKeyCallBacks")),
|
||||||
|
ctypes.byref(ctypes.c_void_p.in_dll(core, "kCFTypeDictionaryValueCallBacks")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _macos_put_us_in_the_list():
|
||||||
|
"""Ask with the prompt, which is what creates the row to switch on.
|
||||||
|
|
||||||
|
AXIsProcessTrusted only answers the question, and an application that has
|
||||||
|
only ever asked it is not in Accessibility at all: the pane opens on a list
|
||||||
|
Dikte is not in, and the only way through is the + button and a trip to the
|
||||||
|
Applications folder. Asking with kAXTrustedCheckOptionPrompt puts it there,
|
||||||
|
and macOS shows its own dialog with the button that opens the pane.
|
||||||
|
"""
|
||||||
|
services, core = _macos_api()
|
||||||
|
options = _macos_prompt_options(services, core)
|
||||||
|
if not options:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
services.AXIsProcessTrustedWithOptions(options)
|
||||||
|
finally:
|
||||||
|
core.CFRelease(options)
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_for_permission():
|
||||||
|
"""Get Dikte into the Accessibility list, and only the first time.
|
||||||
|
|
||||||
|
Every dictation would otherwise reopen the pane until the box is ticked,
|
||||||
|
which is a window in the user's face on top of the paste that did not
|
||||||
|
happen.
|
||||||
"""
|
"""
|
||||||
global _asked_for_permission
|
global _asked_for_permission
|
||||||
if _asked_for_permission:
|
if _asked_for_permission:
|
||||||
return
|
return
|
||||||
_asked_for_permission = True
|
_asked_for_permission = True
|
||||||
|
try:
|
||||||
|
_macos_put_us_in_the_list()
|
||||||
|
except (PasteError, OSError, ValueError):
|
||||||
|
# An older macOS, or a framework that would not load: the pane below is
|
||||||
|
# still worth opening, even if the row has to be added by hand.
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
["open", ("x-apple.systempreferences:com.apple.preference.security"
|
["open", ("x-apple.systempreferences:com.apple.preference.security"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Where Dikte keeps its settings and its data, for the system it is on.
|
||||||
|
|
||||||
|
A module of its own because two others need the answer and one of them cannot
|
||||||
|
ask the other: config.py imports ggml.py, so ggml.py cannot import config.py
|
||||||
|
back. Left alone, each worked it out for itself, and only config.py knew about
|
||||||
|
macOS. The result on a Mac was settings under `~/Library/Application Support`
|
||||||
|
and several gigabytes of models under `~/.local/share`, which is not a place a
|
||||||
|
Mac user looks, and not a place `uninstall.sh --purge` would have deleted from.
|
||||||
|
|
||||||
|
Read at import, as the modules that use it already do. `directories()` takes the
|
||||||
|
platform as an argument so that a test can stand on the other one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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. 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.
|
||||||
|
"""
|
||||||
|
here = platform or sys.platform
|
||||||
|
if here == "darwin":
|
||||||
|
support = pathlib.Path.home() / "Library/Application Support/Dikte"
|
||||||
|
return support, support
|
||||||
|
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()
|
||||||
+11
-2
@@ -671,6 +671,14 @@ class SettingsWindow(QDialog):
|
|||||||
self.local_threads = QSpinBox()
|
self.local_threads = QSpinBox()
|
||||||
self.local_threads.setRange(0, 64)
|
self.local_threads.setRange(0, 64)
|
||||||
self.local_threads.setSpecialValueText(t("Automatic"))
|
self.local_threads.setSpecialValueText(t("Automatic"))
|
||||||
|
# A spin box asks for room for its numbers, and 64 is two characters:
|
||||||
|
# the word standing in for zero is what actually has to fit, and on
|
||||||
|
# macOS, where the stepper sits inside the frame, it does not. Widened
|
||||||
|
# to the word rather than to a number picked by eye, so that it still
|
||||||
|
# fits once the word is "Otomatik".
|
||||||
|
self.local_threads.setMinimumWidth(
|
||||||
|
self.local_threads.fontMetrics()
|
||||||
|
.horizontalAdvance(t("Automatic")) + 56)
|
||||||
self.local_options = QWidget()
|
self.local_options = QWidget()
|
||||||
options_form = QFormLayout(self.local_options)
|
options_form = QFormLayout(self.local_options)
|
||||||
options_form.setContentsMargins(0, 0, 0, 0)
|
options_form.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -1618,7 +1626,8 @@ class SettingsWindow(QDialog):
|
|||||||
# turns them off.
|
# turns them off.
|
||||||
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
for which, (box, _status, _missing) in self._shortcut_rows.items():
|
||||||
spec = hotkey.SHORTCUTS[which]
|
spec = hotkey.SHORTCUTS[which]
|
||||||
conf[spec.setting] = box.currentText().strip() or spec.fallback
|
conf[spec.setting] = (box.currentText().strip()
|
||||||
|
or hotkey.default_combo(which))
|
||||||
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
|
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
|
||||||
conf["history_limit"] = self.history_limit.value()
|
conf["history_limit"] = self.history_limit.value()
|
||||||
conf.save()
|
conf.save()
|
||||||
@@ -1864,7 +1873,7 @@ class SettingsWindow(QDialog):
|
|||||||
def _install_shortcut(self, which):
|
def _install_shortcut(self, which):
|
||||||
spec = hotkey.SHORTCUTS[which]
|
spec = hotkey.SHORTCUTS[which]
|
||||||
box, _status, _missing = self._shortcut_rows[which]
|
box, _status, _missing = self._shortcut_rows[which]
|
||||||
combo = box.currentText().strip() or spec.fallback
|
combo = box.currentText().strip() or hotkey.default_combo(which)
|
||||||
if not combo:
|
if not combo:
|
||||||
QMessageBox.information(self, t("Shortcut"),
|
QMessageBox.information(self, t("Shortcut"),
|
||||||
t("Type a key combination first."))
|
t("Type a key combination first."))
|
||||||
|
|||||||
+244
-28
@@ -16,6 +16,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
import wave
|
import wave
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -100,6 +101,22 @@ class StereoLevels(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(left, 0.25, places=3)
|
self.assertAlmostEqual(left, 0.25, places=3)
|
||||||
self.assertAlmostEqual(right, 0.5, places=3)
|
self.assertAlmostEqual(right, 0.5, places=3)
|
||||||
|
|
||||||
|
def test_two_mono_streams_are_interleaved_left_then_right(self):
|
||||||
|
self.assertEqual(
|
||||||
|
list(array.array("h", audio.interleave_mono(
|
||||||
|
pcm([100, 200, 300]), pcm([-100, -200, -300])
|
||||||
|
))),
|
||||||
|
[100, -100, 200, -200, 300, -300],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_interleaving_stops_at_the_shorter_stream(self):
|
||||||
|
self.assertEqual(
|
||||||
|
list(array.array("h", audio.interleave_mono(
|
||||||
|
pcm([100, 200]), pcm([-100])
|
||||||
|
))),
|
||||||
|
[100, -100],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WriteWav(DikteTest):
|
class WriteWav(DikteTest):
|
||||||
def test_the_header_says_what_the_recorder_captured(self):
|
def test_the_header_says_what_the_recorder_captured(self):
|
||||||
@@ -235,6 +252,34 @@ class FakeProcess:
|
|||||||
self._alive = False
|
self._alive = False
|
||||||
|
|
||||||
|
|
||||||
|
class StalledProcess(FakeProcess):
|
||||||
|
"""A capture that hands over a buffer and then stops answering at all.
|
||||||
|
|
||||||
|
Not the same thing as one that ends: the device is still there and the pipe
|
||||||
|
is still open, and a read of it never comes back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
super().__init__(data)
|
||||||
|
self.stdout = _StalledStream(data)
|
||||||
|
|
||||||
|
|
||||||
|
class _StalledStream:
|
||||||
|
def __init__(self, data):
|
||||||
|
self._data = io.BytesIO(data)
|
||||||
|
self._released = threading.Event()
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
chunk = self._data.read(size)
|
||||||
|
if chunk:
|
||||||
|
return chunk
|
||||||
|
self._released.wait()
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
self._released.set()
|
||||||
|
|
||||||
|
|
||||||
class RecordingCommand(OnLinux, DikteTest):
|
class RecordingCommand(OnLinux, DikteTest):
|
||||||
"""Which program captures the microphone, and how it is asked to."""
|
"""Which program captures the microphone, and how it is asked to."""
|
||||||
|
|
||||||
@@ -465,42 +510,178 @@ class RecorderChain(OnLinux, DikteTest):
|
|||||||
self.assertFalse(recorder.active)
|
self.assertFalse(recorder.active)
|
||||||
|
|
||||||
|
|
||||||
class MeetingCommand(unittest.TestCase):
|
class MeetingCommands(unittest.TestCase):
|
||||||
"""One process reading both devices, because two would drift apart."""
|
"""Pulse can share a process; AVFoundation sessions cannot."""
|
||||||
|
|
||||||
def command(self, platform, mic="", system="them"):
|
def commands(self, platform, mic="", system="them"):
|
||||||
with mock.patch.object(sys, "platform", platform):
|
with mock.patch.object(sys, "platform", platform), \
|
||||||
return audio.meeting_command(mic, system)
|
mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \
|
||||||
|
mock.patch.object(
|
||||||
|
audio, "_resolve_avfoundation_target",
|
||||||
|
side_effect=lambda target, inputs=None: target or "default"):
|
||||||
|
return audio.meeting_commands(mic, system)
|
||||||
|
|
||||||
def test_linux_reads_both_through_pulse(self):
|
def test_linux_reads_both_through_pulse(self):
|
||||||
cmd = self.command("linux", mic="mine")
|
commands = self.commands("linux", mic="mine")
|
||||||
|
self.assertEqual(len(commands), 1)
|
||||||
|
cmd = commands[0]
|
||||||
self.assertEqual(cmd.count("pulse"), 2)
|
self.assertEqual(cmd.count("pulse"), 2)
|
||||||
self.assertEqual(cmd[cmd.index("mine") - 1], "-i")
|
self.assertEqual(cmd[cmd.index("mine") - 1], "-i")
|
||||||
self.assertEqual(cmd[cmd.index("them") - 1], "-i")
|
self.assertEqual(cmd[cmd.index("them") - 1], "-i")
|
||||||
|
|
||||||
def test_a_mac_reads_both_through_avfoundation(self):
|
def test_a_mac_gives_each_avfoundation_device_its_own_process(self):
|
||||||
cmd = self.command("darwin", mic="1")
|
commands = self.commands("darwin", mic="mine")
|
||||||
self.assertEqual(cmd.count("avfoundation"), 2)
|
self.assertEqual(len(commands), 2)
|
||||||
self.assertIn(":1", cmd)
|
self.assertTrue(all(command.count("avfoundation") == 1
|
||||||
self.assertIn(":them", cmd)
|
for command in commands))
|
||||||
|
self.assertIn(":mine", commands[0])
|
||||||
|
self.assertIn(":them", commands[1])
|
||||||
|
|
||||||
def test_no_microphone_named_means_the_default_one(self):
|
def test_no_microphone_named_means_the_default_one(self):
|
||||||
self.assertIn("default", self.command("linux"))
|
self.assertIn("default", self.commands("linux")[0])
|
||||||
self.assertIn(":default", self.command("darwin"))
|
self.assertIn(":default", self.commands("darwin")[0])
|
||||||
|
|
||||||
def test_both_merge_the_two_into_one_stereo_stream(self):
|
def test_pulse_merges_the_two_into_one_stereo_stream(self):
|
||||||
for platform in ("linux", "darwin"):
|
cmd = self.commands("linux")[0]
|
||||||
with self.subTest(platform=platform):
|
self.assertIn(audio.MERGE_FILTER, cmd)
|
||||||
cmd = self.command(platform)
|
self.assertEqual(cmd[cmd.index("-map") + 1], "[out]")
|
||||||
self.assertIn(audio.MERGE_FILTER, cmd)
|
self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le")
|
||||||
self.assertEqual(cmd[cmd.index("-map") + 1], "[out]")
|
|
||||||
self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le")
|
def test_each_mac_process_produces_clock_corrected_mono_pcm(self):
|
||||||
|
for cmd in self.commands("darwin"):
|
||||||
|
self.assertIn("first_pts=0", cmd[cmd.index("-af") + 1])
|
||||||
|
self.assertEqual(cmd[cmd.index("-ac") + 1], "1")
|
||||||
|
self.assertEqual(cmd[-2:], ["1", "-"])
|
||||||
|
|
||||||
def test_neither_lets_ffmpeg_read_the_terminal(self):
|
def test_neither_lets_ffmpeg_read_the_terminal(self):
|
||||||
"""It shares stdin with Dikte, and would eat a keypress meant for it."""
|
"""It shares stdin with Dikte, and would eat a keypress meant for it."""
|
||||||
for platform in ("linux", "darwin"):
|
for platform in ("linux", "darwin"):
|
||||||
with self.subTest(platform=platform):
|
with self.subTest(platform=platform):
|
||||||
self.assertIn("-nostdin", self.command(platform))
|
for command in self.commands(platform):
|
||||||
|
self.assertIn("-nostdin", command)
|
||||||
|
|
||||||
|
def test_both_mac_devices_are_read_off_one_listing(self):
|
||||||
|
"""Asking twice costs an ffmpeg run, and the second answer could have
|
||||||
|
renumbered between the two."""
|
||||||
|
with mock.patch.object(sys, "platform", "darwin"), \
|
||||||
|
mock.patch.object(audio, "_avfoundation_inputs",
|
||||||
|
return_value=[("0", "mine"),
|
||||||
|
("1", "them")]) as inputs:
|
||||||
|
audio.meeting_commands("mine", "them")
|
||||||
|
inputs.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
class MacMeetingRecorder(OnMacOS, DikteTest):
|
||||||
|
# Whichever way the machine has them ordered, a name is what is saved and
|
||||||
|
# the index it happens to hold now is what ffmpeg is given.
|
||||||
|
DEVICES = [("0", "External Headset"), ("1", "BlackHole 2ch"),
|
||||||
|
("2", "MacBook Pro Microphone")]
|
||||||
|
|
||||||
|
def devices(self):
|
||||||
|
return mock.patch.object(audio, "_avfoundation_inputs",
|
||||||
|
return_value=self.DEVICES)
|
||||||
|
|
||||||
|
def record(self, mine, theirs):
|
||||||
|
path = str(self.path("meeting.wav"))
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
stopped, failed, warnings = [], [], []
|
||||||
|
recorder.stopped.connect(lambda *args: stopped.append(args))
|
||||||
|
recorder.failed.connect(failed.append)
|
||||||
|
recorder.warned.connect(warnings.append)
|
||||||
|
processes = [FakeProcess(mine), FakeProcess(theirs)]
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=processes) as popen:
|
||||||
|
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
return path, warnings, stopped, failed, processes, popen
|
||||||
|
|
||||||
|
def test_the_two_capture_processes_become_one_stereo_file(self):
|
||||||
|
path, _, stopped, failed, _, _ = self.record(
|
||||||
|
tone(1.0, freq=440), tone(1.0, freq=880)
|
||||||
|
)
|
||||||
|
self.assertEqual(failed, [])
|
||||||
|
self.assertEqual(len(stopped), 1)
|
||||||
|
with contextlib.closing(wave.open(path, "rb")) as wav:
|
||||||
|
self.assertEqual(wav.getnchannels(), 2)
|
||||||
|
self.assertEqual(wav.getframerate(), audio.RATE)
|
||||||
|
self.assertEqual(wav.getnframes(), audio.RATE)
|
||||||
|
|
||||||
|
def test_each_avfoundation_device_is_opened_by_a_different_process(self):
|
||||||
|
_, _, _, _, _, popen = self.record(tone(0.5), tone(0.5))
|
||||||
|
commands = [call.args[0] for call in popen.call_args_list]
|
||||||
|
self.assertEqual(len(commands), 2)
|
||||||
|
self.assertTrue(all(command.count("avfoundation") == 1
|
||||||
|
for command in commands))
|
||||||
|
self.assertIn(":2", commands[0])
|
||||||
|
self.assertIn(":1", commands[1])
|
||||||
|
|
||||||
|
def test_a_mostly_empty_microphone_is_said_out_loud_and_still_kept(self):
|
||||||
|
"""Half the file is everyone else, and an hour of them is worth more
|
||||||
|
than the empty channel costs."""
|
||||||
|
path, warnings, stopped, failed, _, _ = self.record(
|
||||||
|
silence(11.0), tone(11.0)
|
||||||
|
)
|
||||||
|
self.assertEqual(failed, [])
|
||||||
|
self.assertEqual(len(stopped), 1)
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
self.assertIn("empty", warnings[0])
|
||||||
|
|
||||||
|
def test_a_microphone_that_was_merely_quiet_is_not_complained_about(self):
|
||||||
|
_, warnings, stopped, _, _, _ = self.record(tone(11.0), tone(11.0))
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
self.assertEqual(len(stopped), 1)
|
||||||
|
|
||||||
|
def test_a_capture_that_falls_silent_ends_the_meeting_rather_than_hanging(self):
|
||||||
|
"""One thread taking turns on both pipes would sit on the dead read
|
||||||
|
until somebody noticed, an hour later."""
|
||||||
|
path = str(self.path("meeting.wav"))
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
stopped = []
|
||||||
|
recorder.stopped.connect(lambda *args: stopped.append(args))
|
||||||
|
mine, theirs = StalledProcess(tone(0.512)), FakeProcess(tone(30.0))
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(audio, "STALL_SECONDS", 0.2), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=(mine, theirs)):
|
||||||
|
try:
|
||||||
|
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||||
|
recorder._thread.join(timeout=2)
|
||||||
|
self.assertFalse(recorder.active)
|
||||||
|
recorder.stop()
|
||||||
|
finally:
|
||||||
|
mine.stdout.release()
|
||||||
|
self.assertAlmostEqual(stopped[0][1], 0.512, places=3)
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
|
||||||
|
def test_stopping_ends_both_capture_processes(self):
|
||||||
|
_, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5))
|
||||||
|
self.assertTrue(all(process.signals for process in processes))
|
||||||
|
|
||||||
|
def test_a_legacy_numeric_target_fails_before_recording(self):
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
failed = []
|
||||||
|
recorder.failed.connect(failed.append)
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(subprocess, "Popen") as popen:
|
||||||
|
recorder.start(str(self.path("meeting.wav")), "2", "1")
|
||||||
|
popen.assert_not_called()
|
||||||
|
self.assertIn("old numeric index", failed[0])
|
||||||
|
|
||||||
|
def test_a_second_capture_process_that_cannot_start_cleans_up_the_first(self):
|
||||||
|
"""A Mac left holding an open AVFoundation session records nothing
|
||||||
|
else until it is let go."""
|
||||||
|
path = str(self.path("meeting.wav"))
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
failed = []
|
||||||
|
recorder.failed.connect(failed.append)
|
||||||
|
first = FakeProcess(tone(1.0))
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(subprocess, "Popen",
|
||||||
|
side_effect=(first, OSError("refused"))):
|
||||||
|
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||||
|
self.assertTrue(first.signals)
|
||||||
|
self.assertIn("refused", failed[0])
|
||||||
|
self.assertFalse(os.path.exists(path))
|
||||||
|
|
||||||
|
|
||||||
class MacDevices(OnMacOS, DikteTest):
|
class MacDevices(OnMacOS, DikteTest):
|
||||||
@@ -527,12 +708,13 @@ class MacDevices(OnMacOS, DikteTest):
|
|||||||
def test_the_audio_half_of_the_listing_is_the_only_half_read(self):
|
def test_the_audio_half_of_the_listing_is_the_only_half_read(self):
|
||||||
with self.listing():
|
with self.listing():
|
||||||
self.assertEqual(audio.list_sources(),
|
self.assertEqual(audio.list_sources(),
|
||||||
[("0", "MacBook Pro Microphone"), ("1", "BlackHole 2ch")])
|
[("MacBook Pro Microphone", "MacBook Pro Microphone"),
|
||||||
|
("BlackHole 2ch", "BlackHole 2ch")])
|
||||||
|
|
||||||
def test_the_index_is_what_ffmpeg_is_given_and_the_name_what_is_shown(self):
|
def test_the_name_is_both_saved_and_shown(self):
|
||||||
with self.listing():
|
with self.listing():
|
||||||
name, description = audio.list_sources()[1]
|
name, description = audio.list_sources()[1]
|
||||||
self.assertEqual(name, "1")
|
self.assertEqual(name, "BlackHole 2ch")
|
||||||
self.assertIn("BlackHole", description)
|
self.assertIn("BlackHole", description)
|
||||||
|
|
||||||
def test_no_ffmpeg_installed(self):
|
def test_no_ffmpeg_installed(self):
|
||||||
@@ -557,7 +739,7 @@ class MacDevices(OnMacOS, DikteTest):
|
|||||||
|
|
||||||
def test_the_loopback_driver_is_picked_out_by_name(self):
|
def test_the_loopback_driver_is_picked_out_by_name(self):
|
||||||
with self.listing():
|
with self.listing():
|
||||||
self.assertEqual(audio.default_monitor(), "1")
|
self.assertEqual(audio.default_monitor(), "BlackHole 2ch")
|
||||||
|
|
||||||
def test_the_other_two_drivers_people_install(self):
|
def test_the_other_two_drivers_people_install(self):
|
||||||
for name in ("Loopback Audio", "Soundflower (2ch)"):
|
for name in ("Loopback Audio", "Soundflower (2ch)"):
|
||||||
@@ -565,13 +747,43 @@ class MacDevices(OnMacOS, DikteTest):
|
|||||||
listing = ("AVFoundation audio devices:\n"
|
listing = ("AVFoundation audio devices:\n"
|
||||||
f"[0] Built-in Microphone\n[1] {name}\n")
|
f"[0] Built-in Microphone\n[1] {name}\n")
|
||||||
with self.listing(stderr=listing):
|
with self.listing(stderr=listing):
|
||||||
self.assertEqual(audio.default_monitor(), "1")
|
self.assertEqual(audio.default_monitor(), name)
|
||||||
|
|
||||||
def test_a_mac_with_nothing_to_record_the_far_side_from(self):
|
def test_a_mac_with_nothing_to_record_the_far_side_from(self):
|
||||||
listing = "AVFoundation audio devices:\n[0] MacBook Pro Microphone\n"
|
listing = "AVFoundation audio devices:\n[0] MacBook Pro Microphone\n"
|
||||||
with self.listing(stderr=listing):
|
with self.listing(stderr=listing):
|
||||||
self.assertEqual(audio.default_monitor(), "")
|
self.assertEqual(audio.default_monitor(), "")
|
||||||
|
|
||||||
|
def test_a_saved_name_is_resolved_against_the_current_index(self):
|
||||||
|
with self.listing():
|
||||||
|
self.assertEqual(audio._resolve_avfoundation_target("BlackHole 2ch"), "1")
|
||||||
|
|
||||||
|
def test_a_saved_name_follows_the_device_when_an_earlier_one_disappears(self):
|
||||||
|
listing = ("AVFoundation audio devices:\n"
|
||||||
|
"[0] BlackHole 2ch\n[1] MacBook Pro Microphone\n")
|
||||||
|
with self.listing(stderr=listing):
|
||||||
|
self.assertEqual(
|
||||||
|
audio._resolve_avfoundation_target("MacBook Pro Microphone"), "1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_old_numeric_setting_is_not_silently_reused(self):
|
||||||
|
with self.assertRaises(audio.AudioDeviceError) as caught:
|
||||||
|
audio._resolve_avfoundation_target("1")
|
||||||
|
self.assertIn("old numeric index", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_device_that_went_away_is_said_out_loud(self):
|
||||||
|
with self.listing(), self.assertRaises(audio.AudioDeviceError) as caught:
|
||||||
|
audio._resolve_avfoundation_target("USB Microphone")
|
||||||
|
self.assertIn("no longer connected", str(caught.exception))
|
||||||
|
|
||||||
|
def test_duplicate_names_are_not_guessed_between(self):
|
||||||
|
listing = ("AVFoundation audio devices:\n"
|
||||||
|
"[0] USB Microphone\n[1] USB Microphone\n")
|
||||||
|
with self.listing(stderr=listing), \
|
||||||
|
self.assertRaises(audio.AudioDeviceError) as caught:
|
||||||
|
audio._resolve_avfoundation_target("USB Microphone")
|
||||||
|
self.assertIn("More than one", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
class MacRecordingCommand(OnMacOS, DikteTest):
|
class MacRecordingCommand(OnMacOS, DikteTest):
|
||||||
def test_the_microphone_is_read_through_avfoundation(self):
|
def test_the_microphone_is_read_through_avfoundation(self):
|
||||||
@@ -583,7 +795,11 @@ class MacRecordingCommand(OnMacOS, DikteTest):
|
|||||||
def test_the_empty_half_in_front_of_the_colon_is_the_missing_picture(self):
|
def test_the_empty_half_in_front_of_the_colon_is_the_missing_picture(self):
|
||||||
with only_these_tools("ffmpeg"):
|
with only_these_tools("ffmpeg"):
|
||||||
self.assertIn(":default", audio.recording_command())
|
self.assertIn(":default", audio.recording_command())
|
||||||
self.assertIn(":2", audio.recording_command("2"))
|
listing = "AVFoundation audio devices:\n[2] USB Microphone\n"
|
||||||
|
completed = FakeCompleted(returncode=1, stderr=listing)
|
||||||
|
with only_these_tools("ffmpeg"), \
|
||||||
|
mock.patch.object(subprocess, "run", return_value=completed):
|
||||||
|
self.assertIn(":2", audio.recording_command("USB Microphone"))
|
||||||
|
|
||||||
def test_it_captures_the_format_the_rest_of_the_code_expects(self):
|
def test_it_captures_the_format_the_rest_of_the_code_expects(self):
|
||||||
with only_these_tools("ffmpeg"):
|
with only_these_tools("ffmpeg"):
|
||||||
@@ -681,7 +897,7 @@ class WindowsDevices(OnWindows, DikteTest):
|
|||||||
with self.listing():
|
with self.listing():
|
||||||
self.assertEqual(audio.list_monitors(), [])
|
self.assertEqual(audio.list_monitors(), [])
|
||||||
self.assertEqual(audio.default_monitor(), "")
|
self.assertEqual(audio.default_monitor(), "")
|
||||||
self.assertEqual(audio.meeting_command("mic", "sys"), [])
|
self.assertEqual(audio.meeting_commands("mic", "sys"), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -334,6 +334,57 @@ class ConfigCommands(DikteTest):
|
|||||||
{"cleanup", "subtitles", "meeting", "agent"})
|
{"cleanup", "subtitles", "meeting", "agent"})
|
||||||
|
|
||||||
|
|
||||||
|
class ShortcutStatus(DikteTest):
|
||||||
|
"""Where the answer comes from, which is not one place on every system.
|
||||||
|
|
||||||
|
macOS keeps no shortcut registry: a combination is held by the running
|
||||||
|
process and by nothing else, so a command line that reads its own idea of
|
||||||
|
"installed" reports every shortcut as missing while all of them work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run_cmd(self, func, **values):
|
||||||
|
with captured() as (out, err):
|
||||||
|
code = func(Options(**values))
|
||||||
|
return code, out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def status(self, reply):
|
||||||
|
with mock.patch.object(ipc, "send", return_value=reply) as send:
|
||||||
|
code, out, _ = self.run_cmd(cli.cmd_shortcut, shortcut="status",
|
||||||
|
json=True)
|
||||||
|
return code, json.loads(out), send
|
||||||
|
|
||||||
|
def test_what_the_running_instance_holds_is_what_is_reported(self):
|
||||||
|
code, answer, _ = self.status({
|
||||||
|
"shortcuts": {"toggle": "Ctrl+Option+Space", "cancel": None,
|
||||||
|
"ask": None, "meeting": None},
|
||||||
|
"listener": True,
|
||||||
|
})
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertEqual(answer["shortcuts"]["toggle"]["registered"],
|
||||||
|
"Ctrl+Option+Space")
|
||||||
|
self.assertIsNone(answer["shortcuts"]["cancel"]["registered"])
|
||||||
|
self.assertIs(answer["listener"], True)
|
||||||
|
|
||||||
|
def test_the_instance_is_the_one_asked(self):
|
||||||
|
_, _, send = self.status({"shortcuts": {}, "listener": False})
|
||||||
|
send.assert_called_once_with("status")
|
||||||
|
|
||||||
|
def test_nothing_running_falls_back_to_what_this_process_can_read(self):
|
||||||
|
"""Which on Linux is the registry, and on macOS is nothing, correctly
|
||||||
|
so, because there the keys really are gone with the process."""
|
||||||
|
code, answer, _ = self.status(None)
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
for name, spec in hotkey.SHORTCUTS.items():
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.assertEqual(answer["shortcuts"][name]["registered"],
|
||||||
|
hotkey.shortcut_status(spec.desktop_id))
|
||||||
|
|
||||||
|
def test_the_configured_combination_is_reported_either_way(self):
|
||||||
|
code, answer, _ = self.status(None)
|
||||||
|
self.assertEqual(answer["shortcuts"]["toggle"]["configured"],
|
||||||
|
cfg.Config()["shortcut"])
|
||||||
|
|
||||||
|
|
||||||
class Providers(DikteTest):
|
class Providers(DikteTest):
|
||||||
"""The terminal reaches every provider the settings window does."""
|
"""The terminal reaches every provider the settings window does."""
|
||||||
|
|
||||||
|
|||||||
@@ -463,44 +463,6 @@ class Defaults(unittest.TestCase):
|
|||||||
paste.desktop().shortcuts[0])
|
paste.desktop().shortcuts[0])
|
||||||
|
|
||||||
|
|
||||||
class Directories(unittest.TestCase):
|
|
||||||
"""Where the settings and the recordings are kept, per system."""
|
|
||||||
|
|
||||||
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 = cfg._directories("linux")
|
|
||||||
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 = cfg._directories("linux")
|
|
||||||
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 = cfg._directories("darwin")
|
|
||||||
self.assertEqual(config_dir, data_dir)
|
|
||||||
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, _ = cfg._directories("darwin")
|
|
||||||
self.assertNotIn("/c", config_dir.as_posix())
|
|
||||||
|
|
||||||
def test_windows_keeps_settings_and_data_apart(self):
|
|
||||||
# Forward slashes, because a backslash only separates on Windows and
|
|
||||||
# this test also runs on the Linux that checks the Windows half.
|
|
||||||
with mock.patch.dict(os.environ, {"APPDATA": "C:/roam",
|
|
||||||
"LOCALAPPDATA": "C:/local"}):
|
|
||||||
config_dir, data_dir = cfg._directories("win32")
|
|
||||||
self.assertEqual(config_dir.as_posix(), "C:/roam/Dikte")
|
|
||||||
self.assertEqual(data_dir.as_posix(), "C:/local/Dikte")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -236,7 +236,7 @@ class InstallProgram(Local):
|
|||||||
with fake_urlopen(listing):
|
with fake_urlopen(listing):
|
||||||
with self.assertRaises(ggml.LocalError) as caught:
|
with self.assertRaises(ggml.LocalError) as caught:
|
||||||
ggml.install_program(ggml.WHISPER)
|
ggml.install_program(ggml.WHISPER)
|
||||||
self.assertIn("brew install whisper-cpp", str(caught.exception))
|
self.assertIn("Build whisper-server yourself", str(caught.exception))
|
||||||
|
|
||||||
def test_a_mac_uses_the_native_llama_archive_instead_of_ubuntu(self):
|
def test_a_mac_uses_the_native_llama_archive_instead_of_ubuntu(self):
|
||||||
self.patch_attr(sys, "platform", "darwin")
|
self.patch_attr(sys, "platform", "darwin")
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ class Table(unittest.TestCase):
|
|||||||
self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items()
|
self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items()
|
||||||
if spec.fallback], ["toggle"])
|
if spec.fallback], ["toggle"])
|
||||||
|
|
||||||
|
def test_the_fallback_a_mac_gets_is_not_one_macos_already_holds(self):
|
||||||
|
"""Ctrl+Space switches the input source there and Cmd+Space is
|
||||||
|
Spotlight, so the table's own fallback is Linux's and only Linux's."""
|
||||||
|
with mock.patch.object(hotkey.sys, "platform", "darwin"):
|
||||||
|
self.assertEqual(hotkey.default_combo("toggle"), "Ctrl+Option+Space")
|
||||||
|
self.assertEqual(hotkey.default_combo("cancel"), "")
|
||||||
|
with mock.patch.object(hotkey.sys, "platform", "linux"):
|
||||||
|
self.assertEqual(hotkey.default_combo("toggle"), "Ctrl+Space")
|
||||||
|
self.assertEqual(hotkey.default_combo("cancel"), "")
|
||||||
|
|
||||||
|
|
||||||
class ModsMatch(unittest.TestCase):
|
class ModsMatch(unittest.TestCase):
|
||||||
"""The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space."""
|
"""The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space."""
|
||||||
|
|||||||
@@ -279,11 +279,16 @@ class FakeCoreGraphics:
|
|||||||
self.flags = [] # (event, flags)
|
self.flags = [] # (event, flags)
|
||||||
self.posted = [] # (tap, event)
|
self.posted = [] # (tap, event)
|
||||||
self.released = []
|
self.released = []
|
||||||
|
self.prompted = [] # the options dictionaries asked with
|
||||||
|
|
||||||
# --- ApplicationServices
|
# --- ApplicationServices
|
||||||
def AXIsProcessTrusted(self):
|
def AXIsProcessTrusted(self):
|
||||||
return self.trusted
|
return self.trusted
|
||||||
|
|
||||||
|
def AXIsProcessTrustedWithOptions(self, options):
|
||||||
|
self.prompted.append(options)
|
||||||
|
return self.trusted
|
||||||
|
|
||||||
def CGEventCreateKeyboardEvent(self, source, keycode, down):
|
def CGEventCreateKeyboardEvent(self, source, keycode, down):
|
||||||
self.made.append((keycode, down))
|
self.made.append((keycode, down))
|
||||||
if self.makes is not None and len(self.made) > self.makes:
|
if self.makes is not None and len(self.made) > self.makes:
|
||||||
@@ -305,10 +310,16 @@ class MacOS(ClipboardContract, DikteTest):
|
|||||||
platform = "darwin"
|
platform = "darwin"
|
||||||
here = paste.MACOS
|
here = paste.MACOS
|
||||||
|
|
||||||
|
# A stand-in for the CFDictionary: the real one is built out of constants
|
||||||
|
# read from the frameworks, which a fake has none of.
|
||||||
|
OPTIONS = 4242
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.api = FakeCoreGraphics()
|
self.api = FakeCoreGraphics()
|
||||||
self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api))
|
self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api))
|
||||||
|
self.patch_attr(paste, "_macos_prompt_options",
|
||||||
|
lambda services, core: self.OPTIONS)
|
||||||
self.patch_attr(paste.time, "sleep", lambda seconds: None)
|
self.patch_attr(paste.time, "sleep", lambda seconds: None)
|
||||||
# It opens the settings pane once per run; each test gets its own run.
|
# It opens the settings pane once per run; each test gets its own run.
|
||||||
self.patch_attr(paste, "_asked_for_permission", False)
|
self.patch_attr(paste, "_asked_for_permission", False)
|
||||||
@@ -389,6 +400,31 @@ class MacOS(ClipboardContract, DikteTest):
|
|||||||
paste.press("cmd+v")
|
paste.press("cmd+v")
|
||||||
self.assertIn("Accessibility", str(caught.exception))
|
self.assertIn("Accessibility", str(caught.exception))
|
||||||
|
|
||||||
|
def test_asking_is_what_puts_dikte_in_the_accessibility_list(self):
|
||||||
|
"""Opening the pane is not enough on its own.
|
||||||
|
|
||||||
|
AXIsProcessTrusted only answers the question; an application that has
|
||||||
|
never asked with the prompt is not in the list, so the pane opens on a
|
||||||
|
list Dikte is not in and the only way through is the + button.
|
||||||
|
"""
|
||||||
|
self.api.trusted = False
|
||||||
|
with self.assertRaises(paste.PasteError):
|
||||||
|
paste.press("cmd+v")
|
||||||
|
self.assertEqual(self.api.prompted, [self.OPTIONS])
|
||||||
|
# The dictionary is ours to release, and nothing else made an event.
|
||||||
|
self.assertIn(self.OPTIONS, self.api.released)
|
||||||
|
|
||||||
|
def test_it_asks_once_however_many_dictations_fail(self):
|
||||||
|
self.api.trusted = False
|
||||||
|
for _ in range(3):
|
||||||
|
with self.assertRaises(paste.PasteError):
|
||||||
|
paste.press("cmd+v")
|
||||||
|
self.assertEqual(len(self.api.prompted), 1)
|
||||||
|
|
||||||
|
def test_a_trusted_process_is_never_prompted(self):
|
||||||
|
paste.press("cmd+v")
|
||||||
|
self.assertEqual(self.api.prompted, [])
|
||||||
|
|
||||||
def test_readiness_is_the_permission_rather_than_a_program(self):
|
def test_readiness_is_the_permission_rather_than_a_program(self):
|
||||||
self.assertTrue(paste.paste_ready())
|
self.assertTrue(paste.paste_ready())
|
||||||
self.api.trusted = False
|
self.api.trusted = False
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Where the settings and the data are kept, per system.
|
||||||
|
|
||||||
|
Its own file because the answer has to be one answer: config.py and ggml.py
|
||||||
|
both need it, and when each worked it out for itself only one of them knew
|
||||||
|
about macOS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
import ggml
|
||||||
|
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(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(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(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", 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):
|
||||||
|
"""The programs and the models go where everything else goes.
|
||||||
|
|
||||||
|
ggml.py used to read XDG_DATA_HOME itself, which is right on Linux and
|
||||||
|
wrong on a Mac: the settings and the dictations went to ~/Library while
|
||||||
|
several gigabytes of models went to ~/.local/share, where no Mac user looks
|
||||||
|
and where `uninstall.sh --purge` would never have found them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_the_models_live_under_the_data_directory(self):
|
||||||
|
self.assertEqual(ggml.DATA_DIR, paths.DATA_DIR)
|
||||||
|
self.assertEqual(ggml.MODELS_DIR.parent, paths.DATA_DIR)
|
||||||
|
self.assertEqual(ggml.BIN_DIR.parent, paths.DATA_DIR)
|
||||||
|
|
||||||
|
def test_config_and_ggml_cannot_disagree(self):
|
||||||
|
self.assertEqual(cfg.DATA_DIR, ggml.DATA_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+8
-2
@@ -181,13 +181,19 @@ class Settings(DikteTest):
|
|||||||
|
|
||||||
def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
|
def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
|
||||||
"""The application is unusable without the toggle, so that one box
|
"""The application is unusable without the toggle, so that one box
|
||||||
falls back. The rest stay empty, which is how they are switched off."""
|
falls back. The rest stay empty, which is how they are switched off.
|
||||||
|
|
||||||
|
Which combination it falls back to is the platform's and is pinned in
|
||||||
|
test_hotkey; MacSettings runs this too, and there the answer is not
|
||||||
|
Ctrl+Space.
|
||||||
|
"""
|
||||||
conf = cfg.Config()
|
conf = cfg.Config()
|
||||||
window = self.window(conf)
|
window = self.window(conf)
|
||||||
for box, _status, _missing in window._shortcut_rows.values():
|
for box, _status, _missing in window._shortcut_rows.values():
|
||||||
box.setCurrentText("")
|
box.setCurrentText("")
|
||||||
window._save()
|
window._save()
|
||||||
self.assertEqual(conf["shortcut"], "Ctrl+Space")
|
self.assertTrue(conf["shortcut"])
|
||||||
|
self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle"))
|
||||||
self.assertEqual(conf["cancel_shortcut"], "")
|
self.assertEqual(conf["cancel_shortcut"], "")
|
||||||
self.assertEqual(conf["assistant_shortcut"], "")
|
self.assertEqual(conf["assistant_shortcut"], "")
|
||||||
self.assertEqual(conf["meeting_shortcut"], "")
|
self.assertEqual(conf["meeting_shortcut"], "")
|
||||||
|
|||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
"""The three tray icons, drawn here for systems that have no icon theme.
|
||||||
|
|
||||||
|
Linux hands out `audio-input-microphone`, `media-record` and `view-refresh`
|
||||||
|
from whatever icon theme is installed, and Qt finds them through
|
||||||
|
QIcon.fromTheme. macOS has no such registry: fromTheme returns a null icon
|
||||||
|
there, and a null icon in the menu bar is an item you cannot see, which is the
|
||||||
|
whole of Dikte's interface gone. So the same three shapes are drawn here, and
|
||||||
|
used whenever the theme has nothing to offer.
|
||||||
|
|
||||||
|
They are drawn as template images: one colour, transparent everywhere else,
|
||||||
|
with isMask set. That is what lets macOS invert them for a dark menu bar and
|
||||||
|
grey them while the menu is open, and it is why the shapes are outlines rather
|
||||||
|
than the coloured glyphs a Linux theme would give.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QPointF, QRectF, Qt
|
||||||
|
from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
|
||||||
|
QPen, QPixmap)
|
||||||
|
|
||||||
|
# What a Mac menu bar asks for: 22 points, at 1x and at 2x. Both are put in the
|
||||||
|
# icon rather than one being scaled, because a scaled stroke goes soft.
|
||||||
|
SIZES = (22, 44)
|
||||||
|
# Drawn in black; the mask throws the colour away and keeps the coverage, and
|
||||||
|
# on a system that does not do masks black is still the right ink for a light
|
||||||
|
# panel and readable on a dark one.
|
||||||
|
INK = QColor(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _canvas(size):
|
||||||
|
pixmap = QPixmap(size, size)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
return pixmap, painter
|
||||||
|
|
||||||
|
|
||||||
|
def _paint_microphone(painter, size, ink):
|
||||||
|
"""A capsule on a stand: idle, and the application's own mark.
|
||||||
|
|
||||||
|
The colour is a parameter because the same glyph is the tray stencil, where
|
||||||
|
it is black and then masked, and the white one on the application icon.
|
||||||
|
"""
|
||||||
|
unit = size / 22.0
|
||||||
|
pen = QPen(ink, 1.6 * unit)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(ink)
|
||||||
|
# The capsule, held away from the edges so the stroke below has room.
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(8.2 * unit, 3.4 * unit, 5.6 * unit, 10.4 * unit),
|
||||||
|
2.8 * unit, 2.8 * unit,
|
||||||
|
)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
# The arc that cradles it, and the post and foot under that.
|
||||||
|
painter.drawArc(
|
||||||
|
QRectF(5.4 * unit, 6.6 * unit, 11.2 * unit, 10.4 * unit),
|
||||||
|
180 * 16, 180 * 16,
|
||||||
|
)
|
||||||
|
painter.drawLine(QPointF(11 * unit, 16.8 * unit), QPointF(11 * unit, 19 * unit))
|
||||||
|
painter.drawLine(QPointF(7.6 * unit, 19 * unit), QPointF(14.4 * unit, 19 * unit))
|
||||||
|
|
||||||
|
|
||||||
|
def _microphone(painter, size):
|
||||||
|
_paint_microphone(painter, size, INK)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(painter, size):
|
||||||
|
"""A filled dot: recording, and the same red dot the overlay shows."""
|
||||||
|
unit = size / 22.0
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(INK)
|
||||||
|
painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit)
|
||||||
|
|
||||||
|
|
||||||
|
def _working(painter, size):
|
||||||
|
"""An arrow chasing its own circle: transcribing, cleaning up, thinking."""
|
||||||
|
unit = size / 22.0
|
||||||
|
pen = QPen(INK, 2.0 * unit)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
ring = QRectF(4.4 * unit, 4.4 * unit, 13.2 * unit, 13.2 * unit)
|
||||||
|
# Three quarters of the way round, leaving the gap the head sits in.
|
||||||
|
painter.drawArc(ring, 90 * 16, -280 * 16)
|
||||||
|
|
||||||
|
# The head, as a filled triangle at the open end rather than two more
|
||||||
|
# strokes: at 22 points a drawn arrowhead closes up into a blob.
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(INK)
|
||||||
|
head = QPainterPath()
|
||||||
|
head.moveTo(QPointF(11.0 * unit, 1.6 * unit))
|
||||||
|
head.lineTo(QPointF(11.0 * unit, 7.2 * unit))
|
||||||
|
head.lineTo(QPointF(15.8 * unit, 4.4 * unit))
|
||||||
|
head.closeSubpath()
|
||||||
|
painter.drawPath(head)
|
||||||
|
|
||||||
|
|
||||||
|
# The names Linux themes use, which are what dikte.py asks for either way.
|
||||||
|
SHAPES = {
|
||||||
|
"audio-input-microphone": _microphone,
|
||||||
|
"media-record": _record,
|
||||||
|
"view-refresh": _working,
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def icon(name):
|
||||||
|
"""The named icon drawn here, or a null QIcon when it is not one of ours.
|
||||||
|
|
||||||
|
Cached because the tray is refreshed on every state change and every one of
|
||||||
|
those would otherwise redraw three pixmaps. A QIcon is cheap to copy and the
|
||||||
|
pixmaps inside it are shared, so handing the same object out is safe.
|
||||||
|
"""
|
||||||
|
shape = SHAPES.get(name)
|
||||||
|
if shape is None:
|
||||||
|
return QIcon()
|
||||||
|
if name in _cache:
|
||||||
|
return _cache[name]
|
||||||
|
|
||||||
|
result = QIcon()
|
||||||
|
for size in SIZES:
|
||||||
|
pixmap, painter = _canvas(size)
|
||||||
|
try:
|
||||||
|
shape(painter, size)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
result.addPixmap(pixmap)
|
||||||
|
# The line that makes it a template image: macOS then owns the colour, and
|
||||||
|
# the icon follows the menu bar into dark mode instead of staying black.
|
||||||
|
result.setIsMask(True)
|
||||||
|
_cache[name] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- the application icon --------------------------------------------------
|
||||||
|
#
|
||||||
|
# The menu bar wants a flat stencil; the Finder, the Dock and the permission
|
||||||
|
# dialogs want a picture. Same microphone, on a ground of its own, and drawn
|
||||||
|
# here as well so that `install-mac.sh` has an .icns to build without a binary
|
||||||
|
# blob living in the repository.
|
||||||
|
|
||||||
|
# What iconutil expects to find in an .iconset: each of these at 1x and 2x.
|
||||||
|
APP_ICON_SIZES = (16, 32, 128, 256, 512)
|
||||||
|
|
||||||
|
|
||||||
|
def app_pixmap(size):
|
||||||
|
"""The application icon at one size: a white microphone on a blue tile."""
|
||||||
|
pixmap, painter = _canvas(size)
|
||||||
|
try:
|
||||||
|
unit = size / 22.0
|
||||||
|
# macOS rounds and shadows the tile itself for some icon styles but not
|
||||||
|
# for a plain .icns, so the shape is drawn: the squircle radius Apple
|
||||||
|
# uses is close enough to 22% of the side.
|
||||||
|
ground = QLinearGradient(0, 0, 0, size)
|
||||||
|
ground.setColorAt(0.0, QColor(0x3B, 0x82, 0xF6))
|
||||||
|
ground.setColorAt(1.0, QColor(0x1D, 0x4E, 0xD8))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(ground)
|
||||||
|
inset = 1.0 * unit
|
||||||
|
painter.drawRoundedRect(
|
||||||
|
QRectF(inset, inset, size - 2 * inset, size - 2 * inset),
|
||||||
|
4.4 * unit, 4.4 * unit,
|
||||||
|
)
|
||||||
|
# The same glyph as the tray, in white and a little smaller so it sits
|
||||||
|
# inside the tile rather than against its edges.
|
||||||
|
painter.save()
|
||||||
|
painter.translate(size / 2.0, size / 2.0)
|
||||||
|
painter.scale(0.64, 0.64)
|
||||||
|
painter.translate(-size / 2.0, -size / 2.0)
|
||||||
|
_paint_microphone(painter, size, QColor(0xFF, 0xFF, 0xFF))
|
||||||
|
painter.restore()
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
|
||||||
|
def write_iconset(directory):
|
||||||
|
"""Write the PNGs `iconutil -c icns` reads. The directory it wrote to."""
|
||||||
|
directory = pathlib.Path(directory)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
for size in APP_ICON_SIZES:
|
||||||
|
for scale in (1, 2):
|
||||||
|
name = f"icon_{size}x{size}{'@2x' if scale == 2 else ''}.png"
|
||||||
|
app_pixmap(size * scale).save(str(directory / name), "PNG")
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def _main(argv):
|
||||||
|
"""`python3 trayicon.py <path>.iconset`, which install-mac.sh calls.
|
||||||
|
|
||||||
|
A QGuiApplication has to exist before a QPixmap can, and offscreen because
|
||||||
|
this runs from a shell script with no window to open.
|
||||||
|
"""
|
||||||
|
if len(argv) != 2:
|
||||||
|
print("usage: trayicon.py <directory>.iconset", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
from PyQt6.QtGui import QGuiApplication
|
||||||
|
QGuiApplication.setAttribute(
|
||||||
|
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
|
||||||
|
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
|
||||||
|
try:
|
||||||
|
print(write_iconset(argv[1]))
|
||||||
|
finally:
|
||||||
|
del app
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(_main(sys.argv))
|
||||||
+73
-19
@@ -5,13 +5,34 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PY="$(command -v python3 || true)"
|
|
||||||
USER_NAME="$(id -un)"
|
USER_NAME="$(id -un)"
|
||||||
BIN_DIR="$HOME/.local/bin"
|
BIN_DIR="$HOME/.local/bin"
|
||||||
APP_DIR="$HOME/.local/share/applications"
|
|
||||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
# What an installation is made of differs by platform, but what --purge deletes
|
||||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
|
# and what it asks before deleting it does not, which is why this is one script
|
||||||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
|
# with two sets of paths rather than two scripts.
|
||||||
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||||
|
MACOS=1
|
||||||
|
MAC_APP="$HOME/Applications/Dikte.app"
|
||||||
|
AGENT_ID="io.github.yusufipk.dikte"
|
||||||
|
AGENT="$HOME/Library/LaunchAgents/$AGENT_ID.plist"
|
||||||
|
CONFIG_DIR="$HOME/Library/Application Support/Dikte"
|
||||||
|
DATA_DIR="$CONFIG_DIR"
|
||||||
|
# The interpreter this installation was put on, which is not necessarily the
|
||||||
|
# python3 on PATH: Apple's is 3.9 and cannot run Dikte. The wrapper written
|
||||||
|
# by install-mac.sh names it, so it is read back out of there.
|
||||||
|
# `|| true` because there may be no wrapper to read: pipefail would otherwise
|
||||||
|
# make a missing file the end of the script rather than a question answered no.
|
||||||
|
PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$BIN_DIR/dikte" 2>/dev/null | head -1 || true)"
|
||||||
|
[[ -x "$PY" ]] || PY="$(command -v python3 || true)"
|
||||||
|
else
|
||||||
|
MACOS=0
|
||||||
|
APP_DIR="$HOME/.local/share/applications"
|
||||||
|
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||||
|
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
|
||||||
|
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
|
||||||
|
PY="$(command -v python3 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
PURGE=0
|
PURGE=0
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
@@ -63,8 +84,12 @@ echo "──────────────────"
|
|||||||
|
|
||||||
# 1. Global shortcuts ------------------------------------------------------
|
# 1. Global shortcuts ------------------------------------------------------
|
||||||
# Handed to Dikte while it can still run, because it is the half that knows
|
# Handed to Dikte while it can still run, because it is the half that knows
|
||||||
# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings.
|
# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings. macOS
|
||||||
if [[ -n "$PY" ]] && python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
# keeps no registry: the combinations are held by the running process and are
|
||||||
|
# gone the moment it stops, so there is nothing here to take back.
|
||||||
|
if ((MACOS)); then
|
||||||
|
say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
|
||||||
|
elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
||||||
for which in toggle cancel ask meeting; do
|
for which in toggle cancel ask meeting; do
|
||||||
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
|
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
@@ -89,25 +114,49 @@ if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Launchers -------------------------------------------------------------
|
# 3. Launchers -------------------------------------------------------------
|
||||||
# Only our own symlink goes: a file of the same name that somebody else put
|
# Only what this installer wrote goes: a file of the same name that somebody
|
||||||
# there is not ours to delete.
|
# else put there is not ours to delete. On Linux ours is a symlink; on macOS it
|
||||||
|
# is a generated wrapper, which says so in its second line.
|
||||||
if [[ -L "$BIN_DIR/dikte" ]]; then
|
if [[ -L "$BIN_DIR/dikte" ]]; then
|
||||||
remove "$BIN_DIR/dikte"
|
remove "$BIN_DIR/dikte"
|
||||||
|
elif ((MACOS)) && grep -q 'Written by install-mac.sh' "$BIN_DIR/dikte" 2>/dev/null; then
|
||||||
|
remove "$BIN_DIR/dikte"
|
||||||
elif [[ -e "$BIN_DIR/dikte" ]]; then
|
elif [[ -e "$BIN_DIR/dikte" ]]; then
|
||||||
warn "$BIN_DIR/dikte is not our symlink, leaving it alone"
|
warn "$BIN_DIR/dikte is not ours, leaving it alone"
|
||||||
else
|
else
|
||||||
gone "Was not there: $BIN_DIR/dikte"
|
gone "Was not there: $BIN_DIR/dikte"
|
||||||
fi
|
fi
|
||||||
remove "$APP_DIR/dikte.desktop"
|
|
||||||
remove "$AUTOSTART_DIR/dikte.desktop"
|
if ((MACOS)); then
|
||||||
# Removing the shortcut takes its desktop file with it, but an install from
|
# The login item first: bootout while the plist is still there, because
|
||||||
# before this script existed may have left one behind on a desktop that never
|
# launchctl is told which job by the file as much as by the label.
|
||||||
# used them.
|
if [[ -e "$AGENT" ]]; then
|
||||||
for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do
|
launchctl bootout "gui/$(id -u)/$AGENT_ID" >/dev/null 2>&1 || true
|
||||||
if [[ -e "$APP_DIR/$id.desktop" ]]; then
|
|
||||||
remove "$APP_DIR/$id.desktop"
|
|
||||||
fi
|
fi
|
||||||
done
|
remove "$AGENT"
|
||||||
|
if [[ -d "$MAC_APP" ]]; then
|
||||||
|
rm -rf "$MAC_APP"
|
||||||
|
ok "Removed $MAC_APP"
|
||||||
|
else
|
||||||
|
gone "Was not there: $MAC_APP"
|
||||||
|
fi
|
||||||
|
# macOS keeps the permissions filed against the bundle that asked for them,
|
||||||
|
# and deleting the bundle does not withdraw them.
|
||||||
|
say "Microphone and Accessibility are still granted to Dikte; take them back"
|
||||||
|
say "under System Settings → Privacy & Security if you want them gone."
|
||||||
|
fi
|
||||||
|
if ((!MACOS)); then
|
||||||
|
remove "$APP_DIR/dikte.desktop"
|
||||||
|
remove "$AUTOSTART_DIR/dikte.desktop"
|
||||||
|
# Removing the shortcut takes its desktop file with it, but an install from
|
||||||
|
# before this script existed may have left one behind on a desktop that never
|
||||||
|
# used them.
|
||||||
|
for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do
|
||||||
|
if [[ -e "$APP_DIR/$id.desktop" ]]; then
|
||||||
|
remove "$APP_DIR/$id.desktop"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# 4. Settings and dictations -----------------------------------------------
|
# 4. Settings and dictations -----------------------------------------------
|
||||||
echo
|
echo
|
||||||
@@ -142,6 +191,11 @@ fi
|
|||||||
if ((PURGE)); then
|
if ((PURGE)); then
|
||||||
rm -rf "$CONFIG_DIR" "$DATA_DIR"
|
rm -rf "$CONFIG_DIR" "$DATA_DIR"
|
||||||
ok "Settings and dictations deleted"
|
ok "Settings and dictations deleted"
|
||||||
|
elif [[ "$CONFIG_DIR" == "$DATA_DIR" ]]; then
|
||||||
|
# macOS keeps both in the one directory a Mac user's backup already knows
|
||||||
|
# about, so naming it twice would only look like two things were kept.
|
||||||
|
say "Settings and dictations kept: $CONFIG_DIR"
|
||||||
|
say "Delete them too with: ./uninstall.sh --purge"
|
||||||
else
|
else
|
||||||
say "Settings kept: $CONFIG_DIR"
|
say "Settings kept: $CONFIG_DIR"
|
||||||
say "Dictations kept: $DATA_DIR"
|
say "Dictations kept: $DATA_DIR"
|
||||||
|
|||||||
@@ -3,9 +3,22 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PY="$(command -v python3 || true)"
|
|
||||||
USER_NAME="$(id -un)"
|
USER_NAME="$(id -un)"
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||||
|
# Apple's python3 is 3.9 and will stay 3.9, so the one on PATH is the wrong
|
||||||
|
# question here. install-mac.sh names the interpreter it installed against in
|
||||||
|
# the wrapper it wrote, and that is the one this installation runs on.
|
||||||
|
# `|| true` because there may be no wrapper to read: pipefail would otherwise
|
||||||
|
# make a missing file the end of the script rather than a question answered no.
|
||||||
|
PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$HOME/.local/bin/dikte" 2>/dev/null | head -1 || true)"
|
||||||
|
[[ -x "$PY" ]] || PY="$(command -v python3 || true)"
|
||||||
|
DEFAULT_SHORTCUT="Ctrl+Option+Space"
|
||||||
|
else
|
||||||
|
PY="$(command -v python3 || true)"
|
||||||
|
DEFAULT_SHORTCUT="Ctrl+Space"
|
||||||
|
fi
|
||||||
|
|
||||||
say() { printf ' %s\n' "$1"; }
|
say() { printf ' %s\n' "$1"; }
|
||||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||||
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
|
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
|
||||||
@@ -83,7 +96,7 @@ echo
|
|||||||
shortcut="$(setting shortcut)"
|
shortcut="$(setting shortcut)"
|
||||||
cancel_shortcut="$(setting cancel_shortcut)"
|
cancel_shortcut="$(setting cancel_shortcut)"
|
||||||
# Positional, so a chosen discard key cannot be passed without the other one.
|
# Positional, so a chosen discard key cannot be passed without the other one.
|
||||||
"$DIR/install.sh" "${shortcut:-Ctrl+Space}" "${cancel_shortcut:-}"
|
"$DIR/install.sh" "${shortcut:-$DEFAULT_SHORTCUT}" "${cancel_shortcut:-}"
|
||||||
|
|
||||||
# 5. The running instance ---------------------------------------------------
|
# 5. The running instance ---------------------------------------------------
|
||||||
# It is still running the code from before the pull.
|
# It is still running the code from before the pull.
|
||||||
|
|||||||
Reference in New Issue
Block a user