Skip to content

Commit 9f75281

Browse files
committed
fix(meetings): dock popup on wayland, fix audio playback, add retranscribe
- popup: rewrite Hyprland rules with valid windowrulev2 syntax (`title:^(Recording)$` matcher, the previous `match:title Recording` was silently rejected by hyprctl, so the popup spawned in the middle of the screen on Wayland builds). Augment resize_popup() to dispatch movewindowpixel/resizewindowpixel since Qt's set_position() is a no-op on Wayland toplevels. Default AppRun to QT_QPA_PLATFORM=wayland;xcb so Hyprland gets the native plugin where the rules actually apply. - audio: register the voiceflow:// custom URL scheme before QApplication and install VoiceFlowAudioSchemeHandler on the default profile so the MeetingDetailPage <audio> element can stream WAVs with byte-range support for seek/scrub. Pure logic was already in audio_scheme.py — only the Qt glue was missing, which is why the play button silently no-op'd. - retranscribe: add transcript_model column (idempotent migration), parameterize MeetingsController.transcribe with model/device/language overrides via a per-recording dict, expose recordings_retranscribe and recordings_list_cached_models RPCs, and add the RetranscribeDialog modal on MeetingDetailPage with a chip showing which model produced the current transcript. - ci: wipe stale .venv/build/dist before each setup so reruns can't inherit a half-built tree; bump minimum Python to 3.10 (uv.lock regen).
1 parent b9e4fac commit 9f75281

13 files changed

Lines changed: 749 additions & 1390 deletions

File tree

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ jobs:
5858

5959
- name: Setup Python environment
6060
run: |
61+
rm -rf .venv build dist
6162
uv python install 3.12
6263
uv venv --python 3.12 .venv
6364
uv sync
@@ -162,7 +163,9 @@ jobs:
162163
cache-dependency-glob: 'uv.lock'
163164

164165
- name: Setup Python environment
166+
shell: bash
165167
run: |
168+
rm -rf .venv build dist
166169
uv python install 3.12
167170
uv venv --python 3.12 .venv
168171
uv sync

installer/AppRun

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
#!/bin/bash
22
# AppImage entry point for VoiceFlow
33
HERE="$(dirname "$(readlink -f "$0")")"
4+
5+
# On Wayland sessions, prefer native Wayland with XCB fallback.
6+
# Honor any value the user has already set. Native Wayland is needed for the
7+
# Hyprland windowrulev2 dock-positioning of the floating recording popup; the
8+
# XCB fallback covers compositors where the Wayland plugin fails to init.
9+
if [ -z "$QT_QPA_PLATFORM" ] && [ "$XDG_SESSION_TYPE" = "wayland" ]; then
10+
export QT_QPA_PLATFORM="wayland;xcb"
11+
fi
12+
413
exec "$HERE/VoiceFlow" "$@"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ classifiers = [
1515
"Topic :: Office/Business",
1616
]
1717

18-
requires-python = ">=3.9"
18+
requires-python = ">=3.10"
1919

2020
dependencies = [
2121
"pyloid",

src-pyloid/main.py

Lines changed: 123 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,47 @@ def _preload_nvidia_libs():
2929
pass # Best-effort, don't crash on failure
3030

3131
def _setup_hyprland_window_rules():
32-
"""Set Hyprland window rules for the popup overlay if running under Hyprland."""
32+
"""Set Hyprland window rules for the popup overlay if running under Hyprland.
33+
34+
On Wayland, Qt clients cannot position their own toplevels — `set_position()`
35+
is a silent no-op. We therefore rely on the compositor to place and pin the
36+
popup. These rules use `windowrulev2` with the correct matcher syntax
37+
`title:^(Recording)$`. The previous `windowrule "...,match:title Recording"`
38+
form was silently rejected by hyprctl (no `match:` keyword exists), which
39+
is why the popup spawned in the middle of the screen on production builds
40+
even though the Python coordinate math was correct.
41+
42+
TODO(wayland-other-compositors): KDE and GNOME need wlr-layer-shell or
43+
equivalent to dock a window — there's no portable Wayland positioning API.
44+
Only Hyprland is handled here for now (the rest of the userbase is X11/win/mac).
45+
"""
3346
if not os.environ.get('HYPRLAND_INSTANCE_SIGNATURE'):
3447
return
3548
import subprocess
49+
# `move 50%-w/2 100%-h-100` puts the popup horizontally centered and
50+
# 100 px above the bottom of the active monitor (matches the original
51+
# Python intent at main.py: popup_y = _screen_y + _screen_height - 100).
3652
rules = [
37-
"float on, match:title Recording",
38-
"pin on, match:title Recording",
39-
"no_initial_focus on, match:title Recording",
40-
"border_size 0, match:title Recording",
41-
"tag -default-opacity, match:title Recording",
42-
"opacity 1 1, match:title Recording",
43-
"move (monitor_w-window_w)/2 (monitor_h-100), match:title Recording",
53+
"float,title:^(Recording)$",
54+
"pin,title:^(Recording)$",
55+
"noinitialfocus,title:^(Recording)$",
56+
"nofocus,title:^(Recording)$",
57+
"noborder,title:^(Recording)$",
58+
"noshadow,title:^(Recording)$",
59+
"noblur,title:^(Recording)$",
60+
"rounding 0,title:^(Recording)$",
61+
"opacity 1.0 override 1.0 override,title:^(Recording)$",
62+
"move onscreen 50%-w/2 100%-h-100,title:^(Recording)$",
4463
]
4564
for rule in rules:
4665
try:
47-
subprocess.run(['hyprctl', 'keyword', 'windowrule', rule],
48-
capture_output=True, timeout=2)
66+
result = subprocess.run(
67+
['hyprctl', 'keyword', 'windowrulev2', rule],
68+
capture_output=True, timeout=2, text=True,
69+
)
70+
if result.returncode != 0:
71+
print(f"[WARN] hyprctl rejected rule {rule!r}: {result.stderr.strip() or result.stdout.strip()}",
72+
flush=True)
4973
except (FileNotFoundError, subprocess.TimeoutExpired):
5074
break
5175

@@ -57,6 +81,33 @@ def _setup_hyprland_window_rules():
5781
# Disable accessibility scanning — major perf bottleneck on Linux with large HTML pages
5882
os.environ.setdefault('QTWEBENGINE_ENABLE_LINUX_ACCESSIBILITY', '0')
5983

84+
# ----------------------------------------------------------------------------
85+
# Register the voiceflow:// custom URL scheme BEFORE QApplication is created.
86+
# QWebEngineUrlScheme.registerScheme() is a no-op once QApplication exists.
87+
# Pyloid's __init__ instantiates QApplication, so this MUST run before the
88+
# `from pyloid import Pyloid` import below (its module init does NOT construct
89+
# QApplication; only Pyloid(...) does).
90+
#
91+
# The HTML5 <audio> element on MeetingDetailPage builds URLs of the form
92+
# `voiceflow://recording/<filename>.wav`. The matching handler subclass is
93+
# in services.recording.audio_scheme_handler and is installed on the default
94+
# QWebEngineProfile after Pyloid() returns.
95+
# ----------------------------------------------------------------------------
96+
from PySide6.QtWebEngineCore import QWebEngineUrlScheme
97+
98+
_vf_scheme = QWebEngineUrlScheme(b"voiceflow")
99+
_vf_scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host)
100+
# PortUnspecified is the default for newly-constructed schemes; PySide6's
101+
# setDefaultPort wants a raw int (-1) rather than the SpecialPort enum, so
102+
# we just leave it at the default to avoid the type-mismatch.
103+
_vf_scheme.setFlags(
104+
QWebEngineUrlScheme.Flag.SecureScheme
105+
| QWebEngineUrlScheme.Flag.LocalAccessAllowed
106+
| QWebEngineUrlScheme.Flag.CorsEnabled
107+
| QWebEngineUrlScheme.Flag.ViewSourceAllowed
108+
)
109+
QWebEngineUrlScheme.registerScheme(_vf_scheme)
110+
60111
from pyloid.tray import TrayEvent
61112
from pyloid.utils import get_production_path, is_production
62113
from pyloid.serve import pyloid_serve
@@ -212,6 +263,16 @@ def ensure_single_instance():
212263
app = Pyloid(app_name="VoiceFlow", single_instance=True, server=server)
213264
print("[DEBUG] Pyloid app created", flush=True)
214265

266+
# Install the voiceflow:// handler on the default profile. The scheme itself
267+
# was registered above (before QApplication). The handler must outlive every
268+
# request, so we hold a module-level reference — Qt holds a non-owning ref.
269+
from PySide6.QtWebEngineCore import QWebEngineProfile
270+
from services.recording.audio_scheme_handler import VoiceFlowAudioSchemeHandler
271+
_vf_audio_handler = VoiceFlowAudioSchemeHandler(get_controller().meetings.data_root)
272+
QWebEngineProfile.defaultProfile().installUrlSchemeHandler(b"voiceflow", _vf_audio_handler)
273+
log.info("voiceflow:// scheme handler installed",
274+
data_root=str(get_controller().meetings.data_root))
275+
215276
print("[DEBUG] Setting icons...", flush=True)
216277
app.set_icon(get_production_path("src-pyloid/icons/icon.png"))
217278
app.set_tray_icon(get_production_path("src-pyloid/icons/icon.png"))
@@ -287,6 +348,33 @@ def stop_active_meeting():
287348
_screen_height = 1080
288349

289350

351+
def _is_hyprland() -> bool:
352+
return bool(os.environ.get('HYPRLAND_INSTANCE_SIGNATURE'))
353+
354+
355+
def _hypr_dispatch(*args: str) -> None:
356+
"""Run `hyprctl dispatch ...`; no-op if not on Hyprland or hyprctl missing.
357+
358+
Used at runtime to move/resize the floating popup whenever it changes
359+
state (idle ↔ active), since Qt's `set_position()` is silently dropped on
360+
Wayland — the compositor is the only authority on window placement.
361+
"""
362+
if not _is_hyprland():
363+
return
364+
import subprocess
365+
try:
366+
result = subprocess.run(
367+
['hyprctl', 'dispatch', *args],
368+
capture_output=True, timeout=2, text=True,
369+
)
370+
if result.returncode != 0:
371+
log.warning("hyprctl dispatch failed",
372+
args=list(args),
373+
stderr=(result.stderr or '').strip())
374+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
375+
log.warning("hyprctl dispatch error", error=str(e))
376+
377+
290378
def get_active_monitor_info():
291379
"""Get the monitor where the cursor is currently located (for multi-monitor support)."""
292380
global _screen_x, _screen_y, _screen_width, _screen_height
@@ -333,29 +421,36 @@ def resize_popup(width: int, height: int):
333421
return
334422

335423
try:
336-
# Resize the window
424+
# Resize the window (works on X11 / Windows / macOS).
337425
popup_window.set_size(width, height)
338426

339-
# Recenter horizontally on active monitor, keep at bottom
340-
# Use monitor offset (_screen_x, _screen_y) for multi-monitor support
427+
# Recenter horizontally on active monitor, keep at bottom.
428+
# Use monitor offset (_screen_x, _screen_y) for multi-monitor support.
341429
popup_x = _screen_x + (_screen_width - width) // 2
342430
popup_y = _screen_y + _screen_height - 100
343431
popup_window.set_position(popup_x, popup_y)
344432

345-
# Ensure stay-on-top is maintained after resize
346-
# Also prevent resizing and make non-focusable to reduce blinking
433+
# Ensure stay-on-top is maintained after resize.
434+
# Also prevent resizing and make non-focusable to reduce blinking.
347435
qwindow = popup_window._window._window
348436
qwindow.setWindowFlags(
349437
Qt.FramelessWindowHint |
350438
Qt.WindowStaysOnTopHint |
351439
Qt.Tool |
352440
Qt.WindowDoesNotAcceptFocus
353441
)
354-
# Re-apply translucent background (required after setWindowFlags)
442+
# Re-apply translucent background (required after setWindowFlags).
355443
qwindow.setAttribute(Qt.WA_TranslucentBackground, True)
356-
# Prevent window resizing
444+
# Prevent window resizing.
357445
qwindow.setFixedSize(width, height)
358446
qwindow.show()
447+
448+
# Wayland fallback: ask the compositor to re-dock the existing window.
449+
# `set_position()` and `set_size()` above are no-ops on Wayland for
450+
# toplevels — the windowrulev2 from _setup_hyprland_window_rules() only
451+
# fires on initial map, so we have to dispatch the move/resize here too.
452+
_hypr_dispatch('resizewindowpixel', f'exact {width} {height},title:^(Recording)$')
453+
_hypr_dispatch('movewindowpixel', f'exact {popup_x} {popup_y},title:^(Recording)$')
359454
except Exception as e:
360455
log.error("Failed to resize popup", error=str(e))
361456

@@ -423,6 +518,17 @@ def init_popup():
423518
x=popup_x, y=popup_y,
424519
monitor_offset_x=_screen_x, monitor_offset_y=_screen_y)
425520

521+
# Wayland: enforce dock position once the window is mapped.
522+
# The windowrulev2 move rule fires on map, but we re-issue here in
523+
# case the rule registration race hasn't completed yet on first run.
524+
def _enforce_dock_position():
525+
_hypr_dispatch('resizewindowpixel',
526+
f'exact {POPUP_IDLE_WIDTH} {POPUP_IDLE_HEIGHT},title:^(Recording)$')
527+
_hypr_dispatch('movewindowpixel',
528+
f'exact {popup_x} {popup_y},title:^(Recording)$')
529+
530+
QTimer.singleShot(100, _enforce_dock_position)
531+
426532
# Send initial idle state after a brief delay to ensure page is loaded
427533
def send_initial_state():
428534
send_popup_event('popup-state', {'state': 'idle'})

src-pyloid/server.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,11 +660,44 @@ async def recordings_transcribe(*, id: int):
660660
return get_controller().meetings.transcribe(id)
661661

662662

663+
@server.method()
664+
async def recordings_retranscribe(
665+
*,
666+
id: int,
667+
model: Optional[str] = None,
668+
device: Optional[str] = None,
669+
language: Optional[str] = None,
670+
):
671+
"""Re-run transcription on an existing recording with optional overrides.
672+
673+
Reuses the same transcribe queue as `recordings_transcribe` — the only
674+
difference is the model/device/language overrides applied for this single
675+
job. Audio file is unchanged. Existing transcript + segments are replaced
676+
on success. Cancellation goes through `recordings_cancel_transcribe`.
677+
"""
678+
return get_controller().meetings.transcribe(
679+
id, model=model, device=device, language=language,
680+
)
681+
682+
663683
@server.method()
664684
async def recordings_cancel_transcribe(*, id: int):
665685
return get_controller().meetings.cancel_transcribe(id)
666686

667687

688+
@server.method()
689+
async def recordings_list_cached_models():
690+
"""Return all supported whisper models with their cache status, so the
691+
Re-transcribe modal can offer only models that won't trigger a download.
692+
"""
693+
manager = get_model_manager()
694+
names = manager.get_available_models()
695+
return [
696+
{"name": name, "cached": manager.is_model_cached(name)}
697+
for name in names
698+
]
699+
700+
668701
@server.method()
669702
async def recordings_summarize(*, id: int, prompt: Optional[str] = None):
670703
return get_controller().meetings.summarize(id, prompt)

src-pyloid/services/database.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def _ensure_recordings_tables(self, cursor: sqlite3.Cursor) -> None:
7171
sources TEXT NOT NULL DEFAULT '[]',
7272
language TEXT,
7373
transcript TEXT,
74+
transcript_model TEXT,
7475
transcript_status TEXT NOT NULL DEFAULT 'pending',
7576
transcript_progress REAL NOT NULL DEFAULT 0,
7677
transcript_error TEXT,
@@ -91,6 +92,16 @@ def _ensure_recordings_tables(self, cursor: sqlite3.Cursor) -> None:
9192
"ON recordings(created_at DESC)"
9293
)
9394

95+
# Migration for existing DBs: add transcript_model column if missing.
96+
cursor.execute("PRAGMA table_info(recordings)")
97+
existing_cols = {row[1] for row in cursor.fetchall()}
98+
if "transcript_model" not in existing_cols:
99+
try:
100+
cursor.execute("ALTER TABLE recordings ADD COLUMN transcript_model TEXT")
101+
debug("Added transcript_model column to recordings table")
102+
except sqlite3.OperationalError as exc:
103+
debug(f"Failed to add transcript_model column: {exc}")
104+
94105
cursor.execute("""
95106
CREATE TABLE IF NOT EXISTS recording_segments (
96107
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -586,15 +597,24 @@ def set_recording_transcript(
586597
recording_id: int,
587598
transcript: str,
588599
language: Optional[str] = None,
600+
model: Optional[str] = None,
589601
) -> None:
590-
"""Persist the final transcript text. Segments go via replace_recording_segments."""
602+
"""Persist the final transcript text. Segments go via replace_recording_segments.
603+
604+
`model` is the whisper model name that produced the transcript (e.g.
605+
'tiny', 'large-v3'). Optional and backward-compatible — existing callers
606+
that don't pass it leave the column NULL or unchanged on re-transcribe.
607+
"""
591608
conn = self._get_connection()
592609
try:
593610
conn.execute(
594611
"""UPDATE recordings
595-
SET transcript = ?, language = COALESCE(?, language), updated_at = ?
612+
SET transcript = ?,
613+
language = COALESCE(?, language),
614+
transcript_model = COALESCE(?, transcript_model),
615+
updated_at = ?
596616
WHERE id = ?""",
597-
(transcript, language, datetime.now().isoformat(), recording_id),
617+
(transcript, language, model, datetime.now().isoformat(), recording_id),
598618
)
599619
conn.commit()
600620
finally:

0 commit comments

Comments
 (0)