router: deutsche Spracherkennung mit whisper.cpp (CPU-only)

- STT-Worker (stt_worker.py): langlebiger HTTP-Service auf Port 8084
  - whisper-cli als Subprozess (CPU-only, 8 Threads)
  - Audio-Vorbereitung via ffmpeg (WebM/Opus/M4A → 16 kHz WAV)
  - Nativ: WAV, MP3, OGG, FLAC
  - Health-Endpunkt: GET /status
  - Transkription: POST /transcribe (Multipart-Form-Data)

- Router-Integration:
  - POST /v1/audio/transcriptions (OpenAI-kompatibel)
  - GET /v1/audio/models (whisper-1, kokoro-german)
  - GET /v1/audio/voices (martin, victoria)
  - /status mit stt-Section
  - model=whisper-1 akzeptiert
  - response_format: json, verbose_json

- systemd-Service: mike-ai-whisper.service
  - Boot-Start, Restart on failure, journald
  - CPU-only, kein GPU-Lock

- Deploy-Dateien aktualisiert (deploy.sh, install.sh)
- Mock-STT-Worker für lokale Tests (dev/mock_stt_worker.py)
- Tests ergänzt: STT Status, WAV, language=de, unbekanntes Modell,
  Worker down, Recovery, Audio-Modelle, Audio-Voices,
  STT+Qwen parallel, STT+TTS parallel
- README.md: STT-Section mit Endpunkten, Benchmarks, Doku

Benchmarks (CPU-only, 8 Threads):
  7.3 s Audio → 8.9 s (RTF 1.22×)
  30 s Audio → 16.8 s (RTF 0.56×)
  50 s Audio → 18.5 s (RTF 0.37×)
  RAM: ~1.7 GB (Modell), Worker: ~20 MB
This commit is contained in:
Mikei386
2026-08-19 13:53:22 +02:00
parent ff064685ce
commit 51ef07c874
8 changed files with 1059 additions and 6 deletions
+234 -3
View File
@@ -22,10 +22,16 @@ Bildgenerierung (FLUX.2 [klein] 4B Base):
Sprachausgabe (Kokoro-82M, deutsch, CPU-only):
POST /v1/audio/speech (OpenAI-kompatibel)
GET /v1/audio/voices (verfügbare Stimmen)
Der TTS-Worker (mike-ai-kokoro.service) läuft als separater, langlebiger
Prozess mit eigenem Venv und hält die Modelle dauerhaft im RAM. Der
Router leitet /v1/audio/speech per HTTP an den Worker weiter.
Spracherkennung (whisper.cpp, deutsch, CPU-only):
POST /v1/audio/transcriptions (OpenAI-kompatibel)
GET /v1/audio/models (verfügbare Audio-Modelle)
Der TTS-Worker (mike-ai-kokoro.service) und der STT-Worker
(mike-ai-whisper.service) laufen als separate, langlebige Prozesse.
Der Router leitet /v1/audio/speech und /v1/audio/transcriptions
per HTTP an die Worker weiter.
Der Router agiert als Modell-Orchestrator: vor der Generierung wird
llama.cpp gestoppt, der Bild-Worker lädt FLUX, generiert und entlädt
@@ -48,6 +54,7 @@ import subprocess
import sys
import threading
import time
import uuid
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -108,6 +115,12 @@ TTS_DEFAULT_VOICE = "martin"
TTS_FORMATS = ("mp3", "wav", "flac", "pcm")
TTS_DEFAULT_FORMAT = "mp3"
# --- Spracherkennung (whisper.cpp, deutsch, CPU-only) ---
STT_WORKER_URL = os.environ.get("STT_WORKER_URL", "http://127.0.0.1:8084")
STT_TIMEOUT = float(os.environ.get("STT_TIMEOUT", "120")) # s, pro Transkription
STT_CONNECT_TIMEOUT = float(os.environ.get("STT_CONNECT_TIMEOUT", "5"))
STT_MODEL = "whisper-1" # virtuelles Modell für /v1/audio/transcriptions
# Chat-Waiting: Während eines Image-Jobs oder Profilwechsels ist Qwen
# down. Chat-Requests warten (statt 502) bis Qwen wieder bereit ist.
CHAT_WAIT_TIMEOUT = float(os.environ.get("CHAT_WAIT_TIMEOUT", "300")) # s, max. Warten
@@ -256,6 +269,72 @@ def tts_synthesize(text: str, voice: str, speed: float,
return body, content_type
def stt_status() -> dict:
"""Prüft den STT-Worker: erreichbar? bereit?"""
hostport = STT_WORKER_URL.split("://", 1)[-1]
host, _, port = hostport.partition(":")
try:
conn = http.client.HTTPConnection(host, int(port) if port else 80,
timeout=STT_CONNECT_TIMEOUT)
conn.request("GET", "/status")
resp = conn.getresponse()
data = json.loads(resp.read())
conn.close()
return {"reachable": True, **data}
except (OSError, ValueError) as e:
return {"reachable": False, "error": str(e)}
def stt_transcribe(file_data: bytes, filename: str,
language: str | None = None,
prompt: str | None = None,
temperature: float | None = None) -> dict:
"""Transkribiert Audio über den STT-Worker.
Liefert dict mit 'text'. Wirft RuntimeError bei Fehler.
"""
hostport = STT_WORKER_URL.split("://", 1)[-1]
host, _, port = hostport.partition(":")
# Multipart-Form-Data bauen
boundary = "----STTBoundary" + uuid.uuid4().hex[:16]
parts = []
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n".encode("utf-8")
)
parts.append(file_data)
parts.append(b"\r\n")
for key, value in [("language", language), ("prompt", prompt),
("temperature", temperature)]:
if value is not None:
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
f"{value}\r\n".encode("utf-8")
)
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
body = b"".join(parts)
try:
conn = http.client.HTTPConnection(host, int(port) if port else 80,
timeout=STT_CONNECT_TIMEOUT)
conn.request("POST", "/transcribe", body=body,
headers={"Content-Type":
f"multipart/form-data; boundary={boundary}"})
conn.sock.settimeout(STT_TIMEOUT)
resp = conn.getresponse()
data = json.loads(resp.read())
conn.close()
except (OSError, http.client.HTTPException) as e:
raise RuntimeError(f"STT-Worker nicht erreichbar: {e}")
if resp.status != 200:
msg = data.get("error", str(data)) if isinstance(data, dict) else str(data)
raise RuntimeError(f"STT-Fehler ({resp.status}): {msg}")
return data
def upstream_status() -> dict:
"""Prüft llama.cpp: erreichbar? welches Modell? welcher Kontext?"""
try:
@@ -682,10 +761,16 @@ class Handler(BaseHTTPRequestHandler):
self._send_json(200, self._models_payload())
elif path == "/status":
self._send_json(200, self._status_payload())
elif path == "/v1/audio/models" and self.command == "GET":
self._send_json(200, self._audio_models_payload())
elif path == "/v1/audio/voices" and self.command == "GET":
self._send_json(200, self._audio_voices_payload())
elif path == "/v1/images/generations" and self.command == "POST":
self._image_generate()
elif path == "/v1/audio/speech" and self.command == "POST":
self._speech()
elif path == "/v1/audio/transcriptions" and self.command == "POST":
self._transcribe()
elif path == "/images" and self.command == "GET":
self._images_list()
elif path.startswith("/images/") and self.command == "GET":
@@ -759,6 +844,7 @@ class Handler(BaseHTTPRequestHandler):
"last_error": img.last_error,
},
"tts": tts_status(),
"stt": stt_status(),
}
# ---------- Bildgenerierung ----------
@@ -1002,6 +1088,151 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(audio)
# ---------- Audio-Discovery ----------
def _audio_models_payload(self) -> dict:
"""Listet verfügbare Audio-Modelle (STT + TTS)."""
tts = tts_status()
stt = stt_status()
models = []
if stt.get("ready"):
models.append({
"id": STT_MODEL,
"object": "model",
"owned_by": "whisper.cpp",
"type": "transcription",
})
if tts.get("ready"):
models.append({
"id": TTS_MODEL,
"object": "model",
"owned_by": "kokoro",
"type": "speech",
})
return {"object": "list", "data": models}
def _audio_voices_payload(self) -> dict:
"""Listet verfügbare TTS-Stimmen."""
tts = tts_status()
voices = []
for v in tts.get("voices", []):
voices.append({
"id": v,
"object": "voice",
"language": "de",
})
return {"object": "list", "data": voices}
# ---------- STT (Spracherkennung) ----------
def _parse_multipart(self, data: bytes, content_type: str
) -> tuple[bytes, str, dict]:
"""Parst multipart/form-data. Liefert (file_data, filename, fields)."""
boundary = None
for part in content_type.split(";"):
part = part.strip()
if part.startswith("boundary="):
boundary = part[len("boundary="):]
break
if not boundary:
raise ValueError("Kein Boundary in Content-Type")
boundary_bytes = boundary.encode("utf-8")
file_data = b""
filename = ""
fields = {}
parts = data.split(b"--" + boundary_bytes)
for part in parts:
if part in (b"", b"--", b"--\r\n", b"\r\n"):
continue
if b"\r\n\r\n" not in part:
continue
header_part, body_part = part.split(b"\r\n\r\n", 1)
if body_part.endswith(b"\r\n"):
body_part = body_part[:-2]
header_text = header_part.decode("utf-8", errors="replace")
for line in header_text.split("\r\n"):
if "name=" in line and "filename=" in line:
for kv in line.split(";"):
kv = kv.strip()
if kv.startswith("filename="):
filename = kv[len("filename="):].strip('"')
file_data = body_part
elif "name=" in line:
name = line.split("name=")[1].strip().strip('"')
fields[name] = body_part.decode("utf-8", errors="replace")
return file_data, filename, fields
def _transcribe(self) -> None:
"""POST /v1/audio/transcriptions – STT (OpenAI-kompatibel)."""
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type:
self._send_error(400,
"Content-Type muss multipart/form-data sein",
"invalid_request_error", "invalid_content_type")
return
length = int(self.headers.get("Content-Length") or 0)
data = self.rfile.read(length)
try:
file_data, filename, fields = self._parse_multipart(
data, content_type)
except ValueError as e:
self._send_error(400, str(e),
"invalid_request_error", "invalid_multipart")
return
if not file_data:
self._send_error(400, "Keine Datei im Request",
"invalid_request_error", "missing_file")
return
# Modell-Validierung
model = fields.get("model", STT_MODEL)
if model not in (STT_MODEL, "whisper"):
self._send_error(400, f"unbekanntes Modell: {model!r} "
f"(erwartet: {STT_MODEL})",
"invalid_request_error", "unknown_model")
return
# Optionale Felder
language = fields.get("language")
prompt = fields.get("prompt")
temperature = None
if fields.get("temperature"):
try:
temperature = float(fields["temperature"])
except ValueError:
self._send_error(400, "'temperature' muss eine Zahl sein",
"invalid_request_error", "invalid_temperature")
return
response_format = fields.get("response_format", "json")
self.timeout = None # Transkription kann dauern
try:
result = stt_transcribe(
file_data, filename,
language=language, prompt=prompt,
temperature=temperature)
except RuntimeError as e:
self._send_error(503, str(e), "server_error", "stt_failed")
return
# OpenAI-kompatibles Antwort-Format
if response_format == "verbose_json":
resp = {
"text": result.get("text", ""),
"language": result.get("language", "de"),
"duration": result.get("audio_duration_ms", 0) / 1000.0,
}
else:
resp = {"text": result.get("text", "")}
self._send_json(200, resp)
def _switch(self, profile: str) -> None:
if profile not in PROFILES:
self._send_error(400, f"unbekanntes Profil: {profile}",
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
STT-Worker – langlebiger Whisper-Transkriptions-Service (CPU-only).
Liest Audio-Dateien (WAV, MP3, OGG, FLAC, WebM/Opus via ffmpeg),
transkribiert sie mit whisper.cpp (whisper-cli) und liefert JSON-Text.
Konfiguration über Umgebungsvariablen:
WHISPER_HOST Bind-Adresse (Default: 127.0.0.1)
WHISPER_PORT Port (Default: 8083)
WHISPER_CLI Pfad zu whisper-cli (Default: /opt/mike-ai/whisper.cpp/build-cpu/bin/whisper-cli)
WHISPER_MODEL Pfad zum ggml-Modell (Default: /opt/mike-ai/models/whisper/ggml-large-v3-turbo.bin)
WHISPER_THREADS Anzahl CPU-Threads (Default: 8)
WHISPER_LANGUAGE Standard-Sprache (Default: de)
FFMPEG_BIN Pfad zu ffmpeg (Default: /usr/bin/ffmpeg)
LOG_LEVEL Logging-Level (Default: INFO)
Endpunkte:
GET /status → Health + Konfiguration
POST /transcribe → Audio-Datei transkribieren (multipart/form-data oder raw body)
"""
import json
import logging
import os
import subprocess
import sys
import tempfile
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
HOST = os.environ.get("WHISPER_HOST", "127.0.0.1")
PORT = int(os.environ.get("WHISPER_PORT", "8083"))
WHISPER_CLI = os.environ.get(
"WHISPER_CLI",
"/opt/mike-ai/whisper.cpp/build-cpu/bin/whisper-cli",
)
WHISPER_MODEL = os.environ.get(
"WHISPER_MODEL",
"/opt/mike-ai/models/whisper/ggml-large-v3-turbo.bin",
)
WHISPER_THREADS = int(os.environ.get("WHISPER_THREADS", "8"))
WHISPER_LANGUAGE = os.environ.get("WHISPER_LANGUAGE", "de")
FFMPEG_BIN = os.environ.get("FFMPEG_BIN", "/usr/bin/ffmpeg")
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
# Audio-Formate, die whisper.cpp nativ unterstützt
NATIVE_FORMATS = {".wav", ".mp3", ".ogg", ".flac"}
# Formate, die ffmpeg-Konvertierung benötigen
CONVERT_FORMATS = {".webm", ".m4a", ".aac", ".opus", ".wma", ".amr", ".mka"}
logging.basicConfig(
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stdout,
)
log = logging.getLogger("stt-worker")
# ---------------------------------------------------------------------------
# Audio-Konvertierung
# ---------------------------------------------------------------------------
def _detect_format(filename: str) -> str:
"""Erkennt das Dateiformat anhand der Endung."""
ext = os.path.splitext(filename)[1].lower()
return ext
def _convert_to_wav(input_path: str, output_path: str) -> None:
"""Konvertiert Audio per ffmpeg zu 16 kHz mono WAV (s16)."""
cmd = [
FFMPEG_BIN,
"-y",
"-i", input_path,
"-ar", "16000",
"-ac", "1",
"-sample_fmt", "s16",
"-c:a", "pcm_s16le",
output_path,
]
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=30,
)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg-Fehler: {proc.stderr[-500:]}")
def _prepare_audio(data: bytes, filename: str) -> str:
"""
Bereitet Audio-Datei für whisper-cli vor.
Liefert Pfad zu einer WAV-Datei (16 kHz mono s16).
"""
ext = _detect_format(filename)
if ext in NATIVE_FORMATS:
# Nativ unterstützt – direkt verwenden
tmp = tempfile.NamedTemporaryFile(
suffix=ext, prefix="stt_", delete=False
)
tmp.write(data)
tmp.close()
return tmp.name
if ext in CONVERT_FORMATS:
# ffmpeg-Konvertierung nötig
tmp_in = tempfile.NamedTemporaryFile(
suffix=ext, prefix="stt_in_", delete=False
)
tmp_in.write(data)
tmp_in.close()
tmp_out = tempfile.NamedTemporaryFile(
suffix=".wav", prefix="stt_out_", delete=False
)
tmp_out.close()
_convert_to_wav(tmp_in.name, tmp_out.name)
os.unlink(tmp_in.name)
return tmp_out.name
# Unbekanntes Format – versuchen, es als WAV zu behandeln
tmp = tempfile.NamedTemporaryFile(
suffix=".wav", prefix="stt_", delete=False
)
tmp.write(data)
tmp.close()
return tmp.name
# ---------------------------------------------------------------------------
# Transkription
# ---------------------------------------------------------------------------
def transcribe(
audio_path: str,
language: str | None = None,
prompt: str | None = None,
temperature: float | None = None,
) -> dict:
"""
Führt die Transkription mit whisper-cli aus.
Liefert dict mit 'text' und Metadaten.
"""
lang = language or WHISPER_LANGUAGE
if lang == "auto":
lang = "auto"
out_prefix = f"/tmp/stt_{uuid.uuid4().hex[:12]}"
out_json = out_prefix + ".json"
cmd = [
WHISPER_CLI,
"-m", WHISPER_MODEL,
"-f", audio_path,
"-l", lang,
"-t", str(WHISPER_THREADS),
"-oj",
"-of", out_prefix,
"-np",
]
if prompt:
cmd.extend(["--prompt", prompt])
if temperature is not None:
cmd.extend(["-tp", str(temperature)])
t0 = time.monotonic()
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=300,
)
elapsed = time.monotonic() - t0
if proc.returncode != 0:
raise RuntimeError(
f"whisper-cli-Fehler (rc={proc.returncode}): "
f"{proc.stderr[-500:]}"
)
# JSON-Output lesen
result = {"text": "", "language": lang, "duration_ms": int(elapsed * 1000)}
if os.path.exists(out_json):
with open(out_json, "r", encoding="utf-8") as f:
jdata = json.load(f)
# whisper.cpp JSON-Format:
# {"transcription": [{"text": "...", "offsets": {"from": 0, "to": 1000}}],
# "result": {"language": "de"}, ...}
transcription = jdata.get("transcription", [])
if isinstance(transcription, list):
texts = [t.get("text", "") for t in transcription if isinstance(t, dict)]
result["text"] = " ".join(texts).strip()
# Audio-Dauer aus letztem Segment
if transcription and isinstance(transcription[-1], dict):
offsets = transcription[-1].get("offsets", {})
if offsets:
result["audio_duration_ms"] = offsets.get("to", 0)
elif isinstance(transcription, str):
result["text"] = transcription.strip()
# Sprache aus result.language
if "result" in jdata and isinstance(jdata["result"], dict):
if "language" in jdata["result"]:
result["language"] = jdata["result"]["language"]
elif "language" in jdata:
result["language"] = jdata["language"]
os.unlink(out_json)
# Aufräumen
for suffix in (".wav", ".mp3", ".ogg", ".flac", ".json"):
p = out_prefix + suffix
if os.path.exists(p):
os.unlink(p)
log.info(
"Transkription: %d ms, %d Zeichen, Sprache=%s",
result["duration_ms"], len(result["text"]), result["language"],
)
return result
# ---------------------------------------------------------------------------
# HTTP-Handler
# ---------------------------------------------------------------------------
class STTHandler(BaseHTTPRequestHandler):
server_version = "STTWorker/1.0"
def log_message(self, fmt, *args):
log.info("%s %s", self.address_string(), fmt % args)
def _send_json(self, code: int, obj: dict) -> None:
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
def _read_body(self) -> bytes:
length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length) if length > 0 else b""
def _parse_multipart(self, data: bytes, content_type: str) -> tuple[bytes, str, dict]:
"""
Parst multipart/form-data.
Liefert (file_data, filename, form_fields).
"""
# Boundary extrahieren
boundary = None
for part in content_type.split(";"):
part = part.strip()
if part.startswith("boundary="):
boundary = part[len("boundary="):]
break
if not boundary:
raise ValueError("Kein Boundary in Content-Type")
boundary_bytes = boundary.encode("utf-8")
file_data = b""
filename = ""
fields = {}
# Multipart parsen
parts = data.split(b"--" + boundary_bytes)
for part in parts:
if part in (b"", b"--", b"--\r\n", b"\r\n"):
continue
# Header und Body trennen
if b"\r\n\r\n" not in part:
continue
header_part, body_part = part.split(b"\r\n\r\n", 1)
# Trailing CRLF entfernen
if body_part.endswith(b"\r\n"):
body_part = body_part[:-2]
header_text = header_part.decode("utf-8", errors="replace")
for line in header_text.split("\r\n"):
if "name=" in line and "filename=" in line:
# Datei-Feld
for kv in line.split(";"):
kv = kv.strip()
if kv.startswith("filename="):
filename = kv[len("filename="):].strip('"')
file_data = body_part
elif "name=" in line:
# Text-Feld
name = line.split("name=")[1].strip().strip('"')
fields[name] = body_part.decode("utf-8", errors="replace")
return file_data, filename, fields
def do_GET(self):
if self.path == "/status":
model_ok = os.path.isfile(WHISPER_MODEL)
cli_ok = os.path.isfile(WHISPER_CLI)
self._send_json(200, {
"ready": model_ok and cli_ok,
"model": WHISPER_MODEL,
"model_exists": model_ok,
"whisper_cli": WHISPER_CLI,
"whisper_cli_exists": cli_ok,
"threads": WHISPER_THREADS,
"language": WHISPER_LANGUAGE,
"ffmpeg": FFMPEG_BIN,
"ffmpeg_exists": os.path.isfile(FFMPEG_BIN),
})
else:
self._send_json(404, {"error": "nicht gefunden"})
def do_POST(self):
if self.path != "/transcribe":
self._send_json(404, {"error": "nicht gefunden"})
return
content_type = self.headers.get("Content-Type", "")
try:
if "multipart/form-data" in content_type:
data = self._read_body()
file_data, filename, fields = self._parse_multipart(
data, content_type
)
if not file_data:
self._send_json(400, {"error": "Keine Datei im Request"})
return
language = fields.get("language")
prompt = fields.get("prompt")
temperature = fields.get("temperature")
if temperature:
temperature = float(temperature)
else:
# Raw body (direkte Audio-Daten)
file_data = self._read_body()
filename = self.headers.get("X-Filename", "audio.wav")
language = self.headers.get("X-Language")
prompt = self.headers.get("X-Prompt")
temperature = self.headers.get("X-Temperature")
if temperature:
temperature = float(temperature)
if not file_data:
self._send_json(400, {"error": "Leerer Request-Body"})
return
# Audio vorbereiten
audio_path = _prepare_audio(file_data, filename)
try:
result = transcribe(
audio_path,
language=language,
prompt=prompt,
temperature=temperature,
)
finally:
os.unlink(audio_path)
self._send_json(200, result)
except ValueError as e:
self._send_json(400, {"error": str(e)})
except subprocess.TimeoutExpired:
self._send_json(504, {"error": "Transkription-Timeout"})
except Exception as e:
log.exception("Transkriptions-Fehler")
self._send_json(500, {"error": str(e)})
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
log.info(
"STT-Worker startet: host=%s port=%d model=%s threads=%d lang=%s",
HOST, PORT, WHISPER_MODEL, WHISPER_THREADS, WHISPER_LANGUAGE,
)
if not os.path.isfile(WHISPER_MODEL):
log.warning("Modell nicht gefunden: %s", WHISPER_MODEL)
if not os.path.isfile(WHISPER_CLI):
log.warning("whisper-cli nicht gefunden: %s", WHISPER_CLI)
server = ThreadingHTTPServer((HOST, PORT), STTHandler)
log.info("STT-Worker lauscht auf %s:%d", HOST, PORT)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()