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:
@@ -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 monitor --model base --language en # record system audio
|
||||
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
|
||||
```
|
||||
|
||||
@@ -93,7 +95,8 @@ Key options:
|
||||
|
||||
| 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`) |
|
||||
| `-m, --model` | `tiny` / `base` / `small` / `medium` / `large-v3` (default `small`) |
|
||||
| `-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 |
|
||||
| `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`:
|
||||
|
||||
@@ -127,6 +131,13 @@ Notes on `whisper-cpp`:
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
## Project layout
|
||||
|
||||
+101
-5
@@ -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)
|
||||
|
||||
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)
|
||||
|
||||
+41
-14
@@ -50,18 +50,20 @@ class ModelLoader(QThread):
|
||||
failed = Signal(str)
|
||||
|
||||
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)
|
||||
self.model_name = model_name
|
||||
self.engine = engine
|
||||
self.device = device
|
||||
self.compute_type = compute_type
|
||||
self.server_url = server_url
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.model = meetrec.load_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)
|
||||
except Exception as e:
|
||||
self.failed.emit(str(e))
|
||||
@@ -104,7 +106,7 @@ class MeetRecWindow(QWidget):
|
||||
"pt", "ru", "zh", "ja", "ko"]
|
||||
DEVICES = ["auto", "cpu", "cuda"]
|
||||
COMPUTE_TYPES = ["int8", "int8_float16", "float16", "float32"]
|
||||
ENGINES = ["faster-whisper", "whisper-cpp"]
|
||||
ENGINES = ["faster-whisper", "whisper-cpp", "whisper-server"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -151,7 +153,8 @@ class MeetRecWindow(QWidget):
|
||||
|
||||
# row 2: model + language
|
||||
r2 = QHBoxLayout()
|
||||
r2.addWidget(QLabel("Model:"))
|
||||
self.model_lbl = QLabel("Model:")
|
||||
r2.addWidget(self.model_lbl)
|
||||
self.model_cb = QComboBox()
|
||||
self.model_cb.addItems(self.MODELS)
|
||||
self.model_cb.setCurrentText("small")
|
||||
@@ -192,6 +195,17 @@ class MeetRecWindow(QWidget):
|
||||
r2b.addStretch(1)
|
||||
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
|
||||
r3 = QHBoxLayout()
|
||||
r3.addWidget(QLabel("Live:"))
|
||||
@@ -287,18 +301,26 @@ class MeetRecWindow(QWidget):
|
||||
self.src.setCurrentText(keep)
|
||||
|
||||
def _engine_changed(self, _idx):
|
||||
# --device/--compute-type only apply to faster-whisper (CTranslate2);
|
||||
# whisper.cpp picks GPU (Vulkan) or CPU on its own.
|
||||
gpu_opts = self.engine_cb.currentText() != "whisper-cpp"
|
||||
self.device_lbl.setText("Device:" if gpu_opts else "Device (n/a):")
|
||||
self.compute_lbl.setText("Compute:" if gpu_opts else "Compute (n/a):")
|
||||
self.device_cb.setEnabled(gpu_opts)
|
||||
self.compute_cb.setEnabled(gpu_opts)
|
||||
# What applies per engine:
|
||||
# model: faster-whisper + whisper-cpp (server owns the
|
||||
# model otherwise)
|
||||
# device/compute: faster-whisper only (CTranslate2)
|
||||
# server url: whisper-server only
|
||||
engine = self.engine_cb.currentText()
|
||||
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):
|
||||
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.out):
|
||||
self.compute_cb, self.server_url, self.lang, self.live_cb,
|
||||
self.live_spin, self.out):
|
||||
w.setEnabled(enabled)
|
||||
if enabled:
|
||||
self._engine_changed(-1) # whisper-cpp: keep device/compute off
|
||||
@@ -341,9 +363,13 @@ class MeetRecWindow(QWidget):
|
||||
self.elapsed.setText("00:00:00")
|
||||
|
||||
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:
|
||||
self.state = "loading"
|
||||
if key[0] == "whisper-server":
|
||||
self.status.setText(f"Using whisper server {key[4]} ...")
|
||||
else:
|
||||
self.status.setText(
|
||||
f"Loading Whisper model {self._args.model!r} via {key[0]} "
|
||||
"(first run downloads it, this can take a minute)...")
|
||||
@@ -353,6 +379,7 @@ class MeetRecWindow(QWidget):
|
||||
engine=key[0],
|
||||
device=self.device_cb.currentText(),
|
||||
compute_type=self.compute_cb.currentText(),
|
||||
server_url=key[4] or None,
|
||||
parent=self)
|
||||
self.loader.loaded.connect(self._model_ready)
|
||||
self.loader.failed.connect(self._model_failed)
|
||||
|
||||
Reference in New Issue
Block a user