Initial commit: MeetRec — record & transcribe meetings with Whisper

- 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
This commit is contained in:
2026-09-07 09:38:32 +02:00
commit 18ce1c134c
9 changed files with 1143 additions and 0 deletions
+22
View File
@@ -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
+119
View File
@@ -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 `<sink name>.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.
Executable
+10
View File
@@ -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" "$@"
+10
View File
@@ -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" "$@"
Executable
+135
View File
@@ -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" - <<EOF || echo "warning: model pre-download failed — it will download on first use."
from faster_whisper import WhisperModel
WhisperModel("$MODEL", device="cpu", compute_type="int8")
EOF
fi
# ----------------------------------------------------------------- summary
echo
echo "Installed MeetRec to $PREFIX"
echo
echo " GUI: meetrec (also available from your app menu as 'MeetRec')"
echo " CLI: meetrec-cli --help"
echo " Uninstall: ./install.sh --uninstall"
echo
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
echo "note: $BIN_DIR is not in your PATH. Add it, e.g.:"
echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc"
fi
+357
View File
@@ -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 "
"\"<sink name>.monitor\". Match them with -s <part of name>.")
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()
+474
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
numpy
sounddevice
faster-whisper
pyside6
+12
View File
@@ -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