#!/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, mono WAV at the input's native rate 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 Audio is recorded at the input device's native sample rate; both the live window and the engines get it resampled to Whisper's 16 kHz internally. 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 python meetrec.py --engine whisper-cpp -o meeting # GPU via Vulkan (AMD/NVIDIA) Ctrl+C to stop -> final transcripts are written. """ import argparse import json import os import queue import shutil import subprocess import sys import tempfile import threading import time import urllib.error import urllib.request import wave from pathlib import Path from types import SimpleNamespace import numpy as np import sounddevice as sd 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 resample_16k(audio: np.ndarray, rate: int) -> np.ndarray: """Linearly resample mono float32 audio to 16 kHz (Whisper's rate). Only needed for the live rolling buffer, which is fed to the engines as a raw array (they assume 16 kHz there). WAV files are resampled by the engines themselves. """ audio = np.asarray(audio, dtype=np.float32) if rate == SR or audio.size == 0: return audio n_out = max(1, int(round(audio.size * SR / rate))) t_out = np.arange(n_out, dtype=np.float64) * (rate / SR) return np.interp(t_out, np.arange(audio.size, dtype=np.float64), audio).astype(np.float32) 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 float32 at the input's native rate to a WAV file and keeps a rolling buffer for live transcription.""" def __init__(self, device, wav_path: str): self.device = device self.wav_path = wav_path dev = sd.query_devices(kind="input") if device is None \ else sd.query_devices(device) self.rate = int(dev["default_samplerate"]) # input's native rate 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 * self.rate 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 / self.rate def _write(self): with wave.open(self.wav_path, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(self.rate) 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 window resampled to 16 kHz, its start time in s).""" with self.lock: return resample_16k(self.buf, self.rate), self.base / self.rate def start(self): self.stream = sd.InputStream( samplerate=self.rate, channels=1, dtype="float32", device=self.device, callback=self._callback) self.stream.start() def stop(self): if self.stop_event.is_set(): return # idempotent: the GUI close path may call this twice 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_s = rec.snapshot() # 16 kHz audio, absolute start time 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 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, diarize_url=None, agenda=None, library_url=None, device_name="desktop"): """Full-pass transcription of the whole recording, with optional speaker labeling (tinydiarize server) and library upload. on_info(msg), on_segment(start, end, text) and on_done() are optional callbacks; when absent the same progress is printed to stdout. Returns (items, info): items are (start, end, text) tuples of the final — possibly speaker-labeled — transcript.""" def info(msg): if on_info is not None: on_info(msg) else: print(msg, flush=True) def emit(a, b, text): if on_segment is not None: on_segment(a, b, text) else: print(f"[{fmt_hms(a)}] {text}", 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 = [] if diarize_url: for s in segments: # collect first: labels need the whole pass items.append((s.start, s.end, s.text.strip())) info("Running speaker-diarization pass (tinydiarize server)...") try: tdrz = WhisperServerEngine(diarize_url, diarize=True) turns = tdrz.transcribe(wav_path, language=language) labeled = diarize_merge( [SimpleNamespace(start=a, end=b, text=t) for a, b, t in items], turns) items = [(s.start, s.end, s.text) for s in labeled] except Exception as e: info(f"diarize pass failed (continuing unlabeled): {e}") for a, b, text in items: emit(a, b, text) else: for s in segments: # generator: streams as it decodes text = s.text.strip() items.append((s.start, s.end, text)) emit(s.start, s.end, text) 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 library_url: info("Uploading recording to the library...") try: rid = upload_recording( library_url, wav_path, out_stem, info_.duration, getattr(info_, "language", None), device_name, agenda or []) info(f"Uploaded to library as {rid} — summary and agenda check " "follow automatically on the server.") except Exception as e: info(f"upload failed: {e}") if on_done is not None: on_done() return items, info_ # -------------------------------------------------------------------------- # Engines # -------------------------------------------------------------------------- # # Two interchangeable transcription backends: # faster-whisper CTranslate2 — NVIDIA CUDA or CPU (default) # whisper-cpp whisper.cpp CLI — GPU via Vulkan (AMD, Intel, NVIDIA) # or CPU; preferred on AMD hardware (e.g. Ryzen AI laptops) # whisper-server remote whisper.cpp HTTP server (server/whisper-server) — # the model lives on the server; ideal for phones/laptops # # Both expose: transcribe(audio, beam_size, vad_filter, language, # vad_parameters) -> (segments, info) # where `audio` is a 1-D float32 @16 kHz array or a WAV path, segments have # .start/.end/.text and info has .language/.language_probability/.duration. WHISPER_CPP_MODEL_FILES = { "tiny": "ggml-tiny.bin", "base": "ggml-base.bin", "small": "ggml-small.bin", "medium": "ggml-medium.bin", "large-v3": "ggml-large-v3.bin", } WHISPER_CPP_MODEL_URL = ("https://huggingface.co/ggerganov/whisper.cpp/" "resolve/main/{name}") WHISPER_CPP_VAD_FILE = "ggml-silero-v6.2.0.bin" WHISPER_CPP_VAD_URL = ("https://huggingface.co/ggml-org/whisper-vad/" "resolve/main/" + WHISPER_CPP_VAD_FILE) def _download_file(url: str, dest: Path) -> None: tmp = dest.with_suffix(dest.suffix + ".part") req = urllib.request.Request(url, headers={"User-Agent": "meetrec"}) with urllib.request.urlopen(req, timeout=60) as r: total = int(r.headers.get("Content-Length") or 0) done = 0 with open(tmp, "wb") as f: while True: chunk = r.read(1024 * 1024) if not chunk: break f.write(chunk) done += len(chunk) if total: print(f"\r {done/1e6:.0f}/{total/1e6:.0f} MB", end="", flush=True) if total: print(flush=True) os.replace(tmp, dest) def find_whisper_cpp_bin() -> str: """Locate the whisper.cpp 'whisper-cli' binary.""" candidates = [ os.environ.get("WHISPER_CPP_BIN"), shutil.which("whisper-cli"), str(Path.home() / ".local/share/meetrec/whisper-cpp/bin/whisper-cli"), ] for c in candidates: if c and os.path.isfile(c) and os.access(c, os.X_OK): return c raise RuntimeError( "whisper-cli not found. Build whisper.cpp (e.g. './install.sh " "--whisper-cpp', or cmake -DWHISPER_VULKAN=ON) or set WHISPER_CPP_BIN " "to the binary.") def _whisper_cpp_cache() -> Path: cache = Path(os.environ.get( "MEETREC_CACHE", str(Path.home() / ".cache/meetrec"))) d = cache / "whisper-cpp" d.mkdir(parents=True, exist_ok=True) return d def _cached_ggml(fname: str, url: str) -> str: """Return the path of a cached GGML file, downloading it if missing.""" p = _whisper_cpp_cache() / fname if p.exists() and p.stat().st_size > 0: return str(p) print(f"downloading {fname} to {p} ...", flush=True) _download_file(url, p) return str(p) def whisper_cpp_model_path(model_name: str) -> str: """Return the local path of a whisper.cpp GGML model, downloading it.""" try: fname = WHISPER_CPP_MODEL_FILES[model_name] except KeyError: raise RuntimeError( f"unknown model {model_name!r}; use one of: " f"{', '.join(WHISPER_CPP_MODEL_FILES)}") return _cached_ggml(fname, WHISPER_CPP_MODEL_URL.format(name=fname)) def write_wav_float(path: str, audio: np.ndarray) -> None: """Write a 1-D float32 mono @16 kHz array as 16-bit PCM WAV.""" audio = np.asarray(audio, dtype=np.float32).ravel() pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16) with wave.open(path, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(SR) w.writeframes(pcm.tobytes()) def _parse_ts(s: str) -> float: """whisper.cpp timestamp 'HH:MM:SS,mmm' -> seconds.""" h, m, rest = s.split(":") sec, ms = rest.split(",") return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms) / 1000.0 class WhisperCppEngine: """Transcription via the whisper.cpp 'whisper-cli' binary. Build whisper.cpp with -DWHISPER_VULKAN=ON to run on a GPU (Radeon, Intel, or NVIDIA); it falls back to CPU otherwise. GPU vs. CPU is decided by the build, so --device/--compute-type do not apply. vad_filter=True enables whisper.cpp's Silero VAD (auto-downloaded); vad_parameters are accepted but not mapped. """ def __init__(self, model_name: str, cli: str = None): self.model_name = model_name self.cli = cli or find_whisper_cpp_bin() self.model_path = whisper_cpp_model_path(model_name) def transcribe(self, audio, beam_size=5, vad_filter=False, language=None, vad_parameters=None): # vad_parameters accepted for interface compatibility; Silero VAD uses # whisper.cpp's own defaults with tempfile.TemporaryDirectory(prefix="meetrec-") as td: if isinstance(audio, (str, os.PathLike)): wav = str(audio) with wave.open(wav, "rb") as w: duration = w.getnframes() / w.getframerate() else: wav = os.path.join(td, "in.wav") write_wav_float(wav, audio) duration = len(audio) / SR cmd = [self.cli, "-m", self.model_path, "-f", wav, "-oj", "-of", os.path.join(td, "out"), "-bs", str(max(1, int(beam_size))), "-np", "-l", language or "auto"] if vad_filter: cmd += ["--vad", "-vm", _cached_ggml( WHISPER_CPP_VAD_FILE, WHISPER_CPP_VAD_URL)] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError( (proc.stderr or proc.stdout).strip() or f"whisper-cli exited {proc.returncode}") with open(os.path.join(td, "out.json")) as f: data = json.load(f) result = data.get("result", {}) segs = [] for item in data.get("transcription", []): off = item.get("offsets", {}) if "from" in off and "to" in off: # milliseconds (current) a, b = off["from"] / 1000.0, off["to"] / 1000.0 else: # 'HH:MM:SS,mmm' (older) ts = item.get("timestamps", {}) a = _parse_ts(ts.get("from", "00:00:00,000")) b = _parse_ts(ts.get("to", "00:00:00,000")) text = item.get("text", "").strip() if text: segs.append(SimpleNamespace(start=a, end=b, text=text)) info = SimpleNamespace( language=result.get("language") or language or "unknown", language_probability=1.0, # not reported by whisper.cpp duration=duration, ) return segs, info MULTIPART_BOUNDARY = "meetrec-9f3e1c7a5b2d" def _multipart_body(fields: dict, files) -> bytes: """Build a multipart/form-data body with stdlib only. files: list of (field_name, filename, file_obj) tuples.""" parts = [] for name, value in fields.items(): parts.append( f"--{MULTIPART_BOUNDARY}\r\n" f'Content-Disposition: form-data; name="{name}"\r\n\r\n' f"{value}\r\n".encode()) for name, fname, fh in files: parts.append( f"--{MULTIPART_BOUNDARY}\r\n" f'Content-Disposition: form-data; name="{name}"; ' f'filename="{fname}"\r\n' f"Content-Type: application/octet-stream\r\n\r\n".encode()) parts.append(fh.read()) parts.append(b"\r\n") parts.append(f"--{MULTIPART_BOUNDARY}--\r\n".encode()) return b"".join(parts) class WhisperServerEngine: """Transcription via a remote whisper.cpp server (POST /inference). Use the Docker Compose stack in server/whisper-server/ to run one (Vulkan GPU on AMD, or CPU). The model lives on the server, so --model / --device / --compute-type do not apply, and beam size is a server-start setting in whisper.cpp v1.9.3 (no per-request override) — beam_size is accepted for interface compatibility but ignored. vad_filter/vad_parameters are likewise accepted but unused; the server runs its own pipeline. Note: verbose_json reports language names like "german" rather than ISO codes. With diarize=True (a tinydiarize server, see whisper-server-tdrz) segments carry .speaker_turn_next and their text is NOT used for labeling — use diarize_merge() for that. """ def __init__(self, server_url: str, diarize: bool = False): self.server_url = server_url.rstrip("/") self.diarize = diarize self.model_name = None # the server owns the model def transcribe(self, audio, beam_size=5, vad_filter=False, language=None, vad_parameters=None, timeout=600): with tempfile.TemporaryDirectory(prefix="meetrec-") as td: if isinstance(audio, (str, os.PathLike)): wav = str(audio) # server resamples to 16 kHz itself else: wav = os.path.join(td, "in.wav") write_wav_float(wav, audio) files = [("file", "audio.wav", open(wav, "rb"))] fields = {"response_format": "verbose_json", "language": language or "auto"} if self.diarize: fields["tinydiarize"] = "true" try: body = _multipart_body(fields, files) finally: files[0][2].close() req = urllib.request.Request( self.server_url + "/inference", data=body, headers={"Content-Type": "multipart/form-data; boundary=" + MULTIPART_BOUNDARY}) try: with urllib.request.urlopen(req, timeout=timeout) as r: data = json.load(r) except urllib.error.HTTPError as e: raise RuntimeError(f"server returned {e.code}: " f"{e.read()[:300]!r}") from None except urllib.error.URLError as e: raise RuntimeError(f"cannot reach whisper server at " f"{self.server_url}: {e.reason}") from None segs = [SimpleNamespace(start=s["start"], end=s["end"], text=s["text"].strip(), speaker_turn_next=bool(s.get("speaker_turn_next", False))) for s in data.get("segments", []) if s.get("text", "").strip()] info = SimpleNamespace( language=data.get("language") or language or "unknown", language_probability=1.0, # not reported by verbose_json duration=data.get("duration", 0.0), ) return segs, info def diarize_merge(segments, turn_segments): """Label segments with alternating "Sprecher 1/2:" using the TURN TIMES of a tinydiarize pass; segments in which a turn falls are split. Input is returned unchanged when no turns were detected (never mislabels). Mirrors the Android app's Diarization object.""" turns = sorted(t.end for t in turn_segments if getattr(t, "speaker_turn_next", False)) if not turns: return list(segments) def seg(a, b, text): return SimpleNamespace(start=a, end=b, text=text) speaker, ti = 1, 0 out = [] for s in segments: while ti < len(turns) and turns[ti] <= s.start: speaker = 3 - speaker ti += 1 start, text = s.start, s.text while ti < len(turns) and turns[ti] < s.end: t = turns[ti] frac = (t - s.start) / max(s.end - s.start, 1e-9) cut = int(len(text) * frac) out.append(seg(start, t, f"Sprecher {speaker}: {text[:cut].strip()}")) speaker = 3 - speaker ti += 1 start, text = t, text[cut:] out.append(seg(start, s.end, f"Sprecher {speaker}: {text.strip()}")) return out def upload_recording(library_url: str, wav_path: str, out_stem: str, duration: float, language, device: str, agenda) -> str: """Upload a recording bundle to the meetrec-server library; returns the new recording id. The server then generates the summary and agenda coverage automatically (Ollama).""" files = [] for ext in ("wav", "txt", "srt", "json"): p = wav_path if ext == "wav" else out_stem + "." + ext if os.path.isfile(p): files.append((ext, "meeting." + ext, open(p, "rb"))) started = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(wav_path))) fields = { "started_at": started, "duration_ms": int(duration * 1000), "language": language or "", "device": device, "agenda": json.dumps(list(agenda or []), ensure_ascii=False), } try: body = _multipart_body(fields, files) finally: for _, _, fh in files: fh.close() req = urllib.request.Request( library_url.rstrip("/") + "/api/recordings", data=body, method="POST", headers={"Content-Type": "multipart/form-data; boundary=" + MULTIPART_BOUNDARY}) try: with urllib.request.urlopen(req, timeout=300) as r: return json.load(r)["id"] except urllib.error.HTTPError as e: raise RuntimeError(f"library upload failed: {e.code} " f"{e.read()[:200]!r}") from None except urllib.error.URLError as e: raise RuntimeError(f"cannot reach library at {library_url}: " f"{e.reason}") from None def library_list(library_url: str): """List the recording library (newest first); dicts match the meetrec-server index (id, started_at, duration_ms, language, device, agenda, files, summary, agenda_status, agenda_results).""" url = library_url.rstrip("/") + "/api/recordings" try: with urllib.request.urlopen(url, timeout=15) as r: return json.load(r) except urllib.error.URLError as e: raise RuntimeError(f"cannot reach library at {library_url}: {e.reason}") from None def library_get_file(library_url: str, rid: str, name: str) -> str: """Fetch one stored file (meeting.txt/json, summary.md, agenda.json).""" url = library_url.rstrip("/") + f"/api/recordings/{rid}/files/{name}" try: with urllib.request.urlopen(url, timeout=60) as r: return r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: raise RuntimeError(f"library returned {e.code} for {name}") from None except urllib.error.URLError as e: raise RuntimeError(f"cannot reach library at {library_url}: {e.reason}") from None def library_trigger_summary(library_url: str, rid: str, force: bool = False) -> None: """Ask the server to (re)generate the summary via Ollama.""" url = (library_url.rstrip("/") + f"/api/recordings/{rid}/summary" + ("?force=true" if force else "")) req = urllib.request.Request(url, data=b"", method="POST") try: with urllib.request.urlopen(req, timeout=30) as r: r.read() except urllib.error.URLError as e: raise RuntimeError(f"cannot reach library at {library_url}: {e.reason}") from None def load_engine(model_name, engine="faster-whisper", device="auto", compute_type="int8", server_url=None): """Create a transcription engine. See the Engines section above.""" if engine == "whisper-cpp": return WhisperCppEngine(model_name) if engine == "whisper-server": url = server_url or os.environ.get("MEETREC_SERVER_URL") if not url: raise RuntimeError("no server URL: pass --server-url or set " "MEETREC_SERVER_URL (e.g. http://100.103.83.12:8085)") return WhisperServerEngine(url) if engine == "faster-whisper": from faster_whisper import WhisperModel return WhisperModel(model_name, device=device, compute_type=compute_type) raise RuntimeError(f"unknown engine {engine!r}; use 'faster-whisper' " "or 'whisper-cpp'") # -------------------------------------------------------------------------- # 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("--engine", default="faster-whisper", choices=["faster-whisper", "whisper-cpp", "whisper-server"], help="transcription backend (default: faster-whisper). " "whisper-cpp can use a GPU via Vulkan; whisper-server " "transcribes on a remote whisper.cpp server") ap.add_argument("--server-url", default=None, help="whisper-server base URL, e.g. http://100.103.83.12:8085 " "(or set MEETREC_SERVER_URL); whisper-server engine only") ap.add_argument("--device", default="auto", help="compute device: auto / cpu / cuda (default: auto); " "faster-whisper only") ap.add_argument("--compute-type", default="int8", help="int8 / int8_float16 / float16 / float32 " "(default: int8); faster-whisper only") ap.add_argument("--list-sources", action="store_true", help="list available input devices and exit") ap.add_argument("--agenda-file", default=None, metavar="PATH", help="text file with agenda items, one per line — " "uploaded with the recording for the coverage " "check (see --library-url)") ap.add_argument("--diarize-url", default=os.environ.get("MEETREC_DIARIZE_URL"), help="tinydiarize whisper-server URL for speaker labels " "(Sprecher 1/2), e.g. http://100.103.83.12:8086 " "(or set MEETREC_DIARIZE_URL)") ap.add_argument("--library-url", default=os.environ.get("MEETREC_LIBRARY_URL"), help="meetrec-server URL; uploads the recording after " "transcription and triggers summary + agenda check " "on the server (or set MEETREC_LIBRARY_URL)") args = ap.parse_args() if args.list_sources: list_sources() return device = resolve_source(args.source) if args.engine == "whisper-server": print(f"using whisper server at " f"{args.server_url or os.environ.get('MEETREC_SERVER_URL')} " f"(model lives on the server)") else: print(f"loading Whisper model {args.model!r} via {args.engine!r} " f"(first run downloads it to ~/.cache)...") try: model = load_engine(args.model, engine=args.engine, device=args.device, compute_type=args.compute_type, server_url=args.server_url) except Exception as e: print(f"error: {e}", file=sys.stderr) sys.exit(1) rec = Recorder(device, args.output + ".wav") rec.start() print(f"recording to {args.output}.wav at {rec.rate} Hz " f"(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) agenda = [] if args.agenda_file: with open(args.agenda_file) as f: agenda = [ln.strip() for ln in f if ln.strip()] final_transcribe(model, args.output + ".wav", args.output, args.language, diarize_url=args.diarize_url, agenda=agenda, library_url=args.library_url) if __name__ == "__main__": main()