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:
+22
@@ -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
|
||||||
@@ -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
@@ -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" "$@"
|
||||||
Executable
+10
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
numpy
|
||||||
|
sounddevice
|
||||||
|
faster-whisper
|
||||||
|
pyside6
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user