#!/usr/bin/env python3 """ Class recording watcher for Rocinante. Watches C:/Recordings (mounted as /recordings) for new .wav files. For each file detected: 1. Waits for the file to finish being written 2. Converts WAV -> MP3 via ffmpeg (deletes original WAV) 3. Splits long recordings into 10-minute chunks 4. Transcribes each chunk via local faster-whisper-server (large-v3, CUDA) 5. Stitches results back together with corrected timestamps 6. POSTs transcript + metadata to n8n /webhook/class/ingest Folder convention: subfolder name = class name /recordings/HIST-2020/lecture-03-3.wav -> class=HIST-2020, title=lecture 03 3 """ import os import time import subprocess import logging import requests from pathlib import Path from watchdog.observers.polling import PollingObserver as Observer from watchdog.events import FileSystemEventHandler # Config from environment WHISPER_URL = os.environ.get("WHISPER_URL", "http://whisper:8000") N8N_WEBHOOK = os.environ.get("N8N_WEBHOOK_URL", "https://n8n.paccoco.com/webhook/class/ingest") WATCH_DIR = os.environ.get("WATCH_DIR", "/recordings") WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "large-v3") CHUNK_MINUTES = int(os.environ.get("CHUNK_MINUTES", "10")) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) log = logging.getLogger("watcher") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def wait_for_file_stable(path: Path, interval: float = 2.0, required_stable: int = 3) -> None: """Block until the file size stops changing (i.e. the copy is complete).""" log.info(f"Waiting for {path.name} to finish writing...") prev_size = -1 stable_count = 0 while stable_count < required_stable: try: size = path.stat().st_size except FileNotFoundError: time.sleep(interval) continue if size == prev_size and size > 0: stable_count += 1 else: stable_count = 0 prev_size = size time.sleep(interval) log.info(f"{path.name} is stable ({prev_size:,} bytes)") # Extra check: verify the file handle is released and readable for attempt in range(60): try: with open(path, 'rb') as f: f.read(1024) break except (PermissionError, OSError) as e: log.info(f"File not yet readable ({e}), retrying... ({attempt + 1}/60)") time.sleep(5) else: raise RuntimeError(f"File {path.name} is not readable after 60 attempts") def convert_to_mp3(wav_path: Path) -> Path: mp3_path = wav_path.with_suffix(".mp3") log.info(f"Converting to MP3: {wav_path.name}") result = subprocess.run( [ "ffmpeg", "-y", "-i", str(wav_path), "-codec:a", "libmp3lame", "-qscale:a", "4", str(mp3_path), ], capture_output=True, text=True, ) if result.returncode != 0: raise RuntimeError(f"ffmpeg failed:\n{result.stderr}") wav_path.unlink() log.info(f"Deleted original WAV: {wav_path.name}") return mp3_path def get_duration(mp3_path: Path) -> float: """Return audio duration in seconds via ffprobe.""" result = subprocess.run( [ "ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", str(mp3_path), ], capture_output=True, text=True, ) return float(result.stdout.strip() or "0") def split_into_chunks(mp3_path: Path, chunk_minutes: int = CHUNK_MINUTES) -> list: """Split MP3 into chunks. Returns [mp3_path] if short enough.""" duration = get_duration(mp3_path) chunk_secs = chunk_minutes * 60 if duration <= chunk_secs: log.info(f"Recording is {duration/60:.1f}min — no splitting needed") return [mp3_path] total_chunks = int(duration / chunk_secs) + 1 log.info(f"Recording is {duration/60:.1f}min — splitting into {total_chunks} x {chunk_minutes}min chunks") chunks = [] start = 0.0 i = 0 while start < duration: chunk_path = mp3_path.parent / f"{mp3_path.stem}_chunk{i:03d}.mp3" subprocess.run( [ "ffmpeg", "-y", "-i", str(mp3_path), "-ss", str(start), "-t", str(chunk_secs), "-codec:a", "copy", str(chunk_path), ], capture_output=True, ) chunks.append((chunk_path, start)) start += chunk_secs i += 1 return chunks def transcribe_chunk(mp3_path: Path) -> dict: with open(mp3_path, "rb") as f: resp = requests.post( f"{WHISPER_URL}/v1/audio/transcriptions", files={"file": (mp3_path.name, f, "audio/mpeg")}, data={ "model": WHISPER_MODEL, "language": "en", "response_format": "verbose_json", }, timeout=3600, ) resp.raise_for_status() return resp.json() def transcribe(mp3_path: Path) -> dict: """Transcribe an MP3, splitting into chunks if needed.""" chunks = split_into_chunks(mp3_path) # Short recording — single chunk, no splitting if len(chunks) == 1 and isinstance(chunks[0], Path): log.info(f"Transcribing {mp3_path.name} with {WHISPER_MODEL}...") result = transcribe_chunk(mp3_path) result["segments"] = deduplicate_hallucinations(result.get("segments", [])) log.info("Transcription complete") return result # Long recording — transcribe each chunk and stitch all_segments = [] all_text = [] for i, (chunk_path, time_offset) in enumerate(chunks): log.info(f"Transcribing chunk {i+1}/{len(chunks)}: {chunk_path.name} (offset {time_offset/60:.1f}min)") try: result = transcribe_chunk(chunk_path) segments = result.get("segments", []) for s in segments: s["start"] = s.get("start", 0) + time_offset s["end"] = s.get("end", 0) + time_offset all_segments.extend(segments) all_text.append(result.get("text", "").strip()) except Exception as e: log.error(f"Chunk {i+1} failed: {e}") finally: chunk_path.unlink(missing_ok=True) all_segments = deduplicate_hallucinations(all_segments) total_duration = get_duration(mp3_path) log.info(f"Transcription complete — {len(chunks)} chunks stitched") return { "text": " ".join(all_text), "segments": all_segments, "duration": total_duration, } def is_silence(text: str) -> bool: """Return True if the segment is just silence/noise markers.""" stripped = text.strip() return not stripped or all(c in '.…♪ \t' for c in stripped) def deduplicate_hallucinations(segments: list, max_repeats: int = 3) -> list: """ Remove Whisper phrase-loop hallucinations. When Whisper gets stuck it emits the same segment text hundreds of times in a row (e.g. "I'm also a software engineer." × 200). This function collapses any run longer than `max_repeats` down to at most `max_repeats` occurrences and logs a warning so the truncation is visible in the logs. """ if not segments: return segments result = [] i = 0 while i < len(segments): text = segments[i].get("text", "").strip().lower() j = i + 1 while j < len(segments) and segments[j].get("text", "").strip().lower() == text: j += 1 count = j - i if count > max_repeats: log.warning( f"Hallucination detected: '{segments[i].get('text', '').strip()[:60]}'" f" × {count} — keeping {max_repeats}, dropping {count - max_repeats}" ) result.extend(segments[i : i + max_repeats]) else: result.extend(segments[i:j]) i = j return result def build_timestamped(segments: list) -> str: lines = [] for s in segments: text = s.get('text', '').strip() if is_silence(text): continue mins = int(s.get("start", 0) // 60) secs = int(s.get("start", 0) % 60) lines.append(f"[{mins}:{secs:02d}] {text}") return "\n".join(lines) def send_to_n8n(class_name: str, lecture_title: str, result: dict) -> None: segments = result.get("segments", []) # Filter silence from full text meaningful = [s.get("text", "") for s in segments if not is_silence(s.get("text", ""))] full_text = " ".join(meaningful).strip() or result.get("text", "").strip() duration = result.get("duration") or (segments[-1].get("end", 0) if segments else 0) payload = { "class_name": class_name, "lecture_title": lecture_title, "transcript": full_text, "timestamped_transcript": build_timestamped(segments), "duration_seconds": duration, } log.info(f"Sending to n8n: {class_name} / {lecture_title} ({len(full_text)} chars)") resp = requests.post(N8N_WEBHOOK, json=payload, timeout=300) resp.raise_for_status() log.info(f"n8n accepted — HTTP {resp.status_code}") # --------------------------------------------------------------------------- # Processing pipeline # --------------------------------------------------------------------------- def process_wav(wav_path: Path) -> None: # Must be inside a class subfolder, not the root recordings dir if wav_path.parent == Path(WATCH_DIR): log.warning(f"Skipping {wav_path.name} — drop files into a class subfolder, e.g. /recordings/HIST-2020/") return class_name = wav_path.parent.name lecture_title = wav_path.stem.replace("-", " ").replace("_", " ") log.info(f"--- Processing: {class_name} / {lecture_title} ---") wait_for_file_stable(wav_path) mp3_path = convert_to_mp3(wav_path) result = transcribe(mp3_path) send_to_n8n(class_name, lecture_title, result) log.info(f"--- Done: {class_name} / {lecture_title} ---") # --------------------------------------------------------------------------- # Watchdog handler # --------------------------------------------------------------------------- class WavHandler(FileSystemEventHandler): def on_created(self, event): if event.is_directory: return path = Path(event.src_path) if path.suffix.lower() == ".wav": log.info(f"Detected new WAV: {path}") try: process_wav(path) except Exception as exc: log.error(f"Failed to process {path}: {exc}", exc_info=True) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def scan_existing(watch_dir: Path) -> None: """Process any .wav files already present at startup.""" existing = list(watch_dir.rglob("*.wav")) + list(watch_dir.rglob("*.WAV")) if not existing: return log.info(f"Found {len(existing)} existing WAV(s) at startup — processing...") for wav in existing: try: process_wav(wav) except Exception as exc: log.error(f"Failed to process {wav}: {exc}", exc_info=True) if __name__ == "__main__": watch_dir = Path(WATCH_DIR) watch_dir.mkdir(parents=True, exist_ok=True) log.info(f"Whisper URL : {WHISPER_URL}") log.info(f"n8n webhook : {N8N_WEBHOOK}") log.info(f"Watching : {watch_dir} (recursive)") # Process any WAVs already sitting in the folder scan_existing(watch_dir) observer = Observer() observer.schedule(WavHandler(), str(watch_dir), recursive=True) observer.start() log.info("Watcher started — drop a .wav into a class subfolder to begin") try: while True: time.sleep(1) except KeyboardInterrupt: pass finally: observer.stop() observer.join() log.info("Watcher stopped")