Files
dikte/overlay.py
T
yusufipk 352f7b6c22 Record a meeting from both sides, and write its minutes
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.
2026-07-28 16:32:31 +07:00

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
)