Files
meetrec/meetrec.py
T
fegger a1df9d4037 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
2026-09-07 12:59:14 +02:00

686 lines
27 KiB
Python

#!/usr/bin/env python3
"""
meetrec.py — record an in-person meeting and transcribe it with Whisper.
Records from any PipeWire/ALSA input (microphone, USB conference mic,
USB audio interface, or a Pulse/PipeWire sink "monitor") while optionally
printing a live rolling transcript. On Ctrl+C it stops cleanly and runs a
final, higher-quality full transcription.
Outputs (with -o meeting):
meeting.wav raw recording, mono WAV at the input's native rate
meeting.live.srt live rolling transcript (only when --live > 0)
meeting.txt final transcript
meeting.srt final transcript with timestamps
meeting.json final transcript, structured
Audio is recorded at the input device's native sample rate; both the live
window and the engines get it resampled to Whisper's 16 kHz internally.
Setup on Arch:
sudo pacman -S python pipewire pipewire-pulse
python -m venv .venv && source .venv/bin/activate
pip install numpy sounddevice faster-whisper
Usage:
python meetrec.py --list-sources
python meetrec.py -s "Conference Mic" --model small --live 8 -o meeting
python meetrec.py -s monitor --model base --language en # record system audio
python meetrec.py --engine whisper-cpp -o meeting # GPU via Vulkan (AMD/NVIDIA)
Ctrl+C to stop -> final transcripts are written.
"""
import argparse
import json
import os
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
import wave
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import sounddevice as sd
SR = 16000 # sample rate — what Whisper wants
LIVE_WINDOW = 60 # seconds of rolling audio kept for live transcription
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
def fmt_hms(t: float) -> str:
h, rem = divmod(int(t), 3600)
m, s = divmod(rem, 60)
return f"{h:02d}:{m:02d}:{s:02d}"
def fmt_srt(t: float) -> str:
ms = int(round((t - int(t)) * 1000))
h, rem = divmod(int(t) + (1 if ms >= 1000 else 0), 3600)
m, s = divmod(rem, 60)
if ms >= 1000:
ms -= 1000
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def write_srt(path: str, items) -> None:
with open(path, "w") as f:
for i, (a, b, text) in enumerate(items, 1):
f.write(f"{i}\n{fmt_srt(a)} --> {fmt_srt(b)}\n{text}\n\n")
def resample_16k(audio: np.ndarray, rate: int) -> np.ndarray:
"""Linearly resample mono float32 audio to 16 kHz (Whisper's rate).
Only needed for the live rolling buffer, which is fed to the engines as
a raw array (they assume 16 kHz there). WAV files are resampled by the
engines themselves.
"""
audio = np.asarray(audio, dtype=np.float32)
if rate == SR or audio.size == 0:
return audio
n_out = max(1, int(round(audio.size * SR / rate)))
t_out = np.arange(n_out, dtype=np.float64) * (rate / SR)
return np.interp(t_out, np.arange(audio.size, dtype=np.float64),
audio).astype(np.float32)
def list_sources() -> None:
print("PortAudio (PipeWire/Pulse/ALSA) input devices:\n")
for i, d in enumerate(sd.query_devices()):
if d["max_input_channels"] > 0:
print(f" {i:>3} {d['name']}\n"
f" api={d['hostapi']} in={d['max_input_channels']}ch "
f"rate={d['default_samplerate']:.0f}")
pactl = shutil.which("pactl")
if pactl:
print("\nPulseAudio/PipeWire sources (pactl):\n")
try:
out = subprocess.run(
[pactl, "list", "short", "sources"],
capture_output=True, text=True, timeout=5).stdout.strip()
for line in out.splitlines():
print(" " + line)
except Exception:
pass
print("\nTip: sink monitors (record what the computer plays) appear as "
"\"<sink name>.monitor\". Match them with -s <part of name>.")
def resolve_source(name: str):
"""Return a sounddevice device index, or None for the default device."""
if name in ("default", None, ""):
return None
inputs = [(i, d) for i, d in enumerate(sd.query_devices())
if d["max_input_channels"] > 0]
matches = [(i, d) for i, d in inputs if name.lower() in d["name"].lower()]
if not matches:
print(f"error: no input device matching {name!r}. "
f"Run with --list-sources.", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"note: multiple devices match {name!r}, using: "
f"{matches[0][1]['name']}", file=sys.stderr)
idx, dev = matches[0]
print(f"recording from: [{idx}] {dev['name']} ({dev['hostapi']})")
return idx
# --------------------------------------------------------------------------
# Recording
# --------------------------------------------------------------------------
class Recorder:
"""Records mono float32 at the input's native rate to a WAV file and
keeps a rolling buffer for live transcription."""
def __init__(self, device, wav_path: str):
self.device = device
self.wav_path = wav_path
dev = sd.query_devices(kind="input") if device is None \
else sd.query_devices(device)
self.rate = int(dev["default_samplerate"]) # input's native rate
self.q: "queue.Queue" = queue.Queue()
self.lock = threading.Lock()
self.buf = np.zeros(0, dtype=np.float32) # rolling window
self.base = 0 # samples recorded before buf
self.total = 0 # total samples recorded
self.peak = 0.0 # peak amplitude of last block
self.stop_event = threading.Event()
self.stream = None
self._writer = threading.Thread(target=self._write, daemon=True)
self._writer.start()
def _callback(self, indata, frames, time_info, status):
if status:
print(f"[rec] status: {status}", file=sys.stderr)
self.peak = float(np.max(np.abs(indata[:, 0]))) if frames else 0.0
self.q.put(indata.copy())
with self.lock:
block = indata[:, 0].astype(np.float32, copy=False)
self.buf = np.append(self.buf, block)
self.total += len(block)
limit = LIVE_WINDOW * self.rate
if len(self.buf) > limit:
drop = len(self.buf) - limit
self.buf = self.buf[drop:]
self.base += drop
def duration(self) -> float:
with self.lock:
return self.total / self.rate
def _write(self):
with wave.open(self.wav_path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(self.rate)
while True:
block = self.q.get()
if block is None:
break
pcm = (np.clip(block[:, 0], -1.0, 1.0) * 32767).astype(np.int16)
w.writeframes(pcm.tobytes())
def snapshot(self):
"""Return (rolling window resampled to 16 kHz, its start time in s)."""
with self.lock:
return resample_16k(self.buf, self.rate), self.base / self.rate
def start(self):
self.stream = sd.InputStream(
samplerate=self.rate, channels=1, dtype="float32",
device=self.device, callback=self._callback)
self.stream.start()
def stop(self):
if self.stop_event.is_set():
return # idempotent: the GUI close path may call this twice
self.stop_event.set()
self.q.put(None)
if self.stream:
self.stream.stop()
self.stream.close()
# The writer must drain the queue and close the WAV before we return,
# otherwise the file can be truncated. Join in slices instead of one
# long timeout so a slow disk still gets the full flush; only warn
# after a total of 60 s (effectively never in practice).
waited = 0.0
while self._writer.is_alive() and waited < 60:
self._writer.join(timeout=2.0)
waited += 2.0
if self._writer.is_alive():
print("warning: WAV writer still running after 60 s; "
"the file may be truncated", file=sys.stderr)
# --------------------------------------------------------------------------
# Transcription
# --------------------------------------------------------------------------
def live_worker(model, rec: Recorder, args, live_srt_path: str,
stop: threading.Event, on_line=None):
"""Transcribes the rolling window every `args.live` seconds and appends
newly-finalized segments to the live .srt. If `on_line` is given, new
lines are delivered to it (a, text) instead of being printed."""
def emit(a, text):
if on_line is not None:
on_line(a, text)
else:
print(f"[{fmt_hms(a)}] {text}", flush=True)
items = [] # (abs_start, abs_end, text) for the live .srt
last_end = 0.0
if on_line is None:
print("live transcription on (Ctrl+C to stop)", flush=True)
while not stop.is_set():
stop.wait(args.live)
if stop.is_set():
break
buf, base_s = rec.snapshot() # 16 kHz audio, absolute start time
if len(buf) < 2 * SR: # need at least 2 s of audio
continue
try:
segs, _info = model.transcribe(
buf, beam_size=1, vad_filter=True, language=args.language)
except Exception as e:
print(f"[live] transcription error: {e}", file=sys.stderr)
continue
added = False
for s in segs:
a, b = base_s + s.start, base_s + s.end
text = s.text.strip()
if not text or b < last_end:
continue
items.append((a, b, text))
last_end = max(last_end, b)
emit(a, text)
added = True
if added:
write_srt(live_srt_path, items)
def final_transcribe(model, wav_path: str, out_stem: str, language,
on_info=None, on_segment=None, on_done=None):
"""Full-pass transcription of the whole recording.
on_info(msg), on_segment(start, end, text) and on_done() are optional
callbacks; when absent the same progress is printed to stdout."""
def info(msg):
if on_info is not None:
on_info(msg)
else:
print(msg, flush=True)
info("\nRunning final transcription (this can take a while)...")
segments, info_ = model.transcribe(
wav_path, beam_size=5, vad_filter=True, language=language,
vad_parameters=dict(min_silence_duration_ms=500))
info(f"language: {info_.language} (p={info_.language_probability:.2f}) "
f"duration: {fmt_hms(info_.duration)}")
items = []
for s in segments: # generator: streams as it decodes
text = s.text.strip()
items.append((s.start, s.end, text))
if on_segment is not None:
on_segment(s.start, s.end, text)
else:
print(f"[{fmt_hms(s.start)}] {text}", flush=True)
full = " ".join(t for _, _, t in items)
with open(out_stem + ".txt", "w") as f:
f.write(full + "\n")
write_srt(out_stem + ".srt", items)
with open(out_stem + ".json", "w") as f:
json.dump({
"language": info_.language,
"duration": round(info_.duration, 2),
"segments": [{"start": round(a, 2), "end": round(b, 2), "text": t}
for a, b, t in items],
}, f, ensure_ascii=False, indent=2)
info(f"\nwrote: {out_stem}.wav {out_stem}.txt {out_stem}.srt {out_stem}.json")
if on_done is not None:
on_done()
# --------------------------------------------------------------------------
# Engines
# --------------------------------------------------------------------------
#
# Two interchangeable transcription backends:
# 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)
# where `audio` is a 1-D float32 @16 kHz array or a WAV path, segments have
# .start/.end/.text and info has .language/.language_probability/.duration.
WHISPER_CPP_MODEL_FILES = {
"tiny": "ggml-tiny.bin",
"base": "ggml-base.bin",
"small": "ggml-small.bin",
"medium": "ggml-medium.bin",
"large-v3": "ggml-large-v3.bin",
}
WHISPER_CPP_MODEL_URL = ("https://huggingface.co/ggerganov/whisper.cpp/"
"resolve/main/{name}")
WHISPER_CPP_VAD_FILE = "ggml-silero-v6.2.0.bin"
WHISPER_CPP_VAD_URL = ("https://huggingface.co/ggml-org/whisper-vad/"
"resolve/main/" + WHISPER_CPP_VAD_FILE)
def _download_file(url: str, dest: Path) -> None:
tmp = dest.with_suffix(dest.suffix + ".part")
req = urllib.request.Request(url, headers={"User-Agent": "meetrec"})
with urllib.request.urlopen(req, timeout=60) as r:
total = int(r.headers.get("Content-Length") or 0)
done = 0
with open(tmp, "wb") as f:
while True:
chunk = r.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
done += len(chunk)
if total:
print(f"\r {done/1e6:.0f}/{total/1e6:.0f} MB", end="",
flush=True)
if total:
print(flush=True)
os.replace(tmp, dest)
def find_whisper_cpp_bin() -> str:
"""Locate the whisper.cpp 'whisper-cli' binary."""
candidates = [
os.environ.get("WHISPER_CPP_BIN"),
shutil.which("whisper-cli"),
str(Path.home() / ".local/share/meetrec/whisper-cpp/bin/whisper-cli"),
]
for c in candidates:
if c and os.path.isfile(c) and os.access(c, os.X_OK):
return c
raise RuntimeError(
"whisper-cli not found. Build whisper.cpp (e.g. './install.sh "
"--whisper-cpp', or cmake -DWHISPER_VULKAN=ON) or set WHISPER_CPP_BIN "
"to the binary.")
def _whisper_cpp_cache() -> Path:
cache = Path(os.environ.get(
"MEETREC_CACHE", str(Path.home() / ".cache/meetrec")))
d = cache / "whisper-cpp"
d.mkdir(parents=True, exist_ok=True)
return d
def _cached_ggml(fname: str, url: str) -> str:
"""Return the path of a cached GGML file, downloading it if missing."""
p = _whisper_cpp_cache() / fname
if p.exists() and p.stat().st_size > 0:
return str(p)
print(f"downloading {fname} to {p} ...", flush=True)
_download_file(url, p)
return str(p)
def whisper_cpp_model_path(model_name: str) -> str:
"""Return the local path of a whisper.cpp GGML model, downloading it."""
try:
fname = WHISPER_CPP_MODEL_FILES[model_name]
except KeyError:
raise RuntimeError(
f"unknown model {model_name!r}; use one of: "
f"{', '.join(WHISPER_CPP_MODEL_FILES)}")
return _cached_ggml(fname, WHISPER_CPP_MODEL_URL.format(name=fname))
def write_wav_float(path: str, audio: np.ndarray) -> None:
"""Write a 1-D float32 mono @16 kHz array as 16-bit PCM WAV."""
audio = np.asarray(audio, dtype=np.float32).ravel()
pcm = (np.clip(audio, -1.0, 1.0) * 32767).astype(np.int16)
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(pcm.tobytes())
def _parse_ts(s: str) -> float:
"""whisper.cpp timestamp 'HH:MM:SS,mmm' -> seconds."""
h, m, rest = s.split(":")
sec, ms = rest.split(",")
return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms) / 1000.0
class WhisperCppEngine:
"""Transcription via the whisper.cpp 'whisper-cli' binary.
Build whisper.cpp with -DWHISPER_VULKAN=ON to run on a GPU (Radeon,
Intel, or NVIDIA); it falls back to CPU otherwise. GPU vs. CPU is decided
by the build, so --device/--compute-type do not apply. vad_filter=True
enables whisper.cpp's Silero VAD (auto-downloaded); vad_parameters are
accepted but not mapped.
"""
def __init__(self, model_name: str, cli: str = None):
self.model_name = model_name
self.cli = cli or find_whisper_cpp_bin()
self.model_path = whisper_cpp_model_path(model_name)
def transcribe(self, audio, beam_size=5, vad_filter=False,
language=None, vad_parameters=None):
# vad_parameters accepted for interface compatibility; Silero VAD uses
# whisper.cpp's own defaults
with tempfile.TemporaryDirectory(prefix="meetrec-") as td:
if isinstance(audio, (str, os.PathLike)):
wav = str(audio)
with wave.open(wav, "rb") as w:
duration = w.getnframes() / w.getframerate()
else:
wav = os.path.join(td, "in.wav")
write_wav_float(wav, audio)
duration = len(audio) / SR
cmd = [self.cli, "-m", self.model_path, "-f", wav,
"-oj", "-of", os.path.join(td, "out"),
"-bs", str(max(1, int(beam_size))), "-np",
"-l", language or "auto"]
if vad_filter:
cmd += ["--vad", "-vm", _cached_ggml(
WHISPER_CPP_VAD_FILE, WHISPER_CPP_VAD_URL)]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(
(proc.stderr or proc.stdout).strip()
or f"whisper-cli exited {proc.returncode}")
with open(os.path.join(td, "out.json")) as f:
data = json.load(f)
result = data.get("result", {})
segs = []
for item in data.get("transcription", []):
off = item.get("offsets", {})
if "from" in off and "to" in off: # milliseconds (current)
a, b = off["from"] / 1000.0, off["to"] / 1000.0
else: # 'HH:MM:SS,mmm' (older)
ts = item.get("timestamps", {})
a = _parse_ts(ts.get("from", "00:00:00,000"))
b = _parse_ts(ts.get("to", "00:00:00,000"))
text = item.get("text", "").strip()
if text:
segs.append(SimpleNamespace(start=a, end=b, text=text))
info = SimpleNamespace(
language=result.get("language") or language or "unknown",
language_probability=1.0, # not reported by whisper.cpp
duration=duration,
)
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", 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,
compute_type=compute_type)
raise RuntimeError(f"unknown engine {engine!r}; use 'faster-whisper' "
"or 'whisper-cpp'")
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="Record a meeting and transcribe it with Whisper "
"(PipeWire / ALSA via PortAudio).")
ap.add_argument("-s", "--source", default="default",
help="input to record: 'default' or a substring of a "
"device name (see --list-sources)")
ap.add_argument("-m", "--model", default="small",
help="Whisper model: tiny/base/small/medium/large-v3 "
"(default: small)")
ap.add_argument("-o", "--output", default="meeting",
help="output file stem (default: meeting)")
ap.add_argument("--live", type=int, default=8, metavar="SEC",
help="live transcription interval in seconds; 0 disables "
"(default: 8)")
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", "whisper-server"],
help="transcription backend (default: faster-whisper). "
"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")
ap.add_argument("--compute-type", default="int8",
help="int8 / int8_float16 / float16 / float32 "
"(default: int8); faster-whisper only")
ap.add_argument("--list-sources", action="store_true",
help="list available input devices and exit")
args = ap.parse_args()
if args.list_sources:
list_sources()
return
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,
server_url=args.server_url)
except Exception as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(1)
rec = Recorder(device, args.output + ".wav")
rec.start()
print(f"recording to {args.output}.wav at {rec.rate} Hz "
f"(Ctrl+C to stop)", flush=True)
stop = threading.Event()
worker = None
if args.live > 0:
worker = threading.Thread(
target=live_worker, args=(model, rec, args,
args.output + ".live.srt", stop),
daemon=True)
worker.start()
try:
while not stop.is_set():
stop.wait(0.5)
except KeyboardInterrupt:
print("\nstopping...", flush=True)
finally:
stop.set()
rec.stop()
if worker:
worker.join(timeout=60)
final_transcribe(model, args.output + ".wav", args.output, args.language)
if __name__ == "__main__":
main()