mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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
232 lines
8.0 KiB
Python
232 lines
8.0 KiB
Python
"""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
|
|
)
|