- 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>
399 lines
18 KiB
Python
Executable File
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()
|