mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +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:
+231
@@ -0,0 +1,231 @@
|
||||
"""The small recording indicator that appears in a screen corner without taking focus."""
|
||||
|
||||
import math
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
||||
from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QFontMetrics
|
||||
from PyQt6.QtWidgets import QWidget, QApplication
|
||||
|
||||
BARS = 22
|
||||
HEIGHT = 56
|
||||
MIN_WIDTH = 210
|
||||
MAX_WIDTH = 460
|
||||
MARGIN = 28
|
||||
|
||||
BG = QColor(22, 24, 29, 238)
|
||||
BORDER = QColor(255, 255, 255, 28)
|
||||
TEXT = QColor(235, 237, 242)
|
||||
MUTED = QColor(150, 156, 168)
|
||||
REC = QColor(240, 78, 82)
|
||||
BUSY = QColor(120, 170, 255)
|
||||
OK = QColor(80, 205, 140)
|
||||
ERR = QColor(240, 100, 90)
|
||||
|
||||
STATE_COLORS = {"recording": REC, "busy": BUSY, "done": OK, "error": ERR}
|
||||
|
||||
|
||||
class Overlay(QWidget):
|
||||
def __init__(self, corner="bottom-left"):
|
||||
super().__init__(None)
|
||||
self.corner = corner
|
||||
self.state = "idle"
|
||||
self.message = ""
|
||||
self.levels = [0.0] * BARS
|
||||
self.seconds = 0.0
|
||||
self._phase = 0.0
|
||||
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowStaysOnTopHint
|
||||
| Qt.WindowType.Tool
|
||||
| Qt.WindowType.WindowDoesNotAcceptFocus
|
||||
| Qt.WindowType.X11BypassWindowManagerHint
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.resize(MIN_WIDTH, HEIGHT)
|
||||
|
||||
self._anim = QTimer(self)
|
||||
self._anim.setInterval(33)
|
||||
self._anim.timeout.connect(self._tick)
|
||||
|
||||
self._hide_timer = QTimer(self)
|
||||
self._hide_timer.setSingleShot(True)
|
||||
self._hide_timer.timeout.connect(self.hide)
|
||||
|
||||
# ---- public API --------------------------------------------------
|
||||
|
||||
def show_recording(self):
|
||||
self.state = "recording"
|
||||
self.message = ""
|
||||
self.seconds = 0.0
|
||||
self.levels = [0.0] * BARS
|
||||
self._hide_timer.stop()
|
||||
self._appear()
|
||||
|
||||
def show_busy(self, message):
|
||||
self.state = "busy"
|
||||
self.message = message
|
||||
self._hide_timer.stop()
|
||||
self._appear()
|
||||
|
||||
def show_done(self, message="", msec=1100):
|
||||
self.state = "done"
|
||||
self.message = message
|
||||
self._appear()
|
||||
self._hide_timer.start(msec)
|
||||
|
||||
def show_error(self, message, msec=6000):
|
||||
self.state = "error"
|
||||
self.message = message
|
||||
self._appear()
|
||||
self._hide_timer.start(msec)
|
||||
|
||||
def dismiss(self):
|
||||
self._anim.stop()
|
||||
self._hide_timer.stop()
|
||||
self.hide()
|
||||
|
||||
def push_level(self, level):
|
||||
self.levels = self.levels[1:] + [level]
|
||||
|
||||
def set_seconds(self, seconds):
|
||||
self.seconds = seconds
|
||||
|
||||
# ---- internals -----------------------------------------------------
|
||||
|
||||
def _appear(self):
|
||||
self._resize_to_content()
|
||||
self._reposition()
|
||||
if not self.isVisible():
|
||||
self.show()
|
||||
self.raise_()
|
||||
if not self._anim.isActive():
|
||||
self._anim.start()
|
||||
|
||||
def _resize_to_content(self):
|
||||
if self.state == "recording":
|
||||
width = MIN_WIDTH
|
||||
else:
|
||||
metrics = QFontMetrics(self._label_font())
|
||||
width = max(MIN_WIDTH, min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + 76))
|
||||
self.resize(width, HEIGHT)
|
||||
|
||||
def _reposition(self):
|
||||
# On a multi-monitor setup, show up where the user actually is.
|
||||
screen = QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen()
|
||||
area = screen.availableGeometry()
|
||||
left = "left" in self.corner
|
||||
top = "top" in self.corner
|
||||
x = area.left() + MARGIN if left else area.right() - self.width() - MARGIN
|
||||
y = area.top() + MARGIN if top else area.bottom() - self.height() - MARGIN
|
||||
self.move(int(x), int(y))
|
||||
|
||||
def _tick(self):
|
||||
self._phase += 0.12
|
||||
if self.state == "recording":
|
||||
# keep the ribbon moving even through a pause in speech
|
||||
self.levels = self.levels[1:] + [self.levels[-1] * 0.72]
|
||||
self.update()
|
||||
|
||||
def _label_font(self):
|
||||
font = QFont(self.font())
|
||||
font.setPointSizeF(10.5)
|
||||
return font
|
||||
|
||||
# ---- painting --------------------------------------------------
|
||||
|
||||
def paintEvent(self, _event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(0.5, 0.5, self.width() - 1, self.height() - 1)
|
||||
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 15, 15)
|
||||
painter.fillPath(path, BG)
|
||||
painter.setPen(QPen(BORDER, 1))
|
||||
painter.drawPath(path)
|
||||
|
||||
accent = STATE_COLORS.get(self.state, MUTED)
|
||||
self._draw_indicator(painter, accent)
|
||||
|
||||
if self.state == "recording":
|
||||
self._draw_waveform(painter)
|
||||
self._draw_time(painter)
|
||||
else:
|
||||
self._draw_message(painter)
|
||||
|
||||
def _draw_indicator(self, painter, accent):
|
||||
cx, cy = 26.0, self.height() / 2
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
if self.state == "recording":
|
||||
pulse = 0.62 + 0.38 * (0.5 + 0.5 * math.sin(self._phase * 1.6))
|
||||
glow = QColor(accent)
|
||||
glow.setAlphaF(0.22 * pulse)
|
||||
painter.setBrush(glow)
|
||||
painter.drawEllipse(QPointF(cx, cy), 13 * pulse, 13 * pulse)
|
||||
painter.setBrush(accent)
|
||||
painter.drawEllipse(QPointF(cx, cy), 5.5, 5.5)
|
||||
elif self.state == "busy":
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
pen = QPen(QColor(accent), 2.4)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
span = 100 * 16
|
||||
start = int(-self._phase * 320) % (360 * 16)
|
||||
painter.drawArc(QRectF(cx - 8, cy - 8, 16, 16), start, span)
|
||||
elif self.state == "done":
|
||||
pen = QPen(accent, 2.4)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.drawPolyline(
|
||||
QPointF(cx - 7, cy), QPointF(cx - 2, cy + 5.5), QPointF(cx + 7.5, cy - 6)
|
||||
)
|
||||
else: # error
|
||||
pen = QPen(accent, 2.4)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(QPointF(cx - 6, cy - 6), QPointF(cx + 6, cy + 6))
|
||||
painter.drawLine(QPointF(cx + 6, cy - 6), QPointF(cx - 6, cy + 6))
|
||||
|
||||
def _draw_waveform(self, painter):
|
||||
left, right = 46.0, self.width() - 58.0
|
||||
span = right - left
|
||||
bar_w = 2.6
|
||||
gap = (span - BARS * bar_w) / max(1, BARS - 1)
|
||||
mid = self.height() / 2
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
for i, level in enumerate(self.levels):
|
||||
shaped = min(1.0, level ** 0.55)
|
||||
h = 3.0 + shaped * 26.0
|
||||
x = left + i * (bar_w + gap)
|
||||
color = QColor(REC if shaped > 0.04 else MUTED)
|
||||
color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2))
|
||||
painter.setBrush(color)
|
||||
painter.drawRoundedRect(QRectF(x, mid - h / 2, bar_w, h), 1.3, 1.3)
|
||||
|
||||
def _draw_time(self, painter):
|
||||
font = QFont(self.font())
|
||||
font.setPointSizeF(10.0)
|
||||
font.setFamilies(["monospace"])
|
||||
painter.setFont(font)
|
||||
painter.setPen(MUTED)
|
||||
mins, secs = divmod(int(self.seconds), 60)
|
||||
painter.drawText(
|
||||
QRectF(self.width() - 56, 0, 44, self.height()),
|
||||
int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight),
|
||||
f"{mins}:{secs:02d}",
|
||||
)
|
||||
|
||||
def _draw_message(self, painter):
|
||||
painter.setFont(self._label_font())
|
||||
painter.setPen(TEXT if self.state != "error" else ERR)
|
||||
box = QRectF(46, 0, self.width() - 60, self.height())
|
||||
metrics = QFontMetrics(self._label_font())
|
||||
text = metrics.elidedText(self.message, Qt.TextElideMode.ElideRight, int(box.width()))
|
||||
painter.drawText(
|
||||
box, int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft), text
|
||||
)
|
||||
Reference in New Issue
Block a user