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:
+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()
|
||||
Reference in New Issue
Block a user