diff options
| -rw-r--r-- | modules/transcription-config.el | 22 | ||||
| -rwxr-xr-x | scripts/ratio-transcribe | 203 | ||||
| -rw-r--r-- | tests/test-transcription-config--ratio-backend.el | 102 |
3 files changed, 321 insertions, 6 deletions
diff --git a/modules/transcription-config.el b/modules/transcription-config.el index 944063b8..6d7e39b9 100644 --- a/modules/transcription-config.el +++ b/modules/transcription-config.el @@ -33,6 +33,11 @@ ;; - 'assemblyai: Cloud transcription with speaker diarization ;; API key retrieved from authinfo.gpg (machine api.assemblyai.com) ;; - 'local-whisper: Local transcription (requires whisper installed) +;; - 'ratio: Self-hosted whisper plus speaker diarization on my transcription +;; host, via scripts/ratio-transcribe (ssh queue; falls back to a local +;; worker when the host is unreachable). No API key. SPEAKERS=N in the +;; environment pins the diarizer's speaker count; 1 is right for a +;; recording that holds only one side of a call. ;; ;; NOTIFICATIONS: ;; - "Transcription started on <file>" @@ -56,7 +61,8 @@ "Transcription backend to use. - `openai-api': Fast cloud transcription via OpenAI API - `assemblyai': Cloud transcription with speaker diarization via AssemblyAI -- `local-whisper': Local transcription using installed Whisper") +- `local-whisper': Local transcription using installed Whisper +- `ratio': Self-hosted whisper plus diarization on my transcription host") (defvar cj/transcription-keep-log-when-done nil "Whether to keep log files after successful transcription. @@ -74,9 +80,11 @@ Status: running, complete, error") (defconst cj/--transcription-backends '((openai-api :script "oai-transcribe" :auth-host "api.openai.com" :env-var "OPENAI_API_KEY") (assemblyai :script "assemblyai-transcribe" :auth-host "api.assemblyai.com" :env-var "ASSEMBLYAI_API_KEY") - (local-whisper :script "local-whisper" :auth-host nil :env-var nil)) + (local-whisper :script "local-whisper" :auth-host nil :env-var nil) + (ratio :script "ratio-transcribe" :auth-host nil :env-var nil)) "Per-backend descriptors. Each entry: (SYMBOL :script S :auth-host H :env-var V). -`:auth-host' and `:env-var' are nil for local backends that need no API key.") +`:auth-host' and `:env-var' are nil for backends that need no API key: the +local whisper install, and the self-hosted ratio queue reached over ssh.") (defun cj/--backend-plist (backend) "Return the descriptor plist for BACKEND, or signal if unknown." @@ -470,9 +478,11 @@ duration is computed from START-TIME." "Switch transcription backend. Prompts with completing-read to select from available backends." (interactive) - (let* ((backends '(("assemblyai" . assemblyai) - ("openai-api" . openai-api) - ("local-whisper" . local-whisper))) + ;; Offer exactly the descriptor set. This used to carry its own list, so + ;; a backend added to the descriptors was reachable only by setq. + (let* ((backends (mapcar (lambda (entry) + (cons (symbol-name (car entry)) (car entry))) + cj/--transcription-backends)) (current (symbol-name cj/transcribe-backend)) (prompt (format "Transcription backend (current: %s): " current)) (choice (completing-read prompt backends nil t)) diff --git a/scripts/ratio-transcribe b/scripts/ratio-transcribe new file mode 100755 index 00000000..8db59b66 --- /dev/null +++ b/scripts/ratio-transcribe @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# ratio-transcribe - Transcribe audio on my own transcription host, with speaker labels +# Usage: ratio-transcribe <audio-file> [language] +# +# Same contract as assemblyai-transcribe: the transcript goes to stdout, one line +# per speaker turn ("HH:MM:SS Speaker A: text"); progress and errors go to stderr; +# any failure exits non-zero with nothing on stdout. +# +# The work happens on a host that runs the meeting-transcribe queue (whisper-cpp +# plus pyannote). This script copies the audio over ssh, drops a job into the +# queue, waits, and prints the result. The job id is a hash of the audio and its +# options, so if the connection drops or the laptop sleeps, running the same +# command again just collects the finished transcript. If the host can't be +# reached at all, the same queue and worker run on this machine instead. +# +# Optional environment: +# SPEAKERS exact number of speakers, when you know it +# MIN_SPEAKERS, MAX_SPEAKERS a range instead +# TRANSCRIBE_HOST ssh name of the host (default: ratio) +# TRANSCRIBE_TIMEOUT seconds to wait for the job (default: 3600) +# TRANSCRIBE_POLL seconds between checks (default: 10) +# TRANSCRIBE_LOCAL=1 skip the host and run here +# TRANSCRIBE_WORKER path to the local worker + +set -euo pipefail + +AUDIO="${1:-}" +LANG_CODE="${2:-en}" +HOST="${TRANSCRIBE_HOST:-ratio}" +TIMEOUT="${TRANSCRIBE_TIMEOUT:-3600}" +POLL="${TRANSCRIBE_POLL:-10}" +WORKER="${TRANSCRIBE_WORKER:-$HOME/.local/share/pyannote-diarize/src/transcribe-worker}" +STATE=".local/state/meeting-transcribe" # relative to the home directory, on either machine + +if [[ -z "$AUDIO" ]]; then + echo "Usage: ratio-transcribe <audio-file> [language]" >&2 + echo "Example: SPEAKERS=3 ratio-transcribe meeting.m4a en" >&2 + exit 1 +fi + +if [[ ! -f "$AUDIO" ]]; then + echo "Error: Audio file not found: $AUDIO" >&2 + exit 1 +fi +# scp reads "name:with:colons" as host:path; an absolute path removes the ambiguity. +AUDIO="$(realpath -- "$AUDIO")" + +# Everything below ends up in a job file and on command lines, so check it first. +if [[ ! "$LANG_CODE" =~ ^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$ ]]; then + echo "Error: Invalid language code: $LANG_CODE" >&2 + exit 1 +fi + +for name in SPEAKERS MIN_SPEAKERS MAX_SPEAKERS; do + value="${!name:-}" + if [[ -n "$value" && ! "$value" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: $name must be a positive whole number of speakers, got: $value" >&2 + exit 1 + fi +done +if [[ -n "${SPEAKERS:-}" && ( -n "${MIN_SPEAKERS:-}" || -n "${MAX_SPEAKERS:-}" ) ]]; then + echo "Error: give an exact SPEAKERS count or a MIN/MAX speaker range, not both" >&2 + exit 1 +fi +if [[ -n "${MIN_SPEAKERS:-}" && -n "${MAX_SPEAKERS:-}" ]] && (( MIN_SPEAKERS > MAX_SPEAKERS )); then + echo "Error: MIN_SPEAKERS cannot exceed MAX_SPEAKERS (speaker range)" >&2 + exit 1 +fi + +for tool in jq sha256sum; do + if ! command -v "$tool" &> /dev/null; then + echo "Error: $tool command not found" >&2 + exit 1 + fi +done + +EXT="${AUDIO##*.}" +[[ "$EXT" =~ ^[A-Za-z0-9]{1,5}$ ]] || EXT="bin" +EXT="${EXT,,}" + +if [[ -n "${SPEAKERS:-}" ]]; then + COUNT_TAG="s${SPEAKERS}" +elif [[ -n "${MIN_SPEAKERS:-}${MAX_SPEAKERS:-}" ]]; then + COUNT_TAG="r${MIN_SPEAKERS:-x}-${MAX_SPEAKERS:-x}" +else + COUNT_TAG="auto" +fi +JOB_ID="$(sha256sum "$AUDIO" | cut -c1-16)-${LANG_CODE,,}-${COUNT_TAG}" + +JOB_JSON=$(jq -cn \ + --arg language "$LANG_CODE" \ + --arg name "$(basename "$AUDIO")" \ + --arg speakers "${SPEAKERS:-}" --arg min "${MIN_SPEAKERS:-}" --arg max "${MAX_SPEAKERS:-}" \ + '{language: $language} + + (if $speakers != "" then {speakers: ($speakers | tonumber)} else {} end) + + (if $min != "" then {min_speakers: ($min | tonumber)} else {} end) + + (if $max != "" then {max_speakers: ($max | tonumber)} else {} end) + + {original_name: $name}') + +# ssh reads stdin unless told not to, which would swallow the input of any loop +# this script is called from. Only the job-file upload needs stdin. +remote() { ssh -n -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; } +remote_with_stdin() { ssh -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; } + +# One word for where the job stands on the host: done, failed, queued or new. +remote_status() { + remote "cd $STATE 2>/dev/null || { echo new; exit 0; } + if [ -e done/$JOB_ID.txt ]; then echo done + elif [ -e failed/$JOB_ID.log ]; then echo failed + elif [ -d incoming/$JOB_ID ] || [ -d work/$JOB_ID ]; then echo queued + else echo new; fi" +} + +print_transcript() { # $1 = the transcript text + if [[ -z "${1//[[:space:]]/}" ]]; then + echo "Error: the transcript came back empty" >&2 + exit 1 + fi + echo "Transcription complete! (${SECONDS}s total)" >&2 + printf '%s\n' "$1" +} + +run_remote() { + local status + status=$(remote_status) + + if [[ "$status" == "failed" ]]; then + echo "An earlier attempt at this job failed; trying again..." >&2 + remote "rm -f $STATE/failed/$JOB_ID.log" + status="new" + fi + + if [[ "$status" == "new" ]]; then + echo "Uploading audio file to $HOST..." >&2 + # Copy into uploading/, then rename into incoming/. The queue only ever sees + # a complete job. + remote "mkdir -p $STATE/incoming $STATE/uploading/$JOB_ID" + scp -q -o BatchMode=yes "$AUDIO" "$HOST:$STATE/uploading/$JOB_ID/audio.$EXT" < /dev/null + printf '%s' "$JOB_JSON" | remote_with_stdin "cat > $STATE/uploading/$JOB_ID/job.json" + remote "mv $STATE/uploading/$JOB_ID $STATE/incoming/$JOB_ID" + echo "Job $JOB_ID queued. Waiting for completion..." >&2 + elif [[ "$status" == "queued" ]]; then + echo "Job $JOB_ID is already queued on $HOST. Waiting for completion..." >&2 + fi + + while true; do + # A dropped connection is not a failed job; keep asking until the timeout. + status=$(remote_status 2> /dev/null) || status="unreachable" + case "$status" in + done) + print_transcript "$(remote "cat $STATE/done/$JOB_ID.txt")" + return 0 + ;; + failed) + echo "Error: transcription failed on $HOST" >&2 + remote "cat $STATE/failed/$JOB_ID.log" >&2 || true + exit 1 + ;; + esac + if (( SECONDS >= TIMEOUT )); then + echo "Error: no result after ${TIMEOUT}s. The job is still with $HOST;" >&2 + echo "run the same command again to collect the transcript." >&2 + exit 1 + fi + sleep "$POLL" + [[ "$status" == "unreachable" ]] || echo "Processing... (${SECONDS}s elapsed)" >&2 + done +} + +run_local() { + if [[ ! -x "$WORKER" ]]; then + echo "Error: $HOST is unreachable and there is no local worker at $WORKER" >&2 + exit 1 + fi + local state="$HOME/$STATE" + if [[ ! -s "$state/done/$JOB_ID.txt" ]]; then + echo "Running the transcription locally (this machine is slower; expect a wait)..." >&2 + rm -f "$state/failed/$JOB_ID.log" + rm -rf "$state/uploading/$JOB_ID" + mkdir -p "$state/incoming" "$state/uploading/$JOB_ID" + cp "$AUDIO" "$state/uploading/$JOB_ID/audio.$EXT" + printf '%s' "$JOB_JSON" > "$state/uploading/$JOB_ID/job.json" + [[ -d "$state/incoming/$JOB_ID" ]] || mv "$state/uploading/$JOB_ID" "$state/incoming/$JOB_ID" + HF_HUB_OFFLINE=1 "$WORKER" >&2 < /dev/null + fi + if [[ -e "$state/failed/$JOB_ID.log" ]]; then + echo "Error: local transcription failed" >&2 + cat "$state/failed/$JOB_ID.log" >&2 + exit 1 + fi + if [[ ! -e "$state/done/$JOB_ID.txt" ]]; then + echo "Error: the local worker finished without producing a transcript" >&2 + exit 1 + fi + print_transcript "$(< "$state/done/$JOB_ID.txt")" +} + +if [[ -z "${TRANSCRIBE_LOCAL:-}" ]] && remote true 2> /dev/null; then + run_remote +else + [[ -n "${TRANSCRIBE_LOCAL:-}" ]] || echo "$HOST is unreachable." >&2 + run_local +fi diff --git a/tests/test-transcription-config--ratio-backend.el b/tests/test-transcription-config--ratio-backend.el new file mode 100644 index 00000000..f368b859 --- /dev/null +++ b/tests/test-transcription-config--ratio-backend.el @@ -0,0 +1,102 @@ +;;; test-transcription-config--ratio-backend.el --- the ratio self-hosted backend -*- lexical-binding: t; -*- + +;;; Commentary: +;; The `ratio' backend runs whisper plus speaker diarization on my own host +;; through the scripts/ratio-transcribe client. It needs no API key, so its +;; descriptor carries nil for both :auth-host and :env-var, and the process +;; environment passes through unchanged. +;; +;; Two seams have to agree for a backend to be usable: the descriptor alist +;; that resolves the script, and the completing-read list the interactive +;; switcher offers. The switcher used to carry its own copy of that list, so +;; a descriptor added without a switcher entry was reachable only by setq. It +;; now derives its choices from the alist; the boundary test below guards +;; against a return to a hardcoded list. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(unless (fboundp 'notifications-notify) + (defun notifications-notify (&rest _args) + "Stub notification function for testing." + nil)) + +(require 'transcription-config) + +(defun test-transcription-ratio--switcher-choices () + "Return the backend names `cj/transcription-switch-backend' offers. +Captures the collection handed to `completing-read' and answers with the +current backend so the switcher is a no-op." + (let (offered) + (cl-letf (((symbol-function 'completing-read) + (lambda (_prompt collection &rest _) + (setq offered (mapcar #'car collection)) + (symbol-name cj/transcribe-backend)))) + (let ((cj/transcribe-backend cj/transcribe-backend)) + (cj/transcription-switch-backend))) + offered)) + +;;; Normal + +(ert-deftest test-transcription-config-ratio-descriptor-resolves () + "Normal: the ratio backend resolves to the ratio-transcribe client with no +API-key requirement." + (let ((desc (cj/--backend-plist 'ratio))) + (should (equal (plist-get desc :script) "ratio-transcribe")) + (should (null (plist-get desc :auth-host))) + (should (null (plist-get desc :env-var))))) + +(ert-deftest test-transcription-config-ratio-script-path-and-executable () + "Normal: the script path lands on scripts/ratio-transcribe and the file is +there and executable, so a transcription can actually start." + (let ((cj/transcribe-backend 'ratio)) + (let ((path (cj/--transcription-script-path))) + (should (string-suffix-p "scripts/ratio-transcribe" path)) + (should (file-executable-p path))))) + +(ert-deftest test-transcription-config-ratio-environment-passes-through () + "Normal: no API key means the process environment is returned unchanged, +and nothing consults authinfo." + (cl-letf (((symbol-function 'cj/--auth-source-password) + (lambda (&rest _) (ert-fail "auth-source consulted for a keyless backend")))) + (should (eq (cj/--build-process-environment 'ratio) process-environment)))) + +(ert-deftest test-transcription-config-switcher-offers-ratio () + "Normal: the interactive switcher lists ratio, so the backend is reachable +without a setq." + (should (member "ratio" (test-transcription-ratio--switcher-choices)))) + +;;; Boundary + +(ert-deftest test-transcription-config-switcher-matches-descriptors () + "Boundary: the switcher offers exactly the descriptor set. It derives the +list from the alist now; this is what stops a hardcoded copy coming back." + (should (equal (sort (test-transcription-ratio--switcher-choices) #'string<) + (sort (mapcar (lambda (entry) (symbol-name (car entry))) + cj/--transcription-backends) + #'string<)))) + +(ert-deftest test-transcription-config-every-descriptor-script-exists () + "Boundary: every descriptor names a script that exists under scripts/. +A descriptor for a script that never landed (a hosted alternative referenced +from another repo, say) would fail at transcription time instead." + (dolist (entry cj/--transcription-backends) + (let ((cj/transcribe-backend (car entry))) + (should (file-exists-p (cj/--transcription-script-path)))))) + +;;; Error + +(ert-deftest test-transcription-config-unknown-backend-still-signals () + "Error: adding ratio did not loosen the descriptor lookup; an unknown +backend still signals `user-error'." + (should-error (cj/--backend-plist 'no-such-backend) :type 'user-error)) + +(provide 'test-transcription-config--ratio-backend) +;;; test-transcription-config--ratio-backend.el ends here |
