Add whisper-server engine: transcribe on a remote whisper.cpp server

- WhisperServerEngine posts multipart/form-data to POST /inference
  (verbose_json) with the same transcribe() contract as the other
  engines, so live and final passes work unchanged; stdlib-only
  multipart builder; HTTP and connection errors surface clean messages
- CLI: --engine whisper-server + --server-url (or MEETREC_SERVER_URL)
- GUI: engine dropdown gains whisper-server; model/device/compute grey
  out (the server owns the model), new Server URL field
- model lives server-side, beam size is a server-start setting in
  whisper.cpp v1.9.3 (documented); verbose_json reports language names
  (german) rather than ISO codes
- validated against the live R9700 server over Tailscale: array input,
  native-rate file input (server-side resample), full final_transcribe
  pipeline, and unreachable-server handling
This commit is contained in:
2026-09-07 12:59:14 +02:00
parent e97e8b1d33
commit a1df9d4037
3 changed files with 160 additions and 26 deletions
+103 -7
View File
@@ -41,6 +41,7 @@ import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
import wave
from pathlib import Path
@@ -324,6 +325,8 @@ def final_transcribe(model, wav_path: str, out_stem: str, language,
# faster-whisper CTranslate2 — NVIDIA CUDA or CPU (default)
# whisper-cpp whisper.cpp CLI — GPU via Vulkan (AMD, Intel, NVIDIA)
# or CPU; preferred on AMD hardware (e.g. Ryzen AI laptops)
# whisper-server remote whisper.cpp HTTP server (server/whisper-server) —
# the model lives on the server; ideal for phones/laptops
#
# Both expose: transcribe(audio, beam_size, vad_filter, language,
# vad_parameters) -> (segments, info)
@@ -493,11 +496,95 @@ class WhisperCppEngine:
return segs, info
MULTIPART_BOUNDARY = "meetrec-9f3e1c7a5b2d"
def _multipart_body(fields: dict, file_name: str, file_obj) -> bytes:
"""Build a multipart/form-data body with stdlib only."""
parts = []
for name, value in fields.items():
parts.append(
f"--{MULTIPART_BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
f"{value}\r\n".encode())
parts.append(
f"--{MULTIPART_BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="file"; '
f'filename="{file_name}"\r\n'
f"Content-Type: audio/wav\r\n\r\n".encode())
parts.append(file_obj.read())
parts.append(b"\r\n")
parts.append(f"--{MULTIPART_BOUNDARY}--\r\n".encode())
return b"".join(parts)
class WhisperServerEngine:
"""Transcription via a remote whisper.cpp server (whisper-server).
Use the Docker Compose stack in server/whisper-server/ to run one
(Vulkan GPU on AMD, or CPU). The model lives on the server, so
--model / --device / --compute-type do not apply, and beam size is a
server-start setting in whisper.cpp v1.9.3 (no per-request override) —
beam_size is accepted for interface compatibility but ignored.
vad_filter/vad_parameters are likewise accepted but unused; the server
runs its own pipeline. Note: verbose_json reports language names like
"german" rather than ISO codes.
"""
def __init__(self, server_url: str):
self.server_url = server_url.rstrip("/")
self.model_name = None # the server owns the model
def transcribe(self, audio, beam_size=5, vad_filter=False,
language=None, vad_parameters=None, timeout=600):
with tempfile.TemporaryDirectory(prefix="meetrec-") as td:
if isinstance(audio, (str, os.PathLike)):
wav = str(audio) # server resamples to 16 kHz itself
else:
wav = os.path.join(td, "in.wav")
write_wav_float(wav, audio)
with open(wav, "rb") as f:
body = _multipart_body(
{"response_format": "verbose_json",
"language": language or "auto"},
"audio.wav", f)
req = urllib.request.Request(
self.server_url + "/inference", data=body,
headers={"Content-Type":
"multipart/form-data; boundary=" + MULTIPART_BOUNDARY})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
data = json.load(r)
except urllib.error.HTTPError as e:
raise RuntimeError(f"server returned {e.code}: "
f"{e.read()[:300]!r}") from None
except urllib.error.URLError as e:
raise RuntimeError(f"cannot reach whisper server at "
f"{self.server_url}: {e.reason}") from None
segs = [SimpleNamespace(start=s["start"], end=s["end"],
text=s["text"].strip())
for s in data.get("segments", [])
if s.get("text", "").strip()]
info = SimpleNamespace(
language=data.get("language") or language or "unknown",
language_probability=1.0, # not reported by verbose_json
duration=data.get("duration", 0.0),
)
return segs, info
def load_engine(model_name, engine="faster-whisper",
device="auto", compute_type="int8"):
device="auto", compute_type="int8", server_url=None):
"""Create a transcription engine. See the Engines section above."""
if engine == "whisper-cpp":
return WhisperCppEngine(model_name)
if engine == "whisper-server":
url = server_url or os.environ.get("MEETREC_SERVER_URL")
if not url:
raise RuntimeError("no server URL: pass --server-url or set "
"MEETREC_SERVER_URL (e.g. http://100.103.83.12:8085)")
return WhisperServerEngine(url)
if engine == "faster-whisper":
from faster_whisper import WhisperModel
return WhisperModel(model_name, device=device,
@@ -528,10 +615,13 @@ def main():
ap.add_argument("--language", default=None,
help="force language code, e.g. en, de (default: autodetect)")
ap.add_argument("--engine", default="faster-whisper",
choices=["faster-whisper", "whisper-cpp"],
choices=["faster-whisper", "whisper-cpp", "whisper-server"],
help="transcription backend (default: faster-whisper). "
"whisper-cpp can use a GPU via Vulkan — preferred "
"on AMD hardware")
"whisper-cpp can use a GPU via Vulkan; whisper-server "
"transcribes on a remote whisper.cpp server")
ap.add_argument("--server-url", default=None,
help="whisper-server base URL, e.g. http://100.103.83.12:8085 "
"(or set MEETREC_SERVER_URL); whisper-server engine only")
ap.add_argument("--device", default="auto",
help="compute device: auto / cpu / cuda (default: auto); "
"faster-whisper only")
@@ -548,12 +638,18 @@ def main():
device = resolve_source(args.source)
print(f"loading Whisper model {args.model!r} via {args.engine!r} "
f"(first run downloads it to ~/.cache)...")
if args.engine == "whisper-server":
print(f"using whisper server at "
f"{args.server_url or os.environ.get('MEETREC_SERVER_URL')} "
f"(model lives on the server)")
else:
print(f"loading Whisper model {args.model!r} via {args.engine!r} "
f"(first run downloads it to ~/.cache)...")
try:
model = load_engine(args.model, engine=args.engine,
device=args.device,
compute_type=args.compute_type)
compute_type=args.compute_type,
server_url=args.server_url)
except Exception as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(1)