From 18ce1c134c6cd6a1b0a80723e1e993e952d5a57e Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 7 Sep 2026 09:38:32 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20MeetRec=20=E2=80=94=20recor?= =?UTF-8?q?d=20&=20transcribe=20meetings=20with=20Whisper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - meetrec.py: CLI recorder with live rolling transcript (faster-whisper) - meetrec_gui.py: PySide6 GUI reusing the CLI's recording/transcription logic, with model/device/compute selection and live transcript - install.sh: local install into ~/.local (venv, deps, launchers, desktop entry), with --model/--no-model/--uninstall - bin/meetrec, bin/meetrec-cli: launchers - share/applications/meetrec.desktop: XDG desktop entry - README.md, requirements.txt, .gitignore --- .gitignore | 22 ++ README.md | 119 ++++++++ bin/meetrec | 10 + bin/meetrec-cli | 10 + install.sh | 135 ++++++++ meetrec.py | 357 ++++++++++++++++++++++ meetrec_gui.py | 474 +++++++++++++++++++++++++++++ requirements.txt | 4 + share/applications/meetrec.desktop | 12 + 9 files changed, 1143 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/meetrec create mode 100755 bin/meetrec-cli create mode 100755 install.sh create mode 100644 meetrec.py create mode 100644 meetrec_gui.py create mode 100644 requirements.txt create mode 100644 share/applications/meetrec.desktop diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3af5a70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Virtualenv created by install.sh (when PREFIX=. or similar) +share/meetrec/venv/ + +# Recordings & transcripts (output artifacts) +*.wav +*.srt +*.live.srt + +# Editor / OS +.vscode/ +.idea/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9d333e --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# MeetRec + +Record an in-person meeting and transcribe it with [Whisper](https://github.com/SYSTRAN/faster-whisper). + +Meetrec captures audio from any PipeWire/ALSA input (microphone, USB conference mic, USB audio interface, or a Pulse/PipeWire sink *monitor*) and produces transcripts in `txt`, `srt`, and `json`. It runs fully locally — no audio ever leaves your machine. + +Two interfaces: + +- **`meetrec.py`** — CLI: record, optionally show a live rolling transcript, and on `Ctrl+C` run a final higher-quality pass. +- **`meetrec_gui.py`** — Qt (PySide6) GUI: device/model/language selection, level meter, live transcript, and one-click *Stop & transcribe*. + +## Output files + +With output stem `-o meeting`: + +| File | Contents | +| ------------------ | ----------------------------------------------- | +| `meeting.wav` | raw recording (16 kHz mono WAV) | +| `meeting.live.srt` | live rolling transcript (only with `--live > 0`) | +| `meeting.txt` | final transcript | +| `meeting.srt` | final transcript with timestamps | +| `meeting.json` | final transcript, structured (language, segments) | + +## Requirements + +- Linux with an audio backend: **PipeWire** (or PulseAudio/ALSA). On Arch: `sudo pacman -S pipewire pipewire-pulse` +- **Python 3.10+** +- Python packages (installed for you by the install script): `numpy`, `sounddevice`, `faster-whisper`, `pyside6` (GUI only) +- A GPU (CUDA) is optional — transcription falls back to CPU. + +## Installation + +### Quick install + +```sh +./install.sh +``` + +This: + +1. copies the app to `~/.local/share/meetrec`, +2. creates a venv and installs all dependencies, +3. installs the `meetrec` (GUI) and `meetrec-cli` launchers into `~/.local/bin`, +4. installs the XDG desktop entry (`~/.local/share/applications/meetrec.desktop`), +5. pre-downloads the `small` Whisper model (first run is otherwise slow). + +Options: + +```sh +./install.sh --model base # pre-download a different model (tiny/base/small/medium/large-v3) +./install.sh --no-model # skip the model pre-download +./install.sh --uninstall # remove everything the installer created +PREFIX=/opt/meetrec ./install.sh # install into a different prefix +``` + +### Manual install (venv) + +```sh +sudo pacman -S python pipewire pipewire-pulse +python3 -m venv .venv && source .venv/bin/activate +pip install numpy sounddevice faster-whisper pyside6 +``` + +## Usage + +### GUI + +```sh +meetrec # after ./install.sh +# or from a checkout: +python meetrec_gui.py +``` + +Pick a microphone (or type a name substring), model, language, and compute device/precision (switching model, device, or precision between recordings reloads the model automatically), press **Record**, watch the live transcript, then press **Stop & transcribe**. + +### CLI + +```sh +meetrec-cli --list-sources # find your input devices +meetrec-cli -s "Conference Mic" --model small --live 8 -o meeting +meetrec-cli -s monitor --model base --language en # record system audio +python meetrec.py --help # all options +``` + +Key options: + +| Option | Meaning | +| --------------- | ---------------------------------------------------------------- | +| `-s, --source` | `default` or a substring of a device name (`--list-sources`) | +| `-m, --model` | `tiny` / `base` / `small` / `medium` / `large-v3` (default `small`) | +| `-o, --output` | output file stem (default `meeting`) | +| `--live SEC` | live-transcription interval in seconds; `0` disables (default 8) | +| `--language` | force a language code, e.g. `en`, `de` (default: autodetect) | +| `--device` | `auto` / `cpu` / `cuda` (default `auto`) | +| `--compute-type`| `int8` / `int8_float16` / `float16` / `float32` (default `int8`) | + +`Ctrl+C` stops recording and runs the final transcription. + +### Tips + +- **Record system audio**: sink monitors appear as `.monitor`; use `-s monitor` (or a matching substring). +- **Model size vs. speed/accuracy**: `tiny`/`base` are fast and rough, `small` is a good default, `medium`/`large-v3` are most accurate but much slower on CPU. +- Models are cached in `~/.cache/huggingface` and shared between the CLI and the GUI. + +## Project layout + +``` +meetrec.py CLI: recording + live & final transcription +meetrec_gui.py PySide6 GUI (reuses meetrec.py's logic) +requirements.txt Python dependencies +install.sh install / uninstall into a local prefix +bin/meetrec GUI launcher (installed into ~/.local/bin) +bin/meetrec-cli CLI launcher (installed into ~/.local/bin) +share/applications/meetrec.desktop XDG desktop entry +``` + +## Privacy + +Everything runs locally. The only network access is the one-time Whisper model download from Hugging Face. diff --git a/bin/meetrec b/bin/meetrec new file mode 100755 index 0000000..f489eb1 --- /dev/null +++ b/bin/meetrec @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +# MeetRec — GUI launcher (installed by install.sh) +set -eu +APP_DIR="${MEETREC_APP_DIR:-$HOME/.local/share/meetrec}" +PY="$APP_DIR/venv/bin/python" +if [ ! -x "$PY" ]; then + echo "MeetRec is not installed. Run ./install.sh first." >&2 + exit 1 +fi +exec "$PY" "$APP_DIR/meetrec_gui.py" "$@" diff --git a/bin/meetrec-cli b/bin/meetrec-cli new file mode 100755 index 0000000..29b1152 --- /dev/null +++ b/bin/meetrec-cli @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +# MeetRec — CLI launcher (installed by install.sh) +set -eu +APP_DIR="${MEETREC_APP_DIR:-$HOME/.local/share/meetrec}" +PY="$APP_DIR/venv/bin/python" +if [ ! -x "$PY" ]; then + echo "MeetRec is not installed. Run ./install.sh first." >&2 + exit 1 +fi +exec "$PY" "$APP_DIR/meetrec.py" "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0acc508 --- /dev/null +++ b/install.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# install.sh — install MeetRec (GUI + CLI) into a local prefix. +# +# Usage: +# ./install.sh install, pre-downloading the 'small' model +# ./install.sh --model base pre-download a different Whisper model +# ./install.sh --no-model skip the model pre-download +# ./install.sh --uninstall remove everything the installer created +# +# Environment: +# PREFIX install prefix (default: $HOME/.local) +# PYTHON Python interpreter to use (default: python3) + +set -euo pipefail + +PREFIX="${PREFIX:-$HOME/.local}" +APP_DIR="$PREFIX/share/meetrec" +VENV="$APP_DIR/venv" +BIN_DIR="$PREFIX/bin" +DESKTOP_DIR="$PREFIX/share/applications" +PYTHON="${PYTHON:-python3}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +MODEL="small" +DOWNLOAD_MODEL=1 +UNINSTALL=0 + +while [ $# -gt 0 ]; do + case "$1" in + --model) + [ $# -ge 2 ] || { echo "error: --model needs a value" >&2; exit 1; } + MODEL="$2" + shift 2 + ;; + --no-model) DOWNLOAD_MODEL=0; shift ;; + --uninstall) UNINSTALL=1; shift ;; + -h|--help) + sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "error: unknown option: $1" >&2 + echo "try: ./install.sh --help" >&2 + exit 1 + ;; + esac +done + +# ---------------------------------------------------------------- uninstall + +if [ "$UNINSTALL" = 1 ]; then + echo "Uninstalling MeetRec from $PREFIX ..." + rm -rf "$APP_DIR" + rm -f "$BIN_DIR/meetrec" "$BIN_DIR/meetrec-cli" + rm -f "$DESKTOP_DIR/meetrec.desktop" + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true + fi + echo "Done. (Whisper models remain in ~/.cache/huggingface — remove that if you want them gone.)" + exit 0 +fi + +# ------------------------------------------------------------------- checks + +echo "==> Checking prerequisites" + +if ! command -v "$PYTHON" >/dev/null 2>&1; then + echo "error: $PYTHON not found. Install Python 3.10+ first." >&2 + exit 1 +fi +if ! "$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)'; then + echo "error: Python 3.10+ required, found: $("$PYTHON" --version 2>&1)" >&2 + exit 1 +fi + +if ! command -v pactl >/dev/null 2>&1; then + echo "warning: 'pactl' not found — is PipeWire/PulseAudio running?" + echo " On Arch: sudo pacman -S pipewire pipewire-pulse" +fi +if ! command -v aplay >/dev/null 2>&1 && ! command -v arecord >/dev/null 2>&1; then + echo "warning: ALSA utils not found — recording may not work." +fi + +# -------------------------------------------------------------------- install + +echo "==> Installing app to $APP_DIR" +mkdir -p "$APP_DIR" "$BIN_DIR" "$DESKTOP_DIR" +cp "$SCRIPT_DIR/meetrec.py" "$SCRIPT_DIR/meetrec_gui.py" "$SCRIPT_DIR/requirements.txt" "$APP_DIR/" + +echo "==> Creating virtualenv" +if ! "$PYTHON" -m venv "$VENV"; then + echo "error: could not create a venv." >&2 + echo " Debian/Ubuntu: sudo apt install python3-venv" >&2 + echo " Arch: sudo pacman -S python-virtualenv (or ensure python3-venv)" >&2 + exit 1 +fi +"$VENV/bin/pip" install --upgrade pip --quiet + +echo "==> Installing Python dependencies (numpy, sounddevice, faster-whisper, pyside6)" +"$VENV/bin/pip" install -r "$APP_DIR/requirements.txt" + +echo "==> Installing launchers into $BIN_DIR" +install -m 0755 "$SCRIPT_DIR/bin/meetrec" "$BIN_DIR/meetrec" +install -m 0755 "$SCRIPT_DIR/bin/meetrec-cli" "$BIN_DIR/meetrec-cli" + +echo "==> Installing XDG desktop entry" +install -m 0644 "$SCRIPT_DIR/share/applications/meetrec.desktop" \ + "$DESKTOP_DIR/meetrec.desktop" +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true +fi + +if [ "$DOWNLOAD_MODEL" = 1 ]; then + echo "==> Pre-downloading Whisper model '$MODEL' (first run only, can take a while)" + "$VENV/bin/python" - <> ~/.bashrc" +fi diff --git a/meetrec.py b/meetrec.py new file mode 100644 index 0000000..9616893 --- /dev/null +++ b/meetrec.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +""" +meetrec.py — record an in-person meeting and transcribe it with Whisper. + +Records from any PipeWire/ALSA input (microphone, USB conference mic, +USB audio interface, or a Pulse/PipeWire sink "monitor") while optionally +printing a live rolling transcript. On Ctrl+C it stops cleanly and runs a +final, higher-quality full transcription. + +Outputs (with -o meeting): + meeting.wav raw recording, 16 kHz mono WAV + meeting.live.srt live rolling transcript (only when --live > 0) + meeting.txt final transcript + meeting.srt final transcript with timestamps + meeting.json final transcript, structured + +Setup on Arch: + sudo pacman -S python pipewire pipewire-pulse + python -m venv .venv && source .venv/bin/activate + pip install numpy sounddevice faster-whisper + +Usage: + python meetrec.py --list-sources + python meetrec.py -s "Conference Mic" --model small --live 8 -o meeting + python meetrec.py -s monitor --model base --language en # record system audio + + Ctrl+C to stop -> final transcripts are written. +""" + +import argparse +import json +import queue +import shutil +import subprocess +import sys +import threading +import time +import wave + +import numpy as np +import sounddevice as sd +from faster_whisper import WhisperModel + +SR = 16000 # sample rate — what Whisper wants +LIVE_WINDOW = 60 # seconds of rolling audio kept for live transcription + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + +def fmt_hms(t: float) -> str: + h, rem = divmod(int(t), 3600) + m, s = divmod(rem, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + +def fmt_srt(t: float) -> str: + ms = int(round((t - int(t)) * 1000)) + h, rem = divmod(int(t) + (1 if ms >= 1000 else 0), 3600) + m, s = divmod(rem, 60) + if ms >= 1000: + ms -= 1000 + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def write_srt(path: str, items) -> None: + with open(path, "w") as f: + for i, (a, b, text) in enumerate(items, 1): + f.write(f"{i}\n{fmt_srt(a)} --> {fmt_srt(b)}\n{text}\n\n") + + +def list_sources() -> None: + print("PortAudio (PipeWire/Pulse/ALSA) input devices:\n") + for i, d in enumerate(sd.query_devices()): + if d["max_input_channels"] > 0: + print(f" {i:>3} {d['name']}\n" + f" api={d['hostapi']} in={d['max_input_channels']}ch " + f"rate={d['default_samplerate']:.0f}") + pactl = shutil.which("pactl") + if pactl: + print("\nPulseAudio/PipeWire sources (pactl):\n") + try: + out = subprocess.run( + [pactl, "list", "short", "sources"], + capture_output=True, text=True, timeout=5).stdout.strip() + for line in out.splitlines(): + print(" " + line) + except Exception: + pass + print("\nTip: sink monitors (record what the computer plays) appear as " + "\".monitor\". Match them with -s .") + + +def resolve_source(name: str): + """Return a sounddevice device index, or None for the default device.""" + if name in ("default", None, ""): + return None + inputs = [(i, d) for i, d in enumerate(sd.query_devices()) + if d["max_input_channels"] > 0] + matches = [(i, d) for i, d in inputs if name.lower() in d["name"].lower()] + if not matches: + print(f"error: no input device matching {name!r}. " + f"Run with --list-sources.", file=sys.stderr) + sys.exit(1) + if len(matches) > 1: + print(f"note: multiple devices match {name!r}, using: " + f"{matches[0][1]['name']}", file=sys.stderr) + idx, dev = matches[0] + print(f"recording from: [{idx}] {dev['name']} ({dev['hostapi']})") + return idx + + +# -------------------------------------------------------------------------- +# Recording +# -------------------------------------------------------------------------- + +class Recorder: + """Records mono 16 kHz float32 to a WAV file and keeps a rolling buffer.""" + + def __init__(self, device, wav_path: str): + self.device = device + self.wav_path = wav_path + self.q: "queue.Queue" = queue.Queue() + self.lock = threading.Lock() + self.buf = np.zeros(0, dtype=np.float32) # rolling window + self.base = 0 # samples recorded before buf + self.total = 0 # total samples recorded + self.peak = 0.0 # peak amplitude of last block + self.stop_event = threading.Event() + self.stream = None + self._writer = threading.Thread(target=self._write, daemon=True) + self._writer.start() + + def _callback(self, indata, frames, time_info, status): + if status: + print(f"[rec] status: {status}", file=sys.stderr) + self.peak = float(np.max(np.abs(indata[:, 0]))) if frames else 0.0 + self.q.put(indata.copy()) + with self.lock: + block = indata[:, 0].astype(np.float32, copy=False) + self.buf = np.append(self.buf, block) + self.total += len(block) + limit = LIVE_WINDOW * SR + if len(self.buf) > limit: + drop = len(self.buf) - limit + self.buf = self.buf[drop:] + self.base += drop + + def duration(self) -> float: + with self.lock: + return self.total / SR + + def _write(self): + with wave.open(self.wav_path, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(SR) + while True: + block = self.q.get() + if block is None: + break + pcm = (np.clip(block[:, 0], -1.0, 1.0) * 32767).astype(np.int16) + w.writeframes(pcm.tobytes()) + + def snapshot(self): + """Return (rolling audio, sample offset of its start).""" + with self.lock: + return self.buf.copy(), self.base + + def start(self): + self.stream = sd.InputStream( + samplerate=SR, channels=1, dtype="float32", + device=self.device, callback=self._callback) + self.stream.start() + + def stop(self): + self.stop_event.set() + self.q.put(None) + if self.stream: + self.stream.stop() + self.stream.close() + # The writer must drain the queue and close the WAV before we return, + # otherwise the file can be truncated. Join in slices instead of one + # long timeout so a slow disk still gets the full flush; only warn + # after a total of 60 s (effectively never in practice). + waited = 0.0 + while self._writer.is_alive() and waited < 60: + self._writer.join(timeout=2.0) + waited += 2.0 + if self._writer.is_alive(): + print("warning: WAV writer still running after 60 s; " + "the file may be truncated", file=sys.stderr) + + +# -------------------------------------------------------------------------- +# Transcription +# -------------------------------------------------------------------------- + +def live_worker(model, rec: Recorder, args, live_srt_path: str, + stop: threading.Event, on_line=None): + """Transcribes the rolling window every `args.live` seconds and appends + newly-finalized segments to the live .srt. If `on_line` is given, new + lines are delivered to it (a, text) instead of being printed.""" + def emit(a, text): + if on_line is not None: + on_line(a, text) + else: + print(f"[{fmt_hms(a)}] {text}", flush=True) + + items = [] # (abs_start, abs_end, text) for the live .srt + last_end = 0.0 + if on_line is None: + print("live transcription on (Ctrl+C to stop)", flush=True) + + while not stop.is_set(): + stop.wait(args.live) + if stop.is_set(): + break + buf, base = rec.snapshot() + if len(buf) < 2 * SR: # need at least 2 s of audio + continue + try: + segs, _info = model.transcribe( + buf, beam_size=1, vad_filter=True, language=args.language) + except Exception as e: + print(f"[live] transcription error: {e}", file=sys.stderr) + continue + + base_s = base / SR + added = False + for s in segs: + a, b = base_s + s.start, base_s + s.end + text = s.text.strip() + if not text or b < last_end: + continue + items.append((a, b, text)) + last_end = max(last_end, b) + emit(a, text) + added = True + if added: + write_srt(live_srt_path, items) + + +def final_transcribe(model, wav_path: str, out_stem: str, language, + on_info=None, on_segment=None, on_done=None): + """Full-pass transcription of the whole recording. + + on_info(msg), on_segment(start, end, text) and on_done() are optional + callbacks; when absent the same progress is printed to stdout.""" + def info(msg): + if on_info is not None: + on_info(msg) + else: + print(msg, flush=True) + + info("\nRunning final transcription (this can take a while)...") + segments, info_ = model.transcribe( + wav_path, beam_size=5, vad_filter=True, language=language, + vad_parameters=dict(min_silence_duration_ms=500)) + + info(f"language: {info_.language} (p={info_.language_probability:.2f}) " + f"duration: {fmt_hms(info_.duration)}") + + items = [] + for s in segments: # generator: streams as it decodes + text = s.text.strip() + items.append((s.start, s.end, text)) + if on_segment is not None: + on_segment(s.start, s.end, text) + else: + print(f"[{fmt_hms(s.start)}] {text}", flush=True) + + full = " ".join(t for _, _, t in items) + with open(out_stem + ".txt", "w") as f: + f.write(full + "\n") + write_srt(out_stem + ".srt", items) + with open(out_stem + ".json", "w") as f: + json.dump({ + "language": info_.language, + "duration": round(info_.duration, 2), + "segments": [{"start": round(a, 2), "end": round(b, 2), "text": t} + for a, b, t in items], + }, f, ensure_ascii=False, indent=2) + info(f"\nwrote: {out_stem}.wav {out_stem}.txt {out_stem}.srt {out_stem}.json") + if on_done is not None: + on_done() + + +# -------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser( + description="Record a meeting and transcribe it with Whisper " + "(PipeWire / ALSA via PortAudio).") + ap.add_argument("-s", "--source", default="default", + help="input to record: 'default' or a substring of a " + "device name (see --list-sources)") + ap.add_argument("-m", "--model", default="small", + help="Whisper model: tiny/base/small/medium/large-v3 " + "(default: small)") + ap.add_argument("-o", "--output", default="meeting", + help="output file stem (default: meeting)") + ap.add_argument("--live", type=int, default=8, metavar="SEC", + help="live transcription interval in seconds; 0 disables " + "(default: 8)") + ap.add_argument("--language", default=None, + help="force language code, e.g. en, de (default: autodetect)") + ap.add_argument("--device", default="auto", + help="compute device: auto / cpu / cuda (default: auto)") + ap.add_argument("--compute-type", default="int8", + help="int8 / int8_float16 / float16 / float32 (default: int8)") + ap.add_argument("--list-sources", action="store_true", + help="list available input devices and exit") + args = ap.parse_args() + + if args.list_sources: + list_sources() + return + + device = resolve_source(args.source) + + print(f"loading Whisper model {args.model!r} " + f"(first run downloads it to ~/.cache/huggingface)...", flush=True) + model = WhisperModel(args.model, device=args.device, + compute_type=args.compute_type) + + rec = Recorder(device, args.output + ".wav") + rec.start() + print(f"recording to {args.output}.wav (Ctrl+C to stop)", flush=True) + + stop = threading.Event() + worker = None + if args.live > 0: + worker = threading.Thread( + target=live_worker, args=(model, rec, args, + args.output + ".live.srt", stop), + daemon=True) + worker.start() + + try: + while not stop.is_set(): + stop.wait(0.5) + except KeyboardInterrupt: + print("\nstopping...", flush=True) + finally: + stop.set() + rec.stop() + if worker: + worker.join(timeout=60) + final_transcribe(model, args.output + ".wav", args.output, args.language) + + +if __name__ == "__main__": + main() diff --git a/meetrec_gui.py b/meetrec_gui.py new file mode 100644 index 0000000..4449aed --- /dev/null +++ b/meetrec_gui.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +""" +meetrec_gui.py — Qt (PySide6) GUI for meetrec. + +Records a meeting from any PipeWire/ALSA input, shows a live rolling +transcript while recording, and on Stop runs the final full transcription. +Reuses all recording/transcription logic from meetrec.py (the CLI). + +Setup (Arch): + sudo pacman -S python pipewire pipewire-pulse python-pyside6 + # or: pip install pyside6 + # (plus the usual deps: pip install numpy sounddevice faster-whisper) + +Run: + python meetrec_gui.py +""" + +import queue +import sys +import threading +from types import SimpleNamespace + +from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtWidgets import ( + QApplication, QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QProgressBar, QPushButton, QSpinBox, QTextEdit, + QVBoxLayout, QWidget, +) + +import sounddevice as sd + +import meetrec +from meetrec import ( + Recorder, + WhisperModel, + final_transcribe, + fmt_hms, + live_worker, + resolve_source, +) + + +# -------------------------------------------------------------------------- +# Worker threads +# -------------------------------------------------------------------------- + +class ModelLoader(QThread): + """Loads (and on first run downloads) the Whisper model off the GUI thread.""" + + loaded = Signal(object) + failed = Signal(str) + + def __init__(self, model_name, device="auto", compute_type="int8", + parent=None): + super().__init__(parent) + self.model_name = model_name + self.device = device + self.compute_type = compute_type + + def run(self): + try: + self.model = WhisperModel(self.model_name, + device=self.device, + compute_type=self.compute_type) + self.loaded.emit(self.model) + except Exception as e: + self.failed.emit(str(e)) + + +class FinalWorker(QThread): + """Runs the final full-pass transcription without blocking the GUI.""" + + info = Signal(str) + segment = Signal(float, float, str) + done = Signal(str) # output stem + failed = Signal(str) + + def __init__(self, model, wav_path, out_stem, language, parent=None): + super().__init__(parent) + self.model = model + self.wav_path = wav_path + self.out_stem = out_stem + self.language = language + + def run(self): + try: + final_transcribe( + self.model, self.wav_path, self.out_stem, self.language, + on_info=self.info.emit, + on_segment=self.segment.emit, + on_done=lambda: self.done.emit(self.out_stem), + ) + except Exception as e: + self.failed.emit(str(e)) + + +# -------------------------------------------------------------------------- +# Main window +# -------------------------------------------------------------------------- + +class MeetRecWindow(QWidget): + MODELS = ["tiny", "base", "small", "medium", "large-v3"] + LANGS = ["autodetect", "en", "de", "fr", "es", "it", "nl", "pl", + "pt", "ru", "zh", "ja", "ko"] + DEVICES = ["auto", "cpu", "cuda"] + COMPUTE_TYPES = ["int8", "int8_float16", "float16", "float32"] + + def __init__(self): + super().__init__() + self.setWindowTitle("MeetRec — record & transcribe meetings") + self.setMinimumSize(700, 600) + + self.model = None + self.model_name = None # model currently loaded into memory + self.rec = None + self.state = "idle" # idle | loading | record | finalizing + self.decay = 0.0 + self.stop_evt = threading.Event() + self.line_q: "queue.Queue" = queue.Queue() + self._final_header = False + self._args = None + + self._build_ui() + + self.ui_timer = QTimer(self) + self.ui_timer.setInterval(120) + self.ui_timer.timeout.connect(self._tick) + self.ui_timer.start() + + # ------------------------------ UI layout ----------------------------- + + def _build_ui(self): + root = QVBoxLayout(self) + + # row 1: microphone source + r1 = QHBoxLayout() + r1.addWidget(QLabel("Microphone:")) + self.src = QComboBox() + self.src.setEditable(True) # free text = name substring, like the CLI + self.src.addItem("default") + self.src.setMinimumWidth(280) + self._fill_sources() + refresh = QPushButton("\u21bb") + refresh.setFixedWidth(36) + refresh.setToolTip("Rescan input devices") + refresh.clicked.connect(self._fill_sources) + r1.addWidget(self.src, 1) + r1.addWidget(refresh) + root.addLayout(r1) + + # row 2: model + language + r2 = QHBoxLayout() + r2.addWidget(QLabel("Model:")) + self.model_cb = QComboBox() + self.model_cb.addItems(self.MODELS) + self.model_cb.setCurrentText("small") + r2.addWidget(self.model_cb) + r2.addStretch(1) + r2.addWidget(QLabel("Language:")) + self.lang = QComboBox() + self.lang.setEditable(True) + self.lang.addItems(self.LANGS) + self.lang.setCurrentText("autodetect") + self.lang.setMinimumWidth(110) + r2.addWidget(self.lang) + root.addLayout(r2) + + # row 2b: compute device + precision + r2b = QHBoxLayout() + r2b.addWidget(QLabel("Device:")) + self.device_cb = QComboBox() + self.device_cb.addItems(self.DEVICES) + self.device_cb.setCurrentText("auto") + r2b.addWidget(self.device_cb) + r2b.addWidget(QLabel("Compute:")) + self.compute_cb = QComboBox() + self.compute_cb.addItems(self.COMPUTE_TYPES) + self.compute_cb.setCurrentText("int8") + r2b.addWidget(self.compute_cb) + r2b.addStretch(1) + root.addLayout(r2b) + + # row 3: live options + output name + r3 = QHBoxLayout() + r3.addWidget(QLabel("Live:")) + self.live_cb = QCheckBox("transcribe while recording every") + self.live_cb.setChecked(True) + r3.addWidget(self.live_cb) + self.live_spin = QSpinBox() + self.live_spin.setRange(3, 60) + self.live_spin.setValue(8) + self.live_spin.setSuffix(" s") + r3.addWidget(self.live_spin) + r3.addStretch(1) + r3.addWidget(QLabel("Output:")) + self.out = QLineEdit("meeting") + self.out.setPlaceholderText("file stem, e.g. meeting") + self.out.setMinimumWidth(160) + r3.addWidget(self.out) + root.addLayout(r3) + + # row 4: record / stop + r4 = QHBoxLayout() + self.record_btn = QPushButton("\u23fa Record") + self.record_btn.setMinimumHeight(44) + self.record_btn.setStyleSheet( + "QPushButton { background: #1a7f37; color: white; font-weight: bold; }" + "QPushButton:disabled { background: #888; }") + self.record_btn.clicked.connect(self.on_record) + self.stop_btn = QPushButton("\u23f9 Stop & transcribe") + self.stop_btn.setMinimumHeight(44) + self.stop_btn.setEnabled(False) + self.stop_btn.setStyleSheet( + "QPushButton { background: #c0392b; color: white; font-weight: bold; }" + "QPushButton:disabled { background: #888; }") + self.stop_btn.clicked.connect(self.on_stop) + r4.addWidget(self.record_btn, 1) + r4.addWidget(self.stop_btn, 1) + root.addLayout(r4) + + # row 5: level meter + elapsed + r5 = QHBoxLayout() + self.level = QProgressBar() + self.level.setRange(0, 100) + self.level.setTextVisible(False) + self.level.setFixedHeight(10) + self.level.setStyleSheet( + "QProgressBar::chunk { background: #2ea043; }") + self.elapsed = QLabel("00:00:00") + self.elapsed.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.elapsed.setMinimumWidth(80) + self.elapsed.setStyleSheet("font-size: 15px; font-weight: bold;") + r5.addWidget(self.level, 1) + r5.addWidget(self.elapsed) + root.addLayout(r5) + + # transcript + self.transcript = QTextEdit() + self.transcript.setReadOnly(True) + self.transcript.setPlaceholderText( + "Live transcript appears here while recording.\n" + "After stopping, the final (higher-quality) transcript is shown " + "and saved as .txt / .srt / .json next to the .wav.") + root.addWidget(self.transcript, 1) + + # status bar + bar = QHBoxLayout() + self.status = QLabel("") + bar.addWidget(self.status, 1) + clear = QPushButton("Clear transcript") + clear.clicked.connect(lambda: self.transcript.clear()) + bar.addWidget(clear) + root.addLayout(bar) + + # ------------------------------ helpers ------------------------------- + + def _fill_sources(self): + keep = self.src.currentText() + self.src.blockSignals(True) + self.src.clear() + self.src.addItem("default") + seen = set() + try: + for d in sd.query_devices(): + if d["max_input_channels"] > 0 and d["name"] not in seen: + seen.add(d["name"]) + self.src.addItem(d["name"]) + except Exception: + pass + self.src.blockSignals(False) + idx = self.src.findText(keep) + if idx >= 0: + self.src.setCurrentIndex(idx) + else: + self.src.setCurrentText(keep) + + def _set_inputs_enabled(self, enabled: bool): + for w in (self.src, self.model_cb, self.device_cb, self.compute_cb, + self.lang, self.live_cb, self.live_spin, self.out): + w.setEnabled(enabled) + + def _to_idle(self): + self.state = "idle" + self._set_inputs_enabled(True) + self.record_btn.setEnabled(True) + self.stop_btn.setEnabled(False) + + def _append(self, text: str): + self.transcript.moveCursor(QTextEdit.End) + self.transcript.insertPlainText(text) + sb = self.transcript.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _push_line(self, a: float, text: str): + """Thread-safe: called from the live worker thread.""" + self.line_q.put((a, text)) + + # ------------------------------ state machine -------------------------- + + def on_record(self): + if self.state != "idle": + return + lang = self.lang.currentText().strip() + live = self.live_spin.value() if self.live_cb.isChecked() else 0 + self._args = SimpleNamespace( + source=self.src.currentText().strip() or "default", + model=self.model_cb.currentText(), + output=self.out.text().strip() or "meeting", + live=live, + language=None if lang in ("", "autodetect") else lang, + ) + self._set_inputs_enabled(False) + self.transcript.clear() + self._final_header = False + self.decay = 0.0 + self.level.setValue(0) + self.elapsed.setText("00:00:00") + + if self.model is None or self.model_name != self._args.model: + self.state = "loading" + self.status.setText( + f"Loading Whisper model {self._args.model!r} " + "(first run downloads it, this can take a minute)...") + self.loader = ModelLoader( + self._args.model, + device=self.device_cb.currentText(), + compute_type=self.compute_cb.currentText(), + parent=self) + self.loader.loaded.connect(self._model_ready) + self.loader.failed.connect(self._model_failed) + self.loader.start() + else: + self._start_recording() + + def _model_ready(self, model): + self.model = model + self.model_name = self._args.model + self._start_recording() + + def _model_failed(self, err): + self._to_idle() + self.status.setText("Failed to load model") + QMessageBox.critical(self, "MeetRec", f"Could not load Whisper model:\n\n{err}") + + def _start_recording(self): + try: + device = resolve_source(self._args.source) + except SystemExit: + self._to_idle() + QMessageBox.critical( + self, "MeetRec", + f"No input device matching {self._args.source!r}.\n" + "Pick one from the dropdown or fix the name.") + return + + try: + self.rec = Recorder(device, self._args.output + ".wav") + self.rec.start() + except Exception as e: + self._to_idle() + QMessageBox.critical(self, "MeetRec", + f"Could not open the microphone:\n\n{e}") + return + + self.stop_evt.clear() + self.state = "record" + self.record_btn.setEnabled(False) + self.stop_btn.setEnabled(True) + + try: + idx = device if device is not None else sd.default.device[0] + name = sd.query_devices(idx)["name"] + except Exception: + name = self._args.source + self.status.setText(f"Recording from: {name} → {self._args.output}.wav") + + if self._args.live: + self.live_thread = threading.Thread( + target=live_worker, + args=(self.model, self.rec, self._args, + self._args.output + ".live.srt", self.stop_evt, + self._push_line), + daemon=True) + self.live_thread.start() + + def on_stop(self): + if self.state != "record": + return + self.stop_evt.set() + self.status.setText("Stopped — running final transcription...") + if self.rec: + self.rec.stop() # flushes the WAV writer (a few ms) + + self.state = "finalizing" + self.stop_btn.setEnabled(False) + self.worker = FinalWorker( + self.model, + self._args.output + ".wav", + self._args.output, + self._args.language, + parent=self) + self.worker.info.connect(lambda m: self.status.setText(m)) + self.worker.segment.connect(self._final_segment) + self.worker.done.connect(self._final_done) + self.worker.failed.connect(self._final_failed) + self.worker.start() + + def _final_segment(self, a: float, b: float, text: str): + if not self._final_header: + self._append("\n— final transcript —\n") + self._final_header = True + self._append(f"[{fmt_hms(a)}] {text}\n") + + def _final_done(self, stem: str): + self._to_idle() + self.status.setText( + f"Done. Saved: {stem}.wav {stem}.txt {stem}.srt {stem}.json") + + def _final_failed(self, err): + self._to_idle() + self.status.setText("Final transcription failed") + QMessageBox.critical(self, "MeetRec", + f"Final transcription failed:\n\n{err}\n\n" + "The .wav recording is still saved.") + + # ------------------------------ UI tick -------------------------------- + + def _tick(self): + # drain live lines produced by the worker thread + try: + while True: + a, text = self.line_q.get_nowait() + self._append(f"[{fmt_hms(a)}] {text}\n") + except queue.Empty: + pass + + if self.state == "record" and self.rec is not None: + self.decay = max(self.rec.peak, self.decay * 0.70) + self.level.setValue(int(min(1.0, self.decay) * 100)) + self.elapsed.setText(fmt_hms(self.rec.duration())) + + # ------------------------------ close ---------------------------------- + + def closeEvent(self, ev): + if self.state in ("loading", "record", "finalizing"): + ret = QMessageBox.question( + self, "MeetRec", + "Stop and exit now?\n\n" + "The .wav file is kept, but the final transcription " + "will be aborted (if it hasn't finished).", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if ret != QMessageBox.Yes: + ev.ignore() + return + self.stop_evt.set() + if self.rec: + self.rec.stop() + if self.state == "finalizing" and self.worker: + self.worker.wait(5000) + elif self.state == "loading" and self.loader: + self.loader.wait(5000) + ev.accept() + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("MeetRec") + win = MeetRecWindow() + win.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3d74ff4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +numpy +sounddevice +faster-whisper +pyside6 diff --git a/share/applications/meetrec.desktop b/share/applications/meetrec.desktop new file mode 100644 index 0000000..bba3f05 --- /dev/null +++ b/share/applications/meetrec.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=MeetRec +GenericName=Meeting Recorder +Comment=Record in-person meetings and transcribe them with Whisper +Exec=meetrec +Icon=audio-microphone +Terminal=false +Categories=Audio; +Keywords=meeting;recording;recorder;transcription;transcript;whisper;audio;microphone; +StartupNotify=true