mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Who said what is the hard part of a meeting transcript, and the usual answer is to hand one mixed recording to a model and ask it to tell the voices apart. That guess is wrong often enough to be worse than useless in minutes, where a decision attributed to the wrong person is a decision nobody made. So the question never reaches a model. ffmpeg records the microphone and the default sink's monitor as one stereo stream, you on the left and everyone else on the right, and one process reading both is what keeps them aligned over an hour. Each channel is transcribed on its own and the two are interleaved on a single timeline, so attribution is settled by the wire a voice arrived on. What a microphone picks up from the speakers lands on both channels; our copy is dropped when it overlaps theirs in time and says nearly the same thing. The stream is written to disk as it arrives rather than held in memory, so length costs nothing and a crash costs the tail instead of the whole meeting. Every stage the run reaches is recorded in meetings.jsonl, so a failure while summarising does not throw away the transcription of an hour of audio: the retry reads the transcript back out of the document and picks up from there. A run that dies keeps its recording whether or not audio is being kept. The minutes model is configured on its own, under Settings, with its own prompt, and it is told who was expected in the room so the names come out spelled right. It is told outright that the transcript is a record of other people talking, not instructions addressed to it. The built-in listener now holds several bindings rather than one, and the KDE side is parameterised by desktop id, so the meeting toggle gets a shortcut of its own on the same footing as the dictation one.
326 lines
12 KiB
Python
326 lines
12 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)
|
|
WARN = QColor(240, 180, 80)
|
|
THEM = QColor(110, 190, 255) # the other side of a meeting
|
|
|
|
STATE_COLORS = {"recording": REC, "meeting": REC, "busy": BUSY, "done": OK,
|
|
"warning": WARN, "error": ERR}
|
|
LIVE = ("recording", "meeting")
|
|
|
|
|
|
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.levels2 = [0.0] * BARS # the other side, while a meeting records
|
|
self.seconds = 0.0
|
|
self._phase = 0.0
|
|
self._concealed = True
|
|
|
|
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._conceal)
|
|
|
|
# ---- 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_meeting(self):
|
|
"""Both channels at once: your voice up, the other side down."""
|
|
self.state = "meeting"
|
|
self.message = ""
|
|
self.seconds = 0.0
|
|
self.levels = [0.0] * BARS
|
|
self.levels2 = [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=2000):
|
|
self.state = "done"
|
|
self.message = message
|
|
self._appear()
|
|
self._hide_timer.start(msec)
|
|
|
|
def show_warning(self, message, msec=9000):
|
|
"""Finished, but something the user should know about went wrong."""
|
|
self.state = "warning"
|
|
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._hide_timer.stop()
|
|
self._conceal()
|
|
|
|
def push_level(self, level):
|
|
self.levels = self.levels[1:] + [level]
|
|
|
|
def push_levels(self, mine, theirs):
|
|
self.levels = self.levels[1:] + [mine]
|
|
self.levels2 = self.levels2[1:] + [theirs]
|
|
|
|
def set_seconds(self, seconds):
|
|
self.seconds = seconds
|
|
|
|
# ---- internals -----------------------------------------------------
|
|
|
|
def _appear(self):
|
|
self._resize_to_content()
|
|
self._reposition()
|
|
if not self.isVisible():
|
|
self.show()
|
|
if self._concealed:
|
|
self.raise_()
|
|
self._concealed = False
|
|
if not self._anim.isActive():
|
|
self._anim.start()
|
|
|
|
def _conceal(self):
|
|
"""Empty the window out instead of unmapping it.
|
|
|
|
Unmapping tears the window down and the next dictation builds a new one,
|
|
which makes the compositor repaint whatever sits underneath: on a tiled
|
|
desktop the terminal behind visibly flinches every time the indicator
|
|
goes away. So the window stays mapped and simply paints nothing. It has
|
|
to be a real repaint, not just zero opacity: with the animation stopped
|
|
nothing else damages the surface, and the stale frame would sit on the
|
|
screen until some other event made the compositor redraw it.
|
|
"""
|
|
self._anim.stop()
|
|
self.state = "hidden"
|
|
self._concealed = True
|
|
self.repaint()
|
|
|
|
def _resize_to_content(self):
|
|
if self.state in LIVE:
|
|
# A meeting runs long enough to need an hours field.
|
|
width = MIN_WIDTH + (24 if self.state == "meeting" else 0)
|
|
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 in LIVE:
|
|
# keep the ribbon moving even through a pause in speech
|
|
self.levels = self.levels[1:] + [self.levels[-1] * 0.72]
|
|
if self.state == "meeting":
|
|
self.levels2 = self.levels2[1:] + [self.levels2[-1] * 0.72]
|
|
self.update()
|
|
|
|
def _label_font(self):
|
|
font = QFont(self.font())
|
|
font.setPointSizeF(10.5)
|
|
return font
|
|
|
|
# ---- painting --------------------------------------------------
|
|
|
|
def paintEvent(self, _event):
|
|
if self.state == "hidden":
|
|
return # translucent window, nothing drawn means nothing shown
|
|
|
|
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 in LIVE:
|
|
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 in LIVE:
|
|
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)
|
|
)
|
|
elif self.state == "warning":
|
|
pen = QPen(accent, 2.6)
|
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
painter.setPen(pen)
|
|
painter.drawLine(QPointF(cx, cy - 7), QPointF(cx, cy + 1.5))
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(accent)
|
|
painter.drawEllipse(QPointF(cx, cy + 6), 1.5, 1.5)
|
|
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 _bars(self):
|
|
"""(x of the first bar, bar width, distance between two bars)."""
|
|
# A meeting's clock carries an hours field, so it needs more room and
|
|
# the ribbon has to stop earlier.
|
|
left = 46.0
|
|
right = self.width() - (74.0 if self.state == "meeting" else 58.0)
|
|
bar_w = 2.6
|
|
gap = (right - left - BARS * bar_w) / max(1, BARS - 1)
|
|
return left, bar_w, bar_w + gap
|
|
|
|
@staticmethod
|
|
def _bar_colour(shaped, accent):
|
|
color = QColor(accent if shaped > 0.04 else MUTED)
|
|
color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2))
|
|
return color
|
|
|
|
def _draw_waveform(self, painter):
|
|
if self.state == "meeting":
|
|
self._draw_dual_waveform(painter)
|
|
return
|
|
left, bar_w, step = self._bars()
|
|
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
|
|
painter.setBrush(self._bar_colour(shaped, REC))
|
|
painter.drawRoundedRect(
|
|
QRectF(left + i * step, mid - h / 2, bar_w, h), 1.3, 1.3
|
|
)
|
|
|
|
def _draw_dual_waveform(self, painter):
|
|
"""Your microphone above the line, what the speakers play below it.
|
|
|
|
Seeing both move is the whole check that a meeting is being captured
|
|
properly: one silent half means that side is not reaching the recording.
|
|
"""
|
|
left, bar_w, step = self._bars()
|
|
mid = self.height() / 2
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
for i, (mine, theirs) in enumerate(zip(self.levels, self.levels2)):
|
|
x = left + i * step
|
|
for level, accent, up in ((mine, REC, True), (theirs, THEM, False)):
|
|
shaped = min(1.0, level ** 0.55)
|
|
h = 2.0 + shaped * 12.0
|
|
y = mid - 1.5 - h if up else mid + 1.5
|
|
painter.setBrush(self._bar_colour(shaped, accent))
|
|
painter.drawRoundedRect(QRectF(x, y, 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)
|
|
hours, mins = divmod(mins, 60)
|
|
text = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
|
width = 62 if hours else 44
|
|
painter.drawText(
|
|
QRectF(self.width() - width - 12, 0, width, self.height()),
|
|
int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight),
|
|
text,
|
|
)
|
|
|
|
def _draw_message(self, painter):
|
|
painter.setFont(self._label_font())
|
|
painter.setPen({"error": ERR, "warning": WARN}.get(self.state, TEXT))
|
|
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
|
|
)
|