# frozen_string_literal: true # Transcriber — the LOCAL transcription fallback: drives the WhisperX CLI (transcribe.py, shipped in # resources/whisper/; the running copy's directory is WHISPER_DIR) as a subprocess and maps its exit # codes to an Event state. Standard library only. Production uses the hosted Whisper instead # (remote_transcriber.rb, chosen when WHISPER_URL is set); this one runs when it isn't. # # Exit-code contract (from transcribe.py): # 0 ok · 2 bad args/token · 3 no ffmpeg · 5 HF terms not accepted · 6 alignment failed # 7 diarization failed · 130 Ctrl-C · (1/137/other = crash/OOM) # We treat {2,3,5,6} as terminal config errors (don't retry — alert the operator), 0 as success # (empty output = "no speech"), and everything else as retryable (transient I/O, OOM, timeout). require "open3" module Helpdesk class Transcriber CONFIG_ERRORS = [2, 3, 5, 6].freeze # terminal — re-running won't help def initialize(whisper_dir: ENV["WHISPER_DIR"] || "/home/lucy/whisper", python: nil, script: nil, env_file: nil, offline: true) @dir = whisper_dir @python = python || File.join(@dir, ".venv/bin/python") @script = script || File.join(@dir, "transcribe.py") @env_file = env_file || File.join(@dir, ".env") @offline = offline # use the 7 GB cached models, no network (won't fight the OS sync) end # → { state:, retryable:, exit_code:, transcript:, stderr:, reason? } # state ∈ transcribed | no_speech | config_error | failed def transcribe(audio, model: "large-v2", min_speakers: 2, max_speakers: 3, timeout: nil, output: nil) out = (output || "#{audio}.transcript.txt").to_s errlog = "#{out}.stderr" cmd = [@python, @script, audio.to_s, "-o", out, "--model", model, "--min-speakers", min_speakers.to_s, "--max-speakers", max_speakers.to_s] pid = Process.spawn(build_env, *cmd, pgroup: true, out: File::NULL, err: errlog) code = wait_with_timeout(pid, timeout) classify(code, out, errlog) end private def build_env env = {} if File.exist?(@env_file) File.foreach(@env_file) do |line| next unless (m = line.strip.match(/\A([A-Z_][A-Z0-9_]*)=(.*)\z/)) env[m[1]] = m[2].gsub(/\A["']|["']\z/, "") end end env["HF_HUB_OFFLINE"] = "1" if @offline env end def wait_with_timeout(pid, timeout) return Process.wait2(pid).last.exitstatus unless timeout deadline = mono + timeout loop do _, st = Process.wait2(pid, Process::WNOHANG) return st.exitstatus if st if mono > deadline (Process.kill("TERM", -Process.getpgid(pid)) rescue nil) (Process.wait(pid) rescue nil) return :timeout end sleep 0.25 end end def classify(code, out, errlog) stderr = File.exist?(errlog) ? File.read(errlog) : "" return fail_result(nil, stderr, "timeout") if code == :timeout case code when 0 text = File.exist?(out) ? File.read(out) : "" if text.strip.empty? { state: "no_speech", retryable: false, exit_code: 0, transcript: "", stderr: stderr } else { state: "transcribed", retryable: false, exit_code: 0, transcript: text, stderr: stderr } end when *CONFIG_ERRORS { state: "config_error", retryable: false, exit_code: code, transcript: nil, stderr: stderr } else fail_result(code, stderr, "exit #{code}") end end def fail_result(code, stderr, reason) { state: "failed", retryable: true, exit_code: code, transcript: nil, stderr: stderr, reason: reason } end def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC) end end # CLI: ruby transcriber.rb