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:
+103
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user