tts-voice-creator-clone-and.../guided_installer.py
mARTin-B78 631ca16290 Studio Notebook redesign: warm cream theme, sidebar, multi-file static structure
- New light UI: fixed 220px sidebar, single scrolling page, 8 named sections
- Static files split by concern: style.css, app.js, loader.js, nav.js
- Each page section is its own partial in static/sections/s-*.html
- loader.js fetches all section partials in parallel, then loads app.js and nav.js
- All original functionality, element IDs, and API endpoints preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:53:01 +02:00

399 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import os
import platform
import queue
import shutil
import subprocess
import threading
import webbrowser
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
ROOT = Path(__file__).resolve().parent
GENERATED_DIR = ROOT / "installer" / "generated"
COMPOSE_PATH = GENERATED_DIR / "docker-compose.guided.yml"
DOCKER_DOCS = "https://docs.docker.com/engine/install/"
OPEN_WEBUI_DOCS = "https://docs.openwebui.com/features/audio/"
MODEL_EXPLANATIONS = {
"clone": (
"Voice Clone",
"Uses recorded reference voices from the Voice Library. This is the everyday server for exported voices and Open WebUI.",
"faster-qwen3-tts",
"8020",
"/models/Qwen3-TTS",
),
"design": (
"Voice Design",
"Generates a voice from a written description. Great for prototyping characters without a recording.",
"faster-qwen3-tts-voicedesign",
"8021",
"/models/Qwen3-TTS-VoiceDesign",
),
"custom": (
"Custom Voice",
"Uses Qwen3 CustomVoice speaker IDs and optional instructions. Useful when you already maintain named model speakers.",
"faster-qwen3-tts-customvoice",
"8022",
"/models/Qwen3-TTS-CustomVoice",
),
}
class GuidedInstaller(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("TTS Voice Creator Guided Installer")
self.geometry("1120x780")
self.minsize(920, 680)
self.log_queue: queue.Queue[str] = queue.Queue()
self._build_vars()
self._build_ui()
self.after(120, self._drain_log)
self.check_environment()
def _build_vars(self) -> None:
home = Path.home()
self.voice_dir = tk.StringVar(value=str(home / "TTS_Voices"))
self.model_dir = tk.StringVar(value=str(home / "TTS_Models"))
self.creator_port = tk.StringVar(value="7860")
self.network_name = tk.StringVar(value="tts_net")
self.tts_image = tk.StringVar(value="faster-qwen3-tts-dgx-spark:v4")
self.install_portainer = tk.BooleanVar(value=False)
self.model_enabled = {key: tk.BooleanVar(value=(key == "clone")) for key in MODEL_EXPLANATIONS}
self.model_ports = {key: tk.StringVar(value=meta[3]) for key, meta in MODEL_EXPLANATIONS.items()}
self.model_paths = {key: tk.StringVar(value=str(Path(self.model_dir.get()) / Path(meta[4]).name)) for key, meta in MODEL_EXPLANATIONS.items()}
self.extra_tts_name = tk.StringVar(value="")
self.extra_tts_url = tk.StringVar(value="")
def _build_ui(self) -> None:
self.columnconfigure(0, weight=0)
self.columnconfigure(1, weight=1)
self.rowconfigure(0, weight=1)
left = ttk.Frame(self, padding=14)
left.grid(row=0, column=0, sticky="ns")
right = ttk.Frame(self, padding=14)
right.grid(row=0, column=1, sticky="nsew")
right.columnconfigure(0, weight=1)
right.rowconfigure(1, weight=1)
ttk.Label(left, text="Install Checks", font=("", 15, "bold")).pack(anchor="w")
self.status = tk.Text(left, width=38, height=15, wrap="word")
self.status.pack(fill="x", pady=(8, 12))
ttk.Button(left, text="Re-check this computer", command=self.check_environment).pack(fill="x", pady=3)
ttk.Button(left, text="Open Docker install guide", command=lambda: webbrowser.open(DOCKER_DOCS)).pack(fill="x", pady=3)
ttk.Button(left, text="Install Docker on Linux", command=self.install_docker_linux).pack(fill="x", pady=3)
ttk.Button(left, text="Install Portainer", command=self.install_portainer_now).pack(fill="x", pady=3)
ttk.Button(left, text="Generate compose file", command=self.generate_compose).pack(fill="x", pady=(14, 3))
ttk.Button(left, text="Install / update selected stack", command=self.install_stack).pack(fill="x", pady=3)
ttk.Button(left, text="Open Open WebUI audio guide", command=lambda: webbrowser.open(OPEN_WEBUI_DOCS)).pack(fill="x", pady=(14, 3))
nb = ttk.Notebook(right)
nb.grid(row=0, column=0, sticky="nsew")
right.rowconfigure(0, weight=1)
self._setup_tab(nb)
self._models_tab(nb)
self._openwebui_tab(nb)
self._log_tab(nb)
def _setup_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.columnconfigure(1, weight=1)
nb.add(tab, text="1. Basics")
rows = [
("Voice library folder", self.voice_dir, True),
("Model cache / LLM folder", self.model_dir, True),
("Creator app port", self.creator_port, False),
("Docker network", self.network_name, False),
("Qwen TTS Docker image", self.tts_image, False),
]
for row, (label, var, browse) in enumerate(rows):
ttk.Label(tab, text=label).grid(row=row, column=0, sticky="w", pady=6)
ttk.Entry(tab, textvariable=var).grid(row=row, column=1, sticky="ew", pady=6, padx=8)
if browse:
ttk.Button(tab, text="Browse", command=lambda v=var: self.pick_dir(v)).grid(row=row, column=2, pady=6)
ttk.Checkbutton(tab, text="Also install Portainer web UI", variable=self.install_portainer).grid(row=len(rows), column=1, sticky="w", pady=8)
text = (
"What these pieces do:\n\n"
"Docker runs the app and TTS servers in containers.\n"
"Portainer is optional. It gives non-command-line users a web dashboard for containers.\n"
"The voice folder stores cloned voices and metadata.\n"
"The model folder stores large Qwen model files, ideally on a fast disk with enough space."
)
ttk.Label(tab, text=text, wraplength=680, justify="left").grid(row=len(rows)+1, column=0, columnspan=3, sticky="ew", pady=16)
def _models_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.columnconfigure(2, weight=1)
nb.add(tab, text="2. Qwen Models")
ttk.Label(tab, text="Install", font=("", 10, "bold")).grid(row=0, column=0, sticky="w")
ttk.Label(tab, text="Model", font=("", 10, "bold")).grid(row=0, column=1, sticky="w")
ttk.Label(tab, text="Model location", font=("", 10, "bold")).grid(row=0, column=2, sticky="w")
ttk.Label(tab, text="Port", font=("", 10, "bold")).grid(row=0, column=3, sticky="w")
for i, (key, meta) in enumerate(MODEL_EXPLANATIONS.items(), start=1):
title, desc, _container, _port, _path = meta
ttk.Checkbutton(tab, variable=self.model_enabled[key]).grid(row=i, column=0, sticky="nw", pady=8)
ttk.Label(tab, text=f"{title}\n{desc}", wraplength=260, justify="left").grid(row=i, column=1, sticky="w", pady=8, padx=8)
ttk.Entry(tab, textvariable=self.model_paths[key]).grid(row=i, column=2, sticky="ew", pady=8, padx=8)
ttk.Entry(tab, textvariable=self.model_ports[key], width=8).grid(row=i, column=3, sticky="w", pady=8)
ttk.Label(tab, text="Optional external TTS endpoint", font=("", 11, "bold")).grid(row=5, column=0, columnspan=4, sticky="w", pady=(22, 6))
ttk.Label(tab, text="Name").grid(row=6, column=0, sticky="w")
ttk.Entry(tab, textvariable=self.extra_tts_name).grid(row=6, column=1, sticky="ew", padx=8)
ttk.Label(tab, text="Base URL").grid(row=6, column=2, sticky="e")
ttk.Entry(tab, textvariable=self.extra_tts_url).grid(row=6, column=3, sticky="ew")
ttk.Label(tab, text="Use this if you already have another OpenAI-compatible TTS server. The creator can route to it later by setting the TTS API URL in Settings.", wraplength=760).grid(row=7, column=0, columnspan=4, sticky="w", pady=10)
def _openwebui_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.columnconfigure(0, weight=1)
nb.add(tab, text="3. Open WebUI")
self.openwebui_text = tk.Text(tab, height=24, wrap="word")
self.openwebui_text.grid(row=0, column=0, sticky="nsew")
ttk.Button(tab, text="Refresh instructions", command=self.refresh_openwebui_text).grid(row=1, column=0, sticky="e", pady=8)
self.refresh_openwebui_text()
def _log_tab(self, nb: ttk.Notebook) -> None:
tab = ttk.Frame(nb, padding=12)
tab.rowconfigure(0, weight=1)
tab.columnconfigure(0, weight=1)
nb.add(tab, text="Log")
self.log = tk.Text(tab, wrap="word")
self.log.grid(row=0, column=0, sticky="nsew")
def pick_dir(self, var: tk.StringVar) -> None:
chosen = filedialog.askdirectory(initialdir=var.get() or str(Path.home()))
if chosen:
var.set(chosen)
def log_line(self, msg: str) -> None:
self.log_queue.put(msg.rstrip() + "\n")
def _drain_log(self) -> None:
while True:
try:
msg = self.log_queue.get_nowait()
except queue.Empty:
break
self.log.insert("end", msg)
self.log.see("end")
self.after(120, self._drain_log)
def run_threaded(self, title: str, commands: list[list[str]]) -> None:
def worker():
self.log_line(f"\n== {title} ==")
for cmd in commands:
self.log_line("$ " + " ".join(cmd))
try:
proc = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True)
if proc.stdout:
self.log_line(proc.stdout)
if proc.stderr:
self.log_line(proc.stderr)
if proc.returncode:
self.log_line(f"Command exited with {proc.returncode}")
break
except Exception as exc:
self.log_line(f"Failed: {exc}")
break
threading.Thread(target=worker, daemon=True).start()
def command_ok(self, cmd: list[str]) -> bool:
try:
return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=6).returncode == 0
except Exception:
return False
def docker_names(self) -> set[str]:
try:
out = subprocess.check_output(["docker", "ps", "-a", "--format", "{{.Names}}"], text=True, timeout=8)
return {line.strip() for line in out.splitlines() if line.strip()}
except Exception:
return set()
def check_environment(self) -> None:
docker_bin = shutil.which("docker")
compose_ok = self.command_ok(["docker", "compose", "version"]) if docker_bin else False
daemon_ok = self.command_ok(["docker", "info"]) if docker_bin else False
names = self.docker_names() if daemon_ok else set()
lines = [
f"System: {platform.system()} {platform.release()}",
f"Docker command: {'found' if docker_bin else 'missing'}",
f"Docker daemon: {'running' if daemon_ok else 'not reachable'}",
f"Docker Compose plugin: {'found' if compose_ok else 'missing'}",
f"Portainer: {'installed' if any('portainer' in n for n in names) else 'not found'}",
f"Creator app: {'installed' if 'tts-voice-creator-clone-and-design' in names or 'tts-voice-creator' in names or 'voice-clone-factory' in names else 'not found'}",
f"Qwen clone TTS: {'installed' if 'faster-qwen3-tts' in names else 'not found'}",
f"Qwen Voice Design: {'installed' if 'faster-qwen3-tts-voicedesign' in names else 'not found'}",
f"Qwen Custom Voice: {'installed' if 'faster-qwen3-tts-customvoice' in names else 'not found'}",
]
self.status.delete("1.0", "end")
self.status.insert("end", "\n".join(lines))
def install_portainer_now(self) -> None:
if not messagebox.askyesno("Install Portainer", "Install or update Portainer CE with Docker?"):
return
self.run_threaded("Install Portainer", [
["docker", "volume", "create", "portainer_data"],
[
"docker", "run", "-d", "--name", "portainer", "--restart=always",
"-p", "9000:9000", "-p", "9443:9443",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", "portainer_data:/data",
"portainer/portainer-ce:latest",
],
])
def install_docker_linux(self) -> None:
if platform.system().lower() != "linux":
messagebox.showinfo("Docker installer", "This one-click installer is only for Linux. Use the Docker install guide for this operating system.")
return
if not messagebox.askyesno(
"Install Docker",
"Run Docker's official Linux convenience installer?\n\nThis may ask for sudo privileges and needs internet access.",
):
return
self.run_threaded("Install Docker", [
["curl", "-fsSL", "https://get.docker.com", "-o", "/tmp/get-docker.sh"],
["sh", "/tmp/get-docker.sh"],
["docker", "version"],
])
def generate_compose(self) -> None:
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
content = self.compose_text()
COMPOSE_PATH.write_text(content)
self.log_line(f"Generated {COMPOSE_PATH}")
messagebox.showinfo("Compose generated", f"Generated:\n{COMPOSE_PATH}")
def install_stack(self) -> None:
self.generate_compose()
if self.install_portainer.get():
self.install_portainer_now()
if not messagebox.askyesno("Install stack", "Run docker compose up -d for the generated stack?"):
return
self.run_threaded("Install selected stack", [
["docker", "network", "create", self.network_name.get()],
["docker", "compose", "-f", str(COMPOSE_PATH), "up", "-d", "--build"],
])
def compose_text(self) -> str:
network = self.network_name.get().strip() or "tts_net"
voice_dir = self.voice_dir.get().strip() or "./voices"
model_dir = self.model_dir.get().strip() or "./models"
image = self.tts_image.get().strip() or "faster-qwen3-tts-dgx-spark:v4"
creator_port = self.creator_port.get().strip() or "7860"
services = [
"services:",
" tts-voice-creator-clone-and-design:",
" build:",
" context: ../..",
" dockerfile: Dockerfile",
" image: tts-voice-creator-clone-and-design:latest",
" container_name: tts-voice-creator-clone-and-design",
" restart: unless-stopped",
" ports:",
f" - \"{creator_port}:7860\"",
" volumes:",
f" - {voice_dir}:/voices:rw",
" - tts-voice-creator-clone-and-design:/home/app/.config/tts-voice-creator",
" environment:",
" - PYTHONUNBUFFERED=1",
" - VOICES_DIR=/voices",
" - OUTPUT_DIR=/voices/active_voices",
" - TTS_CONTAINER_NAME=faster-qwen3-tts",
" extra_hosts:",
" - \"host.docker.internal:host-gateway\"",
" networks:",
f" - {network}",
"",
]
for key, meta in MODEL_EXPLANATIONS.items():
if not self.model_enabled[key].get():
continue
_title, _desc, container, _default_port, _default_model = meta
host_port = self.model_ports[key].get().strip() or _default_port
model_path = self.model_paths[key].get().strip() or str(Path(model_dir) / Path(_default_model).name)
services.extend(self.tts_service_yaml(key, container, image, host_port, model_path, network))
services.extend([
"volumes:",
" tts-voice-creator-clone-and-design:",
"",
"networks:",
f" {network}:",
" external: true",
"",
])
return "\n".join(services)
def tts_service_yaml(self, key: str, container: str, image: str, host_port: str, model_path: str, network: str) -> list[str]:
lines = [
f" {container}:",
f" image: {image}",
f" container_name: {container}",
" restart: unless-stopped",
" ports:",
f" - \"{host_port}:8000\"",
" volumes:",
f" - {model_path}:/models/{Path(model_path).name}:ro",
f" - {ROOT / 'support' / 'faster-qwen3-tts-config'}:/config:ro",
" environment:",
" - PYTHONUNBUFFERED=1",
" networks:",
f" - {network}",
]
if key == "design":
lines.extend([
" command: python /config/run_voicedesign_server.py --host 0.0.0.0 --port 8000 --model /models/" + Path(model_path).name,
])
elif key == "custom":
lines.extend([
" command: python /config/run_customvoice_server.py --host 0.0.0.0 --port 8000 --model /models/" + Path(model_path).name,
])
lines.append("")
return lines
def refresh_openwebui_text(self) -> None:
creator = f"http://YOUR_HOST:{self.creator_port.get() or '7860'}/v1"
text = f"""Open WebUI setup
Without routing:
1. Open Open WebUI Admin Settings -> Audio.
2. Choose OpenAI-compatible TTS.
3. Base URL: http://YOUR_HOST:{self.model_ports['clone'].get() or '8020'}/v1
4. API key: dummy
5. Model: qwen3-tts or tts-1
6. Voice: one real active voice, such as EN_F_Anna.
With routing:
1. Open Open WebUI Admin Settings -> Audio.
2. Choose OpenAI-compatible TTS.
3. Base URL: {creator}
4. API key: dummy
5. Model: tts-1
6. Voice: default
7. In TTS Voice Creator -> Routing, map:
Open WebUI + default + EN -> EN_F_YourEnglishVoice
Open WebUI + default + DE -> DE_M_YourGermanVoice
What to tell users:
- Voice Clone is the normal production path for recorded voices.
- Voice Design is for creating voices from descriptions.
- Custom Voice is for named Qwen speakers/custom voice model workflows.
- Portainer is optional. Use it when you want a browser dashboard for containers.
"""
self.openwebui_text.delete("1.0", "end")
self.openwebui_text.insert("end", text)
if __name__ == "__main__":
GuidedInstaller().mainloop()