import {spawn, type ChildProcess} from 'node:child_process' import * as fs from 'node:fs' import * as path from 'node:path' import {mergeTurns, renderJson, renderMarkdown, type Item} from './render.js' import type {WavWriter} from './wav.js' // Live transcription during recording (the record command's --transcribe). // // A single persistent python process loads faster-whisper ONCE and then // serves transcription requests over a line-delimited JSON stdin/stdout // protocol; each request names a WAV file and a frame range, which the helper // reads raw from disk (only flushed bytes are ever requested, so reading a // file that is still being appended to is safe — the header is bypassed). // // The recorder feeds every decoded audio frame through onAudio, which tracks // silence so chunks can be cut at natural pauses (>= 300 ms below the RMS // threshold) — never mid-word. Every tick, each segment with >= MIN_CHUNK of // speech up to a silence cut is dispatched (or force-cut at MAX_CHUNK of // unbroken speech). transcript.md / transcript.json are re-rendered after // every result, so the transcript grows while the meeting is happening. // // If the helper dies, recording is NEVER affected: live transcription // disables itself with a log line, and `transcribe` can be run afterwards. // Tuned for ~8 s from end-of-utterance to text on screen: pause detect // (~0.3 s) + WAV flush (~0.5 s avg) + tick (~1.5 s avg) + ASR (1-4 s with // `small` on CPU) + render debounce (0.5 s). Smaller chunks trade a little // per-chunk context/efficiency for latency; turn merging glues the text back. const QUIET_RMS = 300 // ~ -41 dBFS: below this a 10 ms frame counts as quiet const QUIET_CUT_MS = 300 // this much consecutive quiet = a safe cut point const TICK_MS = 3000 const MIN_CHUNK_SEC = 2 // don't bother the model with less than this const MAX_CHUNK_SEC = 30 // force a cut after this much unbroken speech const FINISH_TIMEOUT_MS = 180000 interface AsrSegment { start: number end: number text: string } const LIVE_PY = ` import sys, json import numpy as np from faster_whisper import WhisperModel model_name = sys.argv[1] language = None if sys.argv[2] == "-" else sys.argv[2] model = WhisperModel(model_name, device="auto", compute_type="auto") print(json.dumps({"ready": True}), flush=True) while True: # readline, not iteration: iterating sys.stdin read-ahead-buffers and can # sit on a complete line without yielding it line = sys.stdin.readline() if not line: break req = json.loads(line) ch = req["channels"] with open(req["wav"], "rb") as f: f.seek(44 + req["startFrame"] * ch * 2) raw = f.read((req["endFrame"] - req["startFrame"]) * ch * 2) a = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 if ch > 1: a = a.reshape(-1, ch).mean(axis=1) rate = req["rate"] if rate != 16000: if rate % 16000 == 0: k = rate // 16000 n = (len(a) // k) * k a = a[:n].reshape(-1, k).mean(axis=1) else: xi = np.arange(0, len(a), rate / 16000.0) a = np.interp(xi, np.arange(len(a)), a) segs, info = model.transcribe(a.astype(np.float32), language=language, vad_filter=True) out = [{"start": s.start, "end": s.end, "text": s.text} for s in segs] print(json.dumps({"id": req["id"], "segments": out}), flush=True) ` interface AsrRequest { wav: string rate: number channels: number startFrame: number endFrame: number } /** The persistent faster-whisper helper process. */ class PythonAsr { private proc: ChildProcess private pending = new Map< number, {resolve: (segs: AsrSegment[]) => void; reject: (err: Error) => void} >() private nextId = 1 private buf = '' private readyResolve!: () => void private readyReject!: (err: Error) => void readonly ready: Promise dead = false constructor(model: string, language: string | null, onLog: (l: string) => void) { this.ready = new Promise((resolve, reject) => { this.readyResolve = resolve this.readyReject = reject }) // NB: `python3 -c