Files
dikte/dikte.py
T
yusufipk efa8687b23 Voice dictation for KDE Wayland: record, transcribe, clean up, paste
Ctrl+Space starts and stops a recording. The audio goes to OpenAI for
transcription, a model on OpenRouter strips the fillers and restores
punctuation, and the result is copied and pasted into the focused window.

Only the Python standard library and PyQt6 — HTTP, multipart uploads and
WAV writing are all hand-rolled.

- pw-record captures raw 16 kHz mono PCM with a live level meter
- the corner indicator is drawn through XWayland, since a Wayland client
  cannot position its own window
- silence is caught before it costs an API call, relative to each
  recording's own noise floor, plus a filter for the stock phrases models
  invent when handed silence
- audio and video files can be transcribed too, optionally with [mm:ss]
  timestamps, chunked through ffmpeg for long inputs
- global shortcut installs as a KDE custom shortcut, with an evdev
  listener as a fallback until the session is restarted
- Turkish and English interface, following the system locale by default
2026-07-25 19:24:46 +07:00

303 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Dikte — press Ctrl+Space, talk, press again: transcribe, clean up, paste.
Usage:
dikte.py run in the background (tray icon)
dikte.py toggle start / stop recording
dikte.py cancel discard the current recording
dikte.py settings open the settings window
dikte.py quit shut the application down
"""
import os
import sys
# A Wayland client cannot place a window in a screen corner, so the indicator
# is drawn through XWayland.
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and os.environ.get("DISPLAY"):
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
from PyQt6.QtCore import QTimer, QElapsedTimer # noqa: E402
from PyQt6.QtGui import QAction, QIcon # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon # noqa: E402
import audio # noqa: E402
import config as cfg # noqa: E402
import hotkey # noqa: E402
from i18n import t # noqa: E402
from overlay import Overlay # noqa: E402
from settings_ui import SettingsWindow # noqa: E402
from worker import Pipeline # noqa: E402
SERVER_NAME = "dikte-" + str(os.getuid())
IDLE, RECORDING, BUSY = "idle", "recording", "busy"
class Dikte:
def __init__(self, app):
self.app = app
self.conf = cfg.Config()
self.state = IDLE
self.settings_window = None
self.overlay = Overlay(self.conf["overlay_corner"])
self.recorder = audio.Recorder()
self.pipeline = Pipeline(self.conf)
self.evdev = hotkey.EvdevHotkey()
self.recorder.level.connect(self.overlay.push_level)
self.recorder.stopped.connect(self._on_recorded)
self.recorder.failed.connect(self._on_error)
self.pipeline.stage.connect(self.overlay.show_busy)
self.pipeline.finished.connect(self._on_finished)
self.pipeline.failed.connect(self._on_error)
self.evdev.triggered.connect(self.toggle)
self.evdev.failed.connect(self._on_error)
self.elapsed = QElapsedTimer()
self.last_toggle = QElapsedTimer()
self.ticker = QTimer()
self.ticker.setInterval(100)
self.ticker.timeout.connect(self._tick)
self.tray = QSystemTrayIcon()
self._apply_settings()
self.tray.show()
# ---- tray ----------------------------------------------------------
def _build_tray(self):
# Keep menu and actions on self: PyQt does not take ownership when they
# are only passed to addAction(), and garbage collection eats them.
self.menu = QMenu()
self.toggle_action = QAction(t("Start recording"), self.menu)
self.toggle_action.triggered.connect(self.toggle)
self.menu.addAction(self.toggle_action)
self.cancel_action = QAction(t("Cancel recording"), self.menu)
self.cancel_action.triggered.connect(self.cancel)
self.cancel_action.setEnabled(False)
self.menu.addAction(self.cancel_action)
self.menu.addSeparator()
self.settings_action = QAction(t("Settings…"), self.menu)
self.settings_action.triggered.connect(self.open_settings)
self.menu.addAction(self.settings_action)
self.menu.addSeparator()
self.quit_action = QAction(t("Quit"), self.menu)
self.quit_action.triggered.connect(self.app.quit)
self.menu.addAction(self.quit_action)
self.tray.setContextMenu(self.menu)
self.tray.setToolTip(t("Dikte — ready"))
self.tray.activated.connect(self._tray_clicked)
self._set_icon("audio-input-microphone")
def _tray_clicked(self, reason):
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.toggle()
def _set_icon(self, name):
icon = QIcon.fromTheme(name)
if icon.isNull():
icon = QIcon.fromTheme("audio-input-microphone")
self.tray.setIcon(icon)
# ---- state ----------------------------------------------------------
def _set_state(self, state):
self.state = state
labels = {
IDLE: ("Start recording", "audio-input-microphone", "Dikte — ready"),
RECORDING: ("Stop and transcribe", "media-record", "Dikte — recording"),
BUSY: ("Working…", "view-refresh", "Dikte — working"),
}
label, icon, tip = labels[state]
self.toggle_action.setText(t(label))
self.toggle_action.setEnabled(state != BUSY)
self.cancel_action.setEnabled(state == RECORDING)
self._set_icon(icon)
self.tray.setToolTip(t(tip))
# ---- actions ---------------------------------------------------------
def toggle(self):
# With both the KDE shortcut and the built-in listener active the key
# arrives twice; swallow the immediate repeat.
if self.last_toggle.isValid() and self.last_toggle.elapsed() < 400:
return
self.last_toggle.restart()
if self.state == IDLE:
self.start()
elif self.state == RECORDING:
self.stop()
# requests during BUSY are ignored
def start(self):
if self.state != IDLE:
return
self.overlay.show_recording()
self.elapsed.restart()
self.ticker.start()
self._set_state(RECORDING)
self.recorder.start(self.conf["mic_target"], self.conf["max_seconds"])
def stop(self):
if self.state != RECORDING:
return
self.ticker.stop()
self._set_state(BUSY)
self.overlay.show_busy(t("Transcribing…"))
self.recorder.stop()
def cancel(self):
if self.state != RECORDING:
return
self.ticker.stop()
self.recorder.cancel()
self.overlay.dismiss()
self._set_state(IDLE)
def _tick(self):
seconds = self.elapsed.elapsed() / 1000.0
self.overlay.set_seconds(seconds)
if seconds >= self.conf["max_seconds"]:
self.stop()
def _on_recorded(self, wav_path, duration, peak):
self.pipeline.run(wav_path, duration, peak)
def _on_finished(self, _raw, text):
preview = text.replace("\n", " ")
preview = preview[:48] + ("…" if len(preview) > 48 else "")
action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
self.overlay.show_done(t("{action}: {preview}", action=action, preview=preview))
self._set_state(IDLE)
def _on_error(self, message):
first_line = message.strip().splitlines()[0]
self.overlay.show_error(first_line)
if len(message) > len(first_line):
self.tray.showMessage("Dikte", message, QSystemTrayIcon.MessageIcon.Warning, 8000)
if self.state == RECORDING:
self.ticker.stop()
self._set_state(IDLE)
# ---- settings ---------------------------------------------------------
def open_settings(self):
if self.settings_window is None:
self.settings_window = SettingsWindow(self.conf, launch_command())
self.settings_window.applied.connect(self._apply_settings)
self.settings_window.finished.connect(self._settings_closed)
self.settings_window.show()
self.settings_window.raise_()
self.settings_window.activateWindow()
def _settings_closed(self, *_):
# Don't drop the object while its own signal is still being delivered.
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
def _apply_settings(self):
self.overlay.corner = self.conf["overlay_corner"]
self._build_tray()
self._set_state(self.state)
if self.conf["evdev_hotkey"]:
self.evdev.start(self.conf["shortcut"])
else:
self.evdev.stop()
def shutdown(self):
self.evdev.stop()
if self.state == RECORDING:
self.recorder.cancel()
self.overlay.dismiss()
self.tray.hide()
def launch_command():
"""The command the KDE shortcut will run."""
return f"{sys.executable} {os.path.realpath(__file__)} toggle"
def send_command(command, timeout=800):
"""Hand a command to the running instance; False when there is none."""
socket = QLocalSocket()
socket.connectToServer(SERVER_NAME)
if not socket.waitForConnected(timeout):
return False
socket.write(command.encode("utf-8"))
socket.flush()
socket.waitForBytesWritten(timeout)
socket.disconnectFromServer()
return True
def main():
args = [a for a in sys.argv[1:] if not a.startswith("-")]
command = args[0] if args else ""
if command and command not in ("toggle", "cancel", "settings", "quit", "start", "stop"):
print(__doc__)
return 2
app = QApplication(sys.argv)
app.setApplicationName("Dikte")
app.setDesktopFileName("dikte")
app.setQuitOnLastWindowClosed(False)
# No command and an instance already running: bring its settings forward.
if send_command(command or "settings"):
return 0
if command in ("cancel", "quit", "stop"):
return 0
if not QSystemTrayIcon.isSystemTrayAvailable():
print("dikte: no system tray found, running anyway")
dikte = Dikte(app)
server = QLocalServer()
QLocalServer.removeServer(SERVER_NAME)
if not server.listen(SERVER_NAME):
print(f"dikte: could not open the IPC socket: {server.errorString()}")
def on_connection():
conn = server.nextPendingConnection()
if conn is None:
return
def read():
payload = bytes(conn.readAll()).decode("utf-8", "replace").strip()
handler = {
"toggle": dikte.toggle,
"start": dikte.start,
"stop": dikte.stop,
"cancel": dikte.cancel,
"settings": dikte.open_settings,
"quit": app.quit,
}.get(payload)
if handler:
handler()
conn.disconnectFromServer()
conn.readyRead.connect(read)
server.newConnection.connect(on_connection)
app.aboutToQuit.connect(dikte.shutdown)
if command == "settings" or not dikte.conf.openai_key():
dikte.open_settings()
elif command == "toggle":
QTimer.singleShot(0, dikte.toggle)
return app.exec()
if __name__ == "__main__":
sys.exit(main())