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
+13 -2
View File
@@ -86,6 +86,8 @@ meetrec-cli --list-sources # find your input de
meetrec-cli -s "Conference Mic" --model small --live 8 -o meeting meetrec-cli -s "Conference Mic" --model small --live 8 -o meeting
meetrec-cli -s monitor --model base --language en # record system audio meetrec-cli -s monitor --model base --language en # record system audio
meetrec-cli --engine whisper-cpp -o meeting # transcribe on the GPU (Vulkan) meetrec-cli --engine whisper-cpp -o meeting # transcribe on the GPU (Vulkan)
MEETREC_SERVER_URL=http://100.103.83.12:8085 \
meetrec-cli --engine whisper-server -o meeting # transcribe on a remote server
python meetrec.py --help # all options python meetrec.py --help # all options
``` ```
@@ -93,7 +95,8 @@ Key options:
| Option | Meaning | | Option | Meaning |
| --------------- | ---------------------------------------------------------------- | | --------------- | ---------------------------------------------------------------- |
| `--engine` | transcription backend: `faster-whisper` (default) or `whisper-cpp` | | `--engine` | transcription backend: `faster-whisper` (default), `whisper-cpp`, or `whisper-server` |
| `--server-url` | whisper-server base URL, e.g. `http://100.103.83.12:8085` (or `MEETREC_SERVER_URL`) |
| `-s, --source` | `default` or a substring of a device name (`--list-sources`) | | `-s, --source` | `default` or a substring of a device name (`--list-sources`) |
| `-m, --model` | `tiny` / `base` / `small` / `medium` / `large-v3` (default `small`) | | `-m, --model` | `tiny` / `base` / `small` / `medium` / `large-v3` (default `small`) |
| `-o, --output` | output file stem (default `meeting`) | | `-o, --output` | output file stem (default `meeting`) |
@@ -118,8 +121,9 @@ Meetrec supports two interchangeable transcription backends — `--engine` on th
| ------ | ------- | ----------- | | ------ | ------- | ----------- |
| `faster-whisper` (default) | CTranslate2 | NVIDIA CUDA (`--device cuda`); otherwise CPU | | `faster-whisper` (default) | CTranslate2 | NVIDIA CUDA (`--device cuda`); otherwise CPU |
| `whisper-cpp` | [whisper.cpp](https://github.com/ggml-org/whisper.cpp) | **AMD / Intel / NVIDIA via Vulkan**; falls back to CPU | | `whisper-cpp` | [whisper.cpp](https://github.com/ggml-org/whisper.cpp) | **AMD / Intel / NVIDIA via Vulkan**; falls back to CPU |
| `whisper-server` | remote whisper.cpp HTTP server | whatever the server has — see [server/whisper-server/](server/whisper-server/) (Docker Compose, Vulkan GPU, Tailscale) |
On AMD hardware (e.g. the Radeon iGPU in Ryzen AI 300 laptops) the `faster-whisper` path is CPU-only — use the `whisper-cpp` engine there, which runs on the GPU when whisper.cpp is built with `-DWHISPER_VULKAN=ON` (`./install.sh --whisper-cpp` does this for you). On AMD hardware (e.g. the Radeon iGPU in Ryzen AI 300 laptops) the `faster-whisper` path is CPU-only — use the `whisper-cpp` engine there, which runs on the GPU when whisper.cpp is built with `-DWHISPER_VULKAN=ON` (`./install.sh --whisper-cpp` does this for you). The `whisper-server` engine moves the work to a server entirely (ideal for slow clients such as phones) and can run `large-v3` on a GPU.
Notes on `whisper-cpp`: Notes on `whisper-cpp`:
@@ -127,6 +131,13 @@ Notes on `whisper-cpp`:
- GGML models (`ggml-*.bin`) download automatically to `~/.cache/meetrec/whisper-cpp` on first use. - GGML models (`ggml-*.bin`) download automatically to `~/.cache/meetrec/whisper-cpp` on first use.
- VAD works like on faster-whisper: whisper.cpp's Silero VAD model (~1 MB) downloads automatically and is used for both live and final passes. - VAD works like on faster-whisper: whisper.cpp's Silero VAD model (~1 MB) downloads automatically and is used for both live and final passes.
- Live mode spawns `whisper-cli` for every rolling-window pass, so the GGML model is reloaded on each live tick — slightly heavier than faster-whisper, which loads once per recording. - Live mode spawns `whisper-cli` for every rolling-window pass, so the GGML model is reloaded on each live tick — slightly heavier than faster-whisper, which loads once per recording.
Notes on `whisper-server`:
- The model lives on the server — `--model`, `--device`, `--compute-type` do not apply; beam size is a server-start setting in whisper.cpp v1.9.3 (no per-request override).
- Run your own with the Docker Compose stack in `server/whisper-server/` — Vulkan GPU (AMD/NVIDIA/Intel), model auto-download, Tailscale-only binding.
- The server has no authentication: keep it on Tailscale or behind a VPN/firewall.
- `verbose_json` reports language names ("german") rather than ISO codes.
- `--device` / `--compute-type` do not apply (GPU vs. CPU is decided by the whisper.cpp build). - `--device` / `--compute-type` do not apply (GPU vs. CPU is decided by the whisper.cpp build).
## Project layout ## Project layout
+103 -7
View File
@@ -41,6 +41,7 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
import urllib.error
import urllib.request import urllib.request
import wave import wave
from pathlib import Path 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) # faster-whisper CTranslate2 — NVIDIA CUDA or CPU (default)
# whisper-cpp whisper.cpp CLI — GPU via Vulkan (AMD, Intel, NVIDIA) # whisper-cpp whisper.cpp CLI — GPU via Vulkan (AMD, Intel, NVIDIA)
# or CPU; preferred on AMD hardware (e.g. Ryzen AI laptops) # 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, # Both expose: transcribe(audio, beam_size, vad_filter, language,
# vad_parameters) -> (segments, info) # vad_parameters) -> (segments, info)
@@ -493,11 +496,95 @@ class WhisperCppEngine:
return segs, info 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", 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.""" """Create a transcription engine. See the Engines section above."""
if engine == "whisper-cpp": if engine == "whisper-cpp":
return WhisperCppEngine(model_name) 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": if engine == "faster-whisper":
from faster_whisper import WhisperModel from faster_whisper import WhisperModel
return WhisperModel(model_name, device=device, return WhisperModel(model_name, device=device,
@@ -528,10 +615,13 @@ def main():
ap.add_argument("--language", default=None, ap.add_argument("--language", default=None,
help="force language code, e.g. en, de (default: autodetect)") help="force language code, e.g. en, de (default: autodetect)")
ap.add_argument("--engine", default="faster-whisper", 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). " help="transcription backend (default: faster-whisper). "
"whisper-cpp can use a GPU via Vulkan — preferred " "whisper-cpp can use a GPU via Vulkan; whisper-server "
"on AMD hardware") "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", ap.add_argument("--device", default="auto",
help="compute device: auto / cpu / cuda (default: auto); " help="compute device: auto / cpu / cuda (default: auto); "
"faster-whisper only") "faster-whisper only")
@@ -548,12 +638,18 @@ def main():
device = resolve_source(args.source) device = resolve_source(args.source)
print(f"loading Whisper model {args.model!r} via {args.engine!r} " if args.engine == "whisper-server":
f"(first run downloads it to ~/.cache)...") 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: try:
model = load_engine(args.model, engine=args.engine, model = load_engine(args.model, engine=args.engine,
device=args.device, device=args.device,
compute_type=args.compute_type) compute_type=args.compute_type,
server_url=args.server_url)
except Exception as e: except Exception as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+44 -17
View File
@@ -50,18 +50,20 @@ class ModelLoader(QThread):
failed = Signal(str) failed = Signal(str)
def __init__(self, model_name, engine="faster-whisper", device="auto", def __init__(self, model_name, engine="faster-whisper", device="auto",
compute_type="int8", parent=None): compute_type="int8", server_url=None, parent=None):
super().__init__(parent) super().__init__(parent)
self.model_name = model_name self.model_name = model_name
self.engine = engine self.engine = engine
self.device = device self.device = device
self.compute_type = compute_type self.compute_type = compute_type
self.server_url = server_url
def run(self): def run(self):
try: try:
self.model = meetrec.load_engine( self.model = meetrec.load_engine(
self.model_name, engine=self.engine, self.model_name, engine=self.engine,
device=self.device, compute_type=self.compute_type) device=self.device, compute_type=self.compute_type,
server_url=self.server_url)
self.loaded.emit(self.model) self.loaded.emit(self.model)
except Exception as e: except Exception as e:
self.failed.emit(str(e)) self.failed.emit(str(e))
@@ -104,7 +106,7 @@ class MeetRecWindow(QWidget):
"pt", "ru", "zh", "ja", "ko"] "pt", "ru", "zh", "ja", "ko"]
DEVICES = ["auto", "cpu", "cuda"] DEVICES = ["auto", "cpu", "cuda"]
COMPUTE_TYPES = ["int8", "int8_float16", "float16", "float32"] COMPUTE_TYPES = ["int8", "int8_float16", "float16", "float32"]
ENGINES = ["faster-whisper", "whisper-cpp"] ENGINES = ["faster-whisper", "whisper-cpp", "whisper-server"]
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -151,7 +153,8 @@ class MeetRecWindow(QWidget):
# row 2: model + language # row 2: model + language
r2 = QHBoxLayout() r2 = QHBoxLayout()
r2.addWidget(QLabel("Model:")) self.model_lbl = QLabel("Model:")
r2.addWidget(self.model_lbl)
self.model_cb = QComboBox() self.model_cb = QComboBox()
self.model_cb.addItems(self.MODELS) self.model_cb.addItems(self.MODELS)
self.model_cb.setCurrentText("small") self.model_cb.setCurrentText("small")
@@ -192,6 +195,17 @@ class MeetRecWindow(QWidget):
r2b.addStretch(1) r2b.addStretch(1)
root.addLayout(r2b) root.addLayout(r2b)
# row 2c: whisper-server URL
r2c = QHBoxLayout()
self.server_lbl = QLabel("Server:")
r2c.addWidget(self.server_lbl)
self.server_url = QLineEdit("http://100.103.83.12:8085")
self.server_url.setToolTip(
"whisper.cpp server (see server/whisper-server/) — "
"the model lives on the server")
r2c.addWidget(self.server_url, 1)
root.addLayout(r2c)
# row 3: live options + output name # row 3: live options + output name
r3 = QHBoxLayout() r3 = QHBoxLayout()
r3.addWidget(QLabel("Live:")) r3.addWidget(QLabel("Live:"))
@@ -287,18 +301,26 @@ class MeetRecWindow(QWidget):
self.src.setCurrentText(keep) self.src.setCurrentText(keep)
def _engine_changed(self, _idx): def _engine_changed(self, _idx):
# --device/--compute-type only apply to faster-whisper (CTranslate2); # What applies per engine:
# whisper.cpp picks GPU (Vulkan) or CPU on its own. # model: faster-whisper + whisper-cpp (server owns the
gpu_opts = self.engine_cb.currentText() != "whisper-cpp" # model otherwise)
self.device_lbl.setText("Device:" if gpu_opts else "Device (n/a):") # device/compute: faster-whisper only (CTranslate2)
self.compute_lbl.setText("Compute:" if gpu_opts else "Compute (n/a):") # server url: whisper-server only
self.device_cb.setEnabled(gpu_opts) engine = self.engine_cb.currentText()
self.compute_cb.setEnabled(gpu_opts) has_model = engine != "whisper-server"
has_dc = engine == "faster-whisper"
self.model_lbl.setText("Model:" if has_model else "Model (n/a):")
self.device_lbl.setText("Device:" if has_dc else "Device (n/a):")
self.compute_lbl.setText("Compute:" if has_dc else "Compute (n/a):")
self.model_cb.setEnabled(has_model)
self.device_cb.setEnabled(has_dc)
self.compute_cb.setEnabled(has_dc)
self.server_url.setEnabled(engine == "whisper-server")
def _set_inputs_enabled(self, enabled: bool): def _set_inputs_enabled(self, enabled: bool):
for w in (self.src, self.model_cb, self.engine_cb, self.device_cb, for w in (self.src, self.model_cb, self.engine_cb, self.device_cb,
self.compute_cb, self.lang, self.live_cb, self.live_spin, self.compute_cb, self.server_url, self.lang, self.live_cb,
self.out): self.live_spin, self.out):
w.setEnabled(enabled) w.setEnabled(enabled)
if enabled: if enabled:
self._engine_changed(-1) # whisper-cpp: keep device/compute off self._engine_changed(-1) # whisper-cpp: keep device/compute off
@@ -341,18 +363,23 @@ class MeetRecWindow(QWidget):
self.elapsed.setText("00:00:00") self.elapsed.setText("00:00:00")
key = (self.engine_cb.currentText(), self._args.model, key = (self.engine_cb.currentText(), self._args.model,
self.device_cb.currentText(), self.compute_cb.currentText()) self.device_cb.currentText(), self.compute_cb.currentText(),
self.server_url.text().strip())
if self.model is None or self.model_key != key: if self.model is None or self.model_key != key:
self.state = "loading" self.state = "loading"
self.status.setText( if key[0] == "whisper-server":
f"Loading Whisper model {self._args.model!r} via {key[0]} " self.status.setText(f"Using whisper server {key[4]} ...")
"(first run downloads it, this can take a minute)...") else:
self.status.setText(
f"Loading Whisper model {self._args.model!r} via {key[0]} "
"(first run downloads it, this can take a minute)...")
self._pending_key = key self._pending_key = key
self.loader = ModelLoader( self.loader = ModelLoader(
self._args.model, self._args.model,
engine=key[0], engine=key[0],
device=self.device_cb.currentText(), device=self.device_cb.currentText(),
compute_type=self.compute_cb.currentText(), compute_type=self.compute_cb.currentText(),
server_url=key[4] or None,
parent=self) parent=self)
self.loader.loaded.connect(self._model_ready) self.loader.loaded.connect(self._model_ready)
self.loader.failed.connect(self._model_failed) self.loader.failed.connect(self._model_failed)