mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -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")
|
||||
]
|
||||
Reference in New Issue
Block a user