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
This commit is contained in:
yusufipk
2026-07-25 19:24:46 +07:00
commit efa8687b23
22 changed files with 3822 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
"""Raw PCM capture through PipeWire (pw-record) with a live level meter."""
import array
import json
import math
import shutil
import signal
import subprocess
import tempfile
import threading
import wave
from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t
RATE = 16000
CHANNELS = 1
SAMPLE_WIDTH = 2 # s16
CHUNK_FRAMES = 1024
CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS
MIN_FRAMES = int(RATE * 0.25)
class Recorder(QObject):
"""Runs pw-record as a child process and reads raw PCM from its stdout."""
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
failed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._proc = None
self._thread = None
self._buffer = bytearray()
self._rms = []
self._cancelled = False
self._lock = threading.Lock()
@property
def active(self):
return self._thread is not None and self._thread.is_alive()
def start(self, target="", max_seconds=300):
if self.active:
return
if not shutil.which("pw-record"):
self.failed.emit(t("pw-record not found. Is pipewire-audio installed?"))
return
cmd = [
"pw-record",
"--raw",
f"--rate={RATE}",
f"--channels={CHANNELS}",
"--format=s16",
]
if target:
cmd.append(f"--target={target}")
cmd.append("-")
try:
self._proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
)
except OSError as exc:
self.failed.emit(t("Could not start recording: {error}", error=exc))
return
self._buffer = bytearray()
self._rms = []
self._cancelled = False
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
self._thread = threading.Thread(target=self._pump, daemon=True)
self._thread.start()
def _pump(self):
stdout = self._proc.stdout
try:
while True:
chunk = stdout.read(CHUNK_BYTES)
if not chunk:
break
peak, rms = chunk_levels(chunk)
with self._lock:
self._buffer.extend(chunk)
self._rms.append(rms)
too_long = len(self._buffer) >= self._max_bytes
self.level.emit(peak)
if too_long:
self._terminate()
break
except (OSError, ValueError):
pass
def _terminate(self):
proc = self._proc
if proc and proc.poll() is None:
try:
proc.send_signal(signal.SIGINT)
proc.wait(timeout=1.5)
except (subprocess.TimeoutExpired, OSError):
try:
proc.kill()
except OSError:
pass
def cancel(self):
self._cancelled = True
self._terminate()
if self._thread:
self._thread.join(timeout=2)
self._thread = None
self._proc = None
with self._lock:
self._buffer = bytearray()
def stop(self):
"""End the recording and write the WAV file."""
if not self._proc:
return
self._terminate()
if self._thread:
self._thread.join(timeout=2)
self._thread = None
self._proc = None
with self._lock:
pcm = bytes(self._buffer)
rms = list(self._rms)
self._buffer = bytearray()
if self._cancelled:
return
frames = len(pcm) // (SAMPLE_WIDTH * CHANNELS)
if frames < MIN_FRAMES: # a stray keypress, not speech
self.failed.emit(t("Recording too short — speak for at least 0.3 s"))
return
path = write_wav(pcm)
self.stopped.emit(path, frames / RATE, rms)
def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH):
fd, path = tempfile.mkstemp(prefix="dikte-", suffix=".wav")
with open(fd, "wb") as raw, wave.open(raw, "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(width)
wav.setframerate(rate)
wav.writeframes(pcm)
return path
def chunk_levels(chunk):
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
samples = array.array("h")
usable = len(chunk) - (len(chunk) % 2)
if usable <= 0:
return 0.0, 0.0
samples.frombytes(chunk[:usable])
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32768.0
return min(1.0, peak), min(1.0, rms)
def list_sources():
"""[(name, description)] for every real input source."""
if not shutil.which("pactl"):
return []
try:
out = subprocess.run(
["pactl", "-f", "json", "list", "sources"],
capture_output=True, text=True, timeout=5, check=True,
).stdout
data = json.loads(out)
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
return []
return [
(src.get("name", ""), src.get("description") or src.get("name", ""))
for src in data
if not src.get("name", "").endswith(".monitor")
]