r""" NeuTTS Gradio Studio - single-file Windows-friendly UI ====================================================== Start: py -3.11 -m venv .venv .\.venv\Scripts\Activate.ps1 pip install --upgrade pip "setuptools<81" wheel pip install -r requirements_neutts_clone_v3.txt Optional für GGUF-Modelle: pip install llama-cpp-python --prefer-binary --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu Optional für ONNX-Decoder: pip install "neutts[onnx]" Windows/eSpeak-ng, falls nötig: setx PHONEMIZER_ESPEAK_LIBRARY "C:\Program Files\eSpeak NG\libespeak-ng.dll" setx PHONEMIZER_ESPEAK_PATH "C:\Program Files\eSpeak NG" Ordnerstruktur neben dieser Datei: voices//reference.wav voices//reference.txt outputs/*.wav outputs/history.jsonl """ from __future__ import annotations import asyncio import json import logging import os import re import shutil import subprocess import sys import time import traceback from datetime import datetime from enum import Enum from json import JSONDecodeError from pathlib import Path from typing import Any, Dict, List, Tuple # Wichtig: Diese Umgebungsvariablen müssen vor dem Gradio-/HuggingFace-Import gesetzt werden. os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False") os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1") # Gradio 6 SSR macht auf Spaces aktuell teils AsyncIO-Cleanup-Noise. os.environ.setdefault("GRADIO_SSR_MODE", "False") def apply_windows_asyncio_policy() -> None: """Nutzt unter Windows den Selector-Loop, bevor Gradio/Uvicorn importiert werden.""" if sys.platform.startswith("win") and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) def install_windows_connection_reset_guard() -> None: """ Fängt nur den bekannten Windows-Proactor-Race-Condition-Fall ab. Hintergrund: Manche lokale Browser-/WebSocket-Verbindungen werden vom Client geschlossen, während Python den Socket im Proactor-Transport ebenfalls schließen will. Das ist bei Gradio/Uvicorn unter Windows nervig, aber nicht der TTS-Fehler. Andere ConnectionResetError-Fälle werden nicht geschluckt. """ if not sys.platform.startswith("win"): return try: import asyncio.proactor_events as proactor_events except ImportError: return transport_cls = getattr(proactor_events, "_ProactorBasePipeTransport", None) if transport_cls is None: return original = getattr(transport_cls, "_call_connection_lost", None) if original is None or getattr(original, "_neutts_guarded", False): return def guarded_call_connection_lost(self, exc): try: return original(self, exc) except ConnectionResetError as err: if getattr(err, "winerror", None) == 10054: return None raise guarded_call_connection_lost._neutts_guarded = True transport_cls._call_connection_lost = guarded_call_connection_lost def install_unix_invalid_fd_cleanup_guard() -> None: """ Linux/Hugging-Face-Guard gegen bekannten asyncio-Cleanup-Noise: ValueError: Invalid file descriptor: -1 Wichtig: Es wird nicht pauschal alles geschluckt. Wir fangen gezielt den Invalid-file-descriptor-Fall im Selector-Loop-Cleanup ab. Zusätzlich gibt es dieselbe frühe Absicherung in sitecustomize.py, die vor app.py geladen wird. """ if sys.platform.startswith("win"): return try: import asyncio.selector_events as selector_events selector_loop_cls = getattr(selector_events, "BaseSelectorEventLoop", None) original_remove_reader = getattr(selector_loop_cls, "_remove_reader", None) if selector_loop_cls else None if original_remove_reader is not None and not getattr(original_remove_reader, "_neutts_invalid_fd_guarded", False): def guarded_remove_reader(self, fd, *args, **kwargs): try: return original_remove_reader(self, fd, *args, **kwargs) except ValueError as exc: if "Invalid file descriptor" in str(exc): return False raise guarded_remove_reader._neutts_invalid_fd_guarded = True selector_loop_cls._remove_reader = guarded_remove_reader except (ImportError, AttributeError, TypeError): pass original_del = getattr(asyncio.BaseEventLoop, "__del__", None) if original_del is None or getattr(original_del, "_neutts_invalid_fd_guarded", False): return def guarded_event_loop_del(self): try: return original_del(self) except ValueError as exc: if "Invalid file descriptor" in str(exc): return None raise guarded_event_loop_del._neutts_invalid_fd_guarded = True asyncio.BaseEventLoop.__del__ = guarded_event_loop_del def configure_quiet_runtime_logging() -> None: """Reduziert nur bekannte Third-Party-Statuslogs, nicht unsere Fehlerausgaben.""" logging.getLogger("torch").setLevel(logging.ERROR) logging.getLogger("torch.utils._pytree").setLevel(logging.ERROR) logging.getLogger("huggingface_hub.file_download").setLevel(logging.ERROR) logging.getLogger("uvicorn.error").setLevel(logging.WARNING) logging.getLogger("uvicorn.access").setLevel(logging.WARNING) apply_windows_asyncio_policy() install_windows_connection_reset_guard() configure_quiet_runtime_logging() import gradio as gr import numpy as np import pandas as pd import soundfile as sf try: import librosa except ImportError: # pragma: no cover - wird nur gebraucht, wenn Stimmen normalisiert werden librosa = None import audio_fx import voice_persistence APP_DIR = Path(__file__).resolve().parent VOICES_DIR = APP_DIR / "voices" OUTPUTS_DIR = APP_DIR / "outputs" MEDIA_WORK_DIR = OUTPUTS_DIR / "prepared_media" HISTORY_FILE = OUTPUTS_DIR / "history.jsonl" SAMPLE_RATE = 24_000 HISTORY_COLUMNS = ["Zeit", "Modell", "Stimme", "Zeichen", "Dauer", "Audio", "RTF", "Output"] APP_HANDLED_ERRORS = (OSError, RuntimeError, ValueError, TypeError, ImportError, AttributeError) AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac", ".wma"} VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".mpeg", ".mpg"} VOICES_DIR.mkdir(parents=True, exist_ok=True) OUTPUTS_DIR.mkdir(parents=True, exist_ok=True) MEDIA_WORK_DIR.mkdir(parents=True, exist_ok=True) # Modelle: du kannst jederzeit weitere Hugging-Face-Repo-IDs ergänzen. MODEL_PRESETS: Dict[str, str] = { "Nano German · klein/schnell/deutsch": "neuphonic/neutts-nano-german", "Nano German Q4 GGUF · deutsch/CPU/llama-cpp": "neuphonic/neutts-nano-german-q4-gguf", "Nano Multilingual · klein/mehrsprachig": "neuphonic/neutts-nano", "Nano Multilingual Q4 GGUF · CPU/llama-cpp": "neuphonic/neutts-nano-q4-gguf", "Air · gross/bessere Qualität": "neuphonic/neutts-air", "Air Q4 GGUF · gross/CPU/llama-cpp": "neuphonic/neutts-air-q4-gguf", "Air Q8 GGUF · gross/besser/mehr RAM": "neuphonic/neutts-air-q8-gguf", } CODEC_PRESETS: Dict[str, str] = { "NeuCodec · Standard": "neuphonic/neucodec", "NeuCodec ONNX Decoder · optional/schneller": "neuphonic/neucodec-onnx-decoder", } WHISPER_MODEL_PRESETS: Dict[str, str] = { "Tiny · schnell/ungenauer": "tiny", "Base · guter Start/CPU": "base", "Small · besser/langsamer": "small", "Medium · deutlich besser/sehr langsam CPU": "medium", } # Cache, damit Modelle nicht bei jeder Generierung neu geladen werden. _MODEL_CACHE: Dict[Tuple[str, str, str, str], Any] = {} _REF_CACHE: Dict[Tuple[str, int, str, str], Any] = {} _WHISPER_CACHE: Dict[Tuple[str, str, str], Any] = {} _REFERENCE_WAV_BY_NAME: Dict[str, str] = {} def patch_torch_pytree_enum_register_constant() -> str: """ Verhindert die kommende PyTorch-Deprecation sauber an der Ursache. Einige Dependencies rufen torch.utils._pytree.register_constant() noch für Enum-Klassen auf. PyTorch unterstützt Enum-Klassen inzwischen nativ. Deshalb wird der alte Call für Enum-Subclasses zu einem No-op gemacht; alle anderen register_constant-Aufrufe laufen unverändert weiter. """ try: from torch.utils import _pytree except ImportError as exc: return f"⚠️ PyTorch pytree konnte nicht geladen werden: {exc}" original = getattr(_pytree, "register_constant", None) if original is None: return "ℹ️ PyTorch register_constant nicht vorhanden; kein Patch nötig." if getattr(original, "_neutts_enum_guarded", False): return "✅ PyTorch pytree Enum-Guard war bereits aktiv." def safe_register_constant(*args, **kwargs): target = args[0] if args else kwargs.get("cls") if isinstance(target, type) and issubclass(target, Enum): return None return original(*args, **kwargs) safe_register_constant._neutts_enum_guarded = True _pytree.register_constant = safe_register_constant return "✅ PyTorch pytree Enum-Guard aktiv." def patch_torch_weight_norm() -> str: """ Nutzt die neue PyTorch-Parametrization-API, bevor NeuTTS/NeuCodec geladen wird. """ try: import torch from torch.nn.utils import parametrizations torch.nn.utils.weight_norm = parametrizations.weight_norm return "✅ PyTorch weight_norm auf parametrizations.weight_norm gemappt." except (ImportError, AttributeError, TypeError) as exc: return f"⚠️ PyTorch weight_norm konnte nicht gepatcht werden: {exc}" TORCH_PATCH_STATUS = "\n".join([ patch_torch_pytree_enum_register_constant(), patch_torch_weight_norm(), ]) def safe_name(value: str | None, fallback: str = "voice") -> str: value = (value or "").strip() value = re.sub(r"[^a-zA-Z0-9_.\- äöüÄÖÜß]+", "_", value) value = value.strip(" ._-") return value or fallback def timestamp() -> str: return datetime.now().strftime("%Y%m%d_%H%M%S") def format_seconds(seconds: float) -> str: if seconds < 60: return f"{seconds:.2f} s" minutes = int(seconds // 60) rest = seconds - minutes * 60 return f"{minutes} min {rest:.1f} s" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8").strip() def write_jsonl(row: dict[str, Any]) -> None: with HISTORY_FILE.open("a", encoding="utf-8") as f: f.write(json.dumps(row, ensure_ascii=False) + "\n") def load_history(limit: int = 30) -> pd.DataFrame: if not HISTORY_FILE.exists(): return pd.DataFrame(columns=HISTORY_COLUMNS) rows: List[List[str]] = [] lines = HISTORY_FILE.read_text(encoding="utf-8").splitlines()[-limit:] for line in reversed(lines): try: item = json.loads(line) except JSONDecodeError: continue rows.append( [ str(item.get("time", "")), str(item.get("model", "")), str(item.get("voice", "")), str(item.get("chars", "")), str(item.get("elapsed", "")), str(item.get("audio_duration", "")), str(item.get("rtf", "")), str(item.get("output", "")), ] ) return pd.DataFrame(rows, columns=HISTORY_COLUMNS) def build_voice_index() -> Dict[str, Tuple[Path, Path]]: """Findet gespeicherte Stimmen und optionale Sample-Stimmen aus dem Repo-Ordner.""" voices: Dict[str, Tuple[Path, Path]] = {} sample_candidates = [ ("Sample · Greta Deutsch", APP_DIR / "samples" / "greta.wav", APP_DIR / "samples" / "greta.txt"), ("Sample · Jo Englisch", APP_DIR / "samples" / "jo.wav", APP_DIR / "samples" / "jo.txt"), ("Sample · Dave Englisch", APP_DIR / "samples" / "dave.wav", APP_DIR / "samples" / "dave.txt"), ("Sample · Mateo Spanisch", APP_DIR / "samples" / "mateo.wav", APP_DIR / "samples" / "mateo.txt"), ("Sample · Juliette Französisch", APP_DIR / "samples" / "juliette.wav", APP_DIR / "samples" / "juliette.txt"), ] for label, wav_path, txt_path in sample_candidates: if wav_path.exists() and txt_path.exists(): voices[label] = (wav_path, txt_path) for voice_dir in sorted(VOICES_DIR.iterdir() if VOICES_DIR.exists() else []): if not voice_dir.is_dir(): continue wav_path = voice_dir / "reference.wav" txt_path = voice_dir / "reference.txt" if wav_path.exists() and txt_path.exists(): voices[f"{voice_dir.name}"] = (wav_path, txt_path) return voices def voice_choices() -> List[str]: return list(build_voice_index().keys()) def refresh_voices(): choices = voice_choices() value = choices[0] if choices else None msg = "✅ Stimmenliste aktualisiert." if choices else "⚠️ Noch keine Stimme gespeichert. Lege rechts eine Referenzstimme an." return gr.update(choices=choices, value=value), msg def normalize_or_copy_audio(src: str | Path, dst: Path, normalize: bool) -> None: src = Path(src) dst.parent.mkdir(parents=True, exist_ok=True) if normalize: if librosa is None: raise RuntimeError("librosa ist nicht installiert. Installiere es mit: pip install librosa") audio, _sr = librosa.load(str(src), sr=SAMPLE_RATE, mono=True) peak = float(np.max(np.abs(audio))) if audio.size else 0.0 if peak > 0: audio = audio / max(peak, 1e-9) * 0.92 sf.write(str(dst), audio, SAMPLE_RATE) else: # NeuTTS erwartet idealerweise WAV. Wenn Gradio per Mikrofon aufnimmt, ist es meistens bereits WAV. shutil.copyfile(src, dst) def get_audio_duration(path: str | Path) -> float: try: info = sf.info(str(path)) if info.samplerate > 0: return float(info.frames) / float(info.samplerate) except (OSError, RuntimeError, ValueError): pass if librosa is not None: try: return float(librosa.get_duration(path=str(path))) except (OSError, RuntimeError, ValueError): return 0.0 return 0.0 def resolve_ffmpeg_binary() -> str: """Findet zuerst das projektlokale ffmpeg, danach ein global installiertes ffmpeg.""" candidates = [] if sys.platform.startswith("win"): candidates.append(APP_DIR / "tools" / "ffmpeg" / "bin" / "ffmpeg.exe") else: candidates.append(APP_DIR / "tools" / "ffmpeg" / "bin" / "ffmpeg") candidates.append(APP_DIR / "tools" / "ffmpeg" / "bin" / "ffmpeg.exe") candidates.append(APP_DIR / "tools" / "ffmpeg" / "bin" / "ffmpeg") for candidate in candidates: if candidate.exists(): return str(candidate) system_ffmpeg = shutil.which("ffmpeg") if system_ffmpeg: return system_ffmpeg raise RuntimeError( "ffmpeg wurde nicht gefunden. Lege es lokal unter " "`tools/ffmpeg/bin/ffmpeg.exe` ab oder installiere ffmpeg im PATH." ) def resolve_existing_media_path(path_value: str | Path | None) -> Path | None: """ Gradio verliert beim Audio-Editor manchmal den Ordner und gibt nur den Dateinamen zurück, z. B. `20260602_203047_reference_audio.wav`. Diese Funktion rekonstruiert den echten Pfad aus unseren Arbeitsordnern. """ if path_value is None: return None raw_path = Path(str(path_value)) if raw_path.exists(): return raw_path.resolve() mapped_path = _REFERENCE_WAV_BY_NAME.get(raw_path.name) if mapped_path and Path(mapped_path).exists(): return Path(mapped_path).resolve() search_dirs = [ MEDIA_WORK_DIR, OUTPUTS_DIR, APP_DIR, Path.cwd(), ] for base_dir in search_dirs: candidate = base_dir / raw_path.name if candidate.exists(): return candidate.resolve() # Letzter lokaler Rettungsanker: in prepared_media rekursiv suchen. # Das ist klein genug und verhindert den bekannten Gradio-Basename-Fehler. if MEDIA_WORK_DIR.exists(): matches = list(MEDIA_WORK_DIR.rglob(raw_path.name)) if matches: return matches[-1].resolve() return raw_path def uploaded_value_to_path(value: Any) -> Path | None: """ Macht Gradio-Dateiwerte robust: - gr.File(type="filepath") kann als str kommen - gr.Audio(editable=True) kann nach dem Schneiden nur einen Dateinamen liefern - je nach Gradio-Version auch als Objekt/Dict mit path/name - bei Listen wird der erste Eintrag genommen """ if value is None: return None if isinstance(value, (list, tuple)) and value: # Achtung: Audio-Numpy-Tuple wird an anderer Stelle behandelt. if not (len(value) == 2 and isinstance(value[0], int)): return uploaded_value_to_path(value[0]) if isinstance(value, dict): for key in ("path", "name", "orig_name"): candidate = value.get(key) if candidate: return resolve_existing_media_path(candidate) return None for attr in ("path", "name", "orig_name"): candidate = getattr(value, attr, None) if candidate: return resolve_existing_media_path(candidate) if isinstance(value, (str, Path)): return resolve_existing_media_path(value) return None def is_video_file(path: str | Path | None) -> bool: if not path: return False return Path(path).suffix.lower() in VIDEO_EXTENSIONS def is_audio_file(path: str | Path | None) -> bool: if not path: return False return Path(path).suffix.lower() in AUDIO_EXTENSIONS def write_audio_tuple_as_reference_wav(audio_value: Any, reason: str = "edited_reference") -> Path: """ Falls Gradio nach dem Schneiden ein Audio-Tuple liefert, wird es sofort als mono/24kHz WAV materialisiert. """ if not isinstance(audio_value, tuple) or len(audio_value) != 2: raise TypeError("Audio-Wert ist kein gültiges Gradio-Audio-Tuple.") sr, audio = audio_value arr = np.asarray(audio, dtype=np.float32) if arr.ndim > 1: arr = np.mean(arr, axis=1) # Gradio kann int16 liefern. Auf -1..1 normalisieren. if arr.size: max_abs = float(np.max(np.abs(arr))) if max_abs > 1.5: arr = arr / 32768.0 if int(sr) != SAMPLE_RATE: if librosa is None: raise RuntimeError("librosa fehlt zum Resampling der bearbeiteten Audiodatei.") arr = librosa.resample(arr, orig_sr=int(sr), target_sr=SAMPLE_RATE) peak = float(np.max(np.abs(arr))) if arr.size else 0.0 if peak > 0: arr = arr / max(peak, 1e-9) * 0.92 out_path = MEDIA_WORK_DIR / f"{timestamp()}_{safe_name(reason, 'edited_reference')}.wav" sf.write(str(out_path), arr, SAMPLE_RATE, subtype="PCM_16") _REFERENCE_WAV_BY_NAME[out_path.name] = str(out_path.resolve()) return out_path def convert_media_to_reference_wav(src: Any, reason: str = "reference") -> Path: """ Extrahiert/konvertiert Audio oder Video sofort zu mono/24kHz WAV. Wichtig: Kein Format wird nur anhand der Dateiendung verworfen, weil Gradio-Tempdateien je nach Version/Browser manchmal andere Namen bekommen. ffmpeg darf selbst prüfen. """ # Wenn der Audio-Editor ein bearbeitetes Tuple liefert, direkt speichern. if isinstance(src, tuple) and len(src) == 2: return write_audio_tuple_as_reference_wav(src, reason) src_path = uploaded_value_to_path(src) if src_path is None: raise ValueError("Kein gültiger Medienpfad von Gradio erhalten.") if not src_path.exists(): raise FileNotFoundError(f"Datei nicht gefunden: {src_path}") ffmpeg_bin = resolve_ffmpeg_binary() out_path = MEDIA_WORK_DIR / f"{timestamp()}_{safe_name(reason, 'reference')}_{safe_name(src_path.stem, 'media')}.wav" cmd = [ ffmpeg_bin, "-y", "-hide_banner", "-loglevel", "error", "-i", str(src_path), "-map", "0:a:0", "-vn", "-ac", "1", "-ar", str(SAMPLE_RATE), "-sample_fmt", "s16", str(out_path), ] creationflags = 0 if sys.platform.startswith("win"): creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) result = subprocess.run( cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=creationflags, ) if result.returncode != 0: tail = (result.stderr or result.stdout or "").strip()[-3000:] raise RuntimeError( "ffmpeg-Konvertierung fehlgeschlagen. " "Prüfe, ob die Datei eine Audiospur enthält und ob ffmpeg.exe unter tools/ffmpeg/bin liegt.\n\n" f"{tail}" ) if not out_path.exists() or out_path.stat().st_size == 0: raise RuntimeError("ffmpeg hat keine gültige WAV-Datei erzeugt.") _REFERENCE_WAV_BY_NAME[out_path.name] = str(out_path.resolve()) return out_path def prepare_reference_media(reference_media: Any): """Ein Eingang für Audio oder Video: sofort zu mono/24kHz WAV vorbereiten und im Audio-Editor anzeigen.""" try: src_path = uploaded_value_to_path(reference_media) if src_path is None: return None, "⚠️ Noch keine Audio- oder Video-Datei ausgewählt." wav_path = convert_media_to_reference_wav(reference_media, "reference") duration = get_audio_duration(wav_path) if is_video_file(src_path): source_type = "Video" elif is_audio_file(src_path): source_type = "Audio" else: source_type = "Medium" return ( str(wav_path), f"✅ {source_type} wurde automatisch zu mono/24kHz WAV vorbereitet.\n\n" f"**Quelle:** `{src_path.name}`\n\n" f"**Jetzt im Audiofeld schneiden/bearbeiten und danach transkribieren oder speichern.**\n\n" f"**Dauer:** `{format_seconds(duration)}`", ) except APP_HANDLED_ERRORS as exc: tb = traceback.format_exc() return ( None, f"❌ Medienvorbereitung fehlgeschlagen:\n\n```text\n{exc}\n```\n\n" f"
Traceback\n\n```text\n{tb}\n```\n\n
", ) def pick_reference_media( prepared_audio: Any, reference_media: Any, ) -> str | None: """ Nimmt die bearbeitete/vorbereitete WAV aus dem Audiofeld. Wichtig: Gradio kann nach dem Audio-Editor nur den Dateinamen liefern. Deshalb wird der Pfad zuerst über resolve_existing_media_path rekonstruiert. """ if isinstance(prepared_audio, tuple) and len(prepared_audio) == 2: return str(write_audio_tuple_as_reference_wav(prepared_audio, "edited_reference")) prepared_path = uploaded_value_to_path(prepared_audio) if prepared_path is not None and prepared_path.exists(): # Wenn es bereits eine WAV ist, kann sie direkt genutzt werden. # Sie wurde beim Upload schon auf mono/24kHz gebracht; bearbeitete # Editor-Exports sind ebenfalls WAV-Dateien. if prepared_path.suffix.lower() == ".wav": return str(prepared_path.resolve()) return str(convert_media_to_reference_wav(prepared_path, "edited_reference")) # Fallback: ursprüngliches Medium erneut vorbereiten. converted, _status = prepare_reference_media(reference_media) if converted: converted_path = uploaded_value_to_path(converted) if converted_path is not None and converted_path.exists(): return str(converted_path.resolve()) return converted return None def save_voice_style_metadata( target_dir: Path, emotion_label: str | None, preserve_laughter: bool, non_speech_notes: str | None, ) -> None: style_data = { "emotion_label": (emotion_label or "").strip(), "preserve_laughter": bool(preserve_laughter), "non_speech_notes": (non_speech_notes or "").strip(), "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } (target_dir / "style.json").write_text(json.dumps(style_data, ensure_ascii=False, indent=2), encoding="utf-8") def apply_tts_event_markers(text: str, marker_mode: str | None) -> str: """ Ein pragmatischer Marker-Fallback. NeuTTS versteht keine garantierten [lacht]-Tokens. Damit es trotzdem im TTS wahrnehmbar wird, können Marker optional in sprechbare Lautäußerungen gewandelt werden. """ mode = (marker_mode or "Marker unverändert lassen").strip() if mode == "Marker unverändert lassen": return text replacements = { r"\[(lacht|lachen|laughs|laugh)\]": " haha ", r"\[(lacht kurz|kurzes lachen)\]": " haha ", r"\[(lacht herzlich|herzliches lachen|lacht laut)\]": " hahahaha ", r"\[(kichert|giggles)\]": " hihi ", r"\[(seufzt|sighs)\]": " hach ", r"\[(atmet|breathes|atmen)\]": " ", r"\((lacht|lachen|laughs|laugh)\)": " haha ", r"\((kichert|giggles)\)": " hihi ", r"\((seufzt|sighs)\)": " hach ", } converted = text for pattern, repl in replacements.items(): converted = re.sub(pattern, repl, converted, flags=re.IGNORECASE) converted = re.sub(r"\s+", " ", converted).strip() return converted def save_voice( voice_name: str | None, prepared_audio: str | None, reference_media: str | None, ref_text: str | None, emotion_label: str | None, preserve_laughter: bool, non_speech_notes: str | None, ): if not voice_name or not voice_name.strip(): return gr.update(choices=voice_choices()), "❌ Bitte einen Namen für die Stimme eingeben.", None source_audio = pick_reference_media(prepared_audio, reference_media) if not source_audio: return gr.update(choices=voice_choices()), "❌ Bitte eine Audio- oder Video-Datei hochladen.", None if not ref_text or not ref_text.strip(): return gr.update(choices=voice_choices()), "❌ Bitte den exakt gesprochenen Referenztext eingeben oder vorher transkribieren.", None name = safe_name(voice_name, "voice") target_dir = VOICES_DIR / name target_dir.mkdir(parents=True, exist_ok=True) wav_path = target_dir / "reference.wav" txt_path = target_dir / "reference.txt" try: normalize_or_copy_audio(source_audio, wav_path, True) txt_path.write_text(ref_text.strip(), encoding="utf-8") save_voice_style_metadata(target_dir, emotion_label, preserve_laughter, non_speech_notes) choices = voice_choices() style_line = "" if emotion_label or preserve_laughter or non_speech_notes: style_line = "\n\nStil-Metadaten: `style.json` gespeichert." # Dauerhaft sichern (HF-Dataset), damit die Stimme einen Space-Neustart überlebt. sync_note = voice_persistence.push_voice_to_dataset(VOICES_DIR, name) return ( gr.update(choices=choices, value=name), f"✅ Stimme gespeichert: `{target_dir}`\n\nReferenz: `{wav_path.name}` + `{txt_path.name}`{style_line}{sync_note}", str(wav_path), ) except APP_HANDLED_ERRORS as exc: return gr.update(choices=voice_choices()), f"❌ Stimme konnte nicht gespeichert werden:\n\n```text\n{exc}\n```", None def resolve_model(model_label: str | None, custom_model: str | None) -> str: custom_model = (custom_model or "").strip() if custom_model: return custom_model if model_label and model_label in MODEL_PRESETS: return MODEL_PRESETS[model_label] return MODEL_PRESETS["Nano German · klein/schnell/deutsch"] def resolve_codec(codec_label: str | None) -> str: if codec_label and codec_label in CODEC_PRESETS: return CODEC_PRESETS[codec_label] return CODEC_PRESETS["NeuCodec · Standard"] def resolve_whisper_model(model_label: str | None) -> str: if model_label and model_label in WHISPER_MODEL_PRESETS: return WHISPER_MODEL_PRESETS[model_label] return WHISPER_MODEL_PRESETS["Base · guter Start/CPU"] def get_tts(backbone_repo: str, backbone_device: str, codec_repo: str, codec_device: str): key = (backbone_repo, backbone_device, codec_repo, codec_device) if key in _MODEL_CACHE: return _MODEL_CACHE[key] from neutts import NeuTTS tts = NeuTTS( backbone_repo=backbone_repo, backbone_device=backbone_device, codec_repo=codec_repo, codec_device=codec_device, ) _MODEL_CACHE[key] = tts return tts def get_ref_codes(tts: Any, ref_audio_path: Path, codec_repo: str, codec_device: str): stat = ref_audio_path.stat() key = (str(ref_audio_path.resolve()), stat.st_mtime_ns, codec_repo, codec_device) if key in _REF_CACHE: return _REF_CACHE[key] ref_codes = tts.encode_reference(str(ref_audio_path)) _REF_CACHE[key] = ref_codes return ref_codes def get_whisper(model_size: str, device: str, compute_type: str): key = (model_size, device, compute_type) if key in _WHISPER_CACHE: return _WHISPER_CACHE[key] from faster_whisper import WhisperModel model = WhisperModel(model_size, device=device, compute_type=compute_type) _WHISPER_CACHE[key] = model return model def split_text(text: str | None, max_chars: int) -> List[str]: text = " ".join((text or "").strip().split()) if not text: return [] if len(text) <= max_chars: return [text] # Erst Satzgrenzen, dann harte Längenbegrenzung. sentences = re.split(r"(?<=[.!?…])\s+", text) chunks: List[str] = [] current = "" for sentence in sentences: sentence = sentence.strip() if not sentence: continue if len(sentence) > max_chars: # Harter Fallback: lange Sätze an Spaces zerlegen. words = sentence.split() for word in words: if len(current) + len(word) + 1 > max_chars and current: chunks.append(current.strip()) current = word else: current = f"{current} {word}".strip() continue if len(current) + len(sentence) + 1 <= max_chars: current = f"{current} {sentence}".strip() else: if current: chunks.append(current.strip()) current = sentence if current: chunks.append(current.strip()) return chunks def ensure_mono_float32(wav: Any) -> np.ndarray: arr = np.asarray(wav, dtype=np.float32) arr = np.squeeze(arr) if arr.ndim > 1: arr = np.mean(arr, axis=1).astype(np.float32) return arr def make_output_path(prefix: str | None, voice: str | None, model_repo: str) -> Path: clean_prefix = safe_name(prefix or "neutts", "neutts") clean_voice = safe_name(voice or "voice", "voice") clean_model = safe_name(model_repo.split("/")[-1], "model") return OUTPUTS_DIR / f"{timestamp()}_{clean_prefix}_{clean_voice}_{clean_model}.wav" def transcribe_reference_audio( prepared_audio: str | None, reference_media: str | None, whisper_model_label: str | None, whisper_language: str | None, whisper_device: str | None, whisper_compute_type: str | None, vad_filter_enabled: bool, preserve_laughter_hint: bool, emotion_label: str | None, non_speech_notes: str | None, ): source_audio = pick_reference_media(prepared_audio, reference_media) if not source_audio: return "", "❌ Bitte zuerst eine Audio- oder Video-Datei hochladen." model_size = resolve_whisper_model(whisper_model_label) device = (whisper_device or "cpu").strip() or "cpu" compute_type = (whisper_compute_type or "int8").strip() or "int8" language = (whisper_language or "auto").strip() language_arg = None if language == "auto" else language started = time.perf_counter() try: model = get_whisper(model_size, device, compute_type) segments, info = model.transcribe( source_audio, language=language_arg, beam_size=5, vad_filter=bool(vad_filter_enabled), ) text = " ".join(segment.text.strip() for segment in segments if segment.text.strip()).strip() elapsed = time.perf_counter() - started detected_lang = getattr(info, "language", "unbekannt") probability = getattr(info, "language_probability", 0.0) marker_notes = [] if preserve_laughter_hint: marker_notes.append("[lacht]") if emotion_label and emotion_label.strip(): marker_notes.append(f"[Stil: {emotion_label.strip()}]") if non_speech_notes and non_speech_notes.strip(): marker_notes.append(non_speech_notes.strip()) # Whisper erkennt Lachen nicht zuverlässig als Text. Deshalb werden Marker bewusst # als editierbare Hinweise ergänzt, statt so zu tun, als wären sie automatisch sicher erkannt. if marker_notes: text = f"{text}\n\n{' '.join(marker_notes)}".strip() if not text: return "", f"⚠️ Keine Sprache erkannt. Dauer: {format_seconds(elapsed)}" laughter_msg = ( "\n\n**Lach-/Emotionshinweis:** Marker wurden als editierbare Hinweise angefügt." if marker_notes else "\n\n**Lach-/Emotionshinweis:** Whisper erkennt Lachen nicht zuverlässig automatisch. Nutze bei Bedarf die Marker-Optionen." ) return ( text, f"✅ Transkription fertig.\n\n" f"**Whisper-Modell:** `{model_size}`\n\n" f"**Sprache:** `{detected_lang}` ({probability:.2%})\n\n" f"**VAD-Filter:** `{'an' if vad_filter_enabled else 'aus'}`\n\n" f"**Dauer:** `{format_seconds(elapsed)}`" f"{laughter_msg}", ) except ImportError: return "", "❌ faster-whisper fehlt. Installiere es mit: `pip install faster-whisper`" except APP_HANDLED_ERRORS as exc: tb = traceback.format_exc() return "", f"❌ Transkription fehlgeschlagen:\n\n```text\n{exc}\n```\n\n
Traceback\n\n```text\n{tb}\n```\n\n
" def synthesize( input_text: str | None, model_label: str | None, custom_model: str | None, codec_label: str | None, voice_label: str | None, backbone_device: str | None, codec_device: str | None, split_enabled: bool, max_chars: int | float, pause_ms: int | float, tail_padding_ms: int | float, event_marker_mode: str | None, output_prefix: str | None, synthesis_speed: float | int | None = 1.0, denoise_enabled: bool = False, ): if not input_text or not input_text.strip(): return None, None, "❌ Bitte Text eingeben.", load_history() voices = build_voice_index() if not voices: return None, None, "❌ Keine Stimme gefunden. Speichere zuerst eine Referenzstimme.", load_history() if not voice_label or voice_label not in voices: return None, None, "❌ Bitte eine vorhandene Stimme auswählen.", load_history() model_repo = resolve_model(model_label, custom_model) codec_repo = resolve_codec(codec_label) backbone = (backbone_device or "cpu").strip() or "cpu" codec = (codec_device or "cpu").strip() or "cpu" ref_audio_path, ref_text_path = voices[voice_label] synthesis_text = apply_tts_event_markers(input_text.strip(), event_marker_mode) started = time.perf_counter() try: ref_text = read_text(ref_text_path) tts = get_tts(model_repo, backbone, codec_repo, codec) ref_codes = get_ref_codes(tts, ref_audio_path, codec_repo, codec) if split_enabled: chunks = split_text(synthesis_text, int(max_chars or 220)) else: chunks = [synthesis_text] if not chunks: return None, None, "❌ Nach dem Text-Splitting ist kein Text übrig geblieben.", load_history() pause = np.zeros(int(SAMPLE_RATE * max(0, int(pause_ms or 0)) / 1000), dtype=np.float32) parts: List[np.ndarray] = [] for idx, chunk in enumerate(chunks, start=1): wav = tts.infer(chunk, ref_codes, ref_text) parts.append(ensure_mono_float32(wav)) if idx < len(chunks) and len(pause) > 0: parts.append(pause) tail_padding = np.zeros( int(SAMPLE_RATE * max(0, int(tail_padding_ms or 0)) / 1000), dtype=np.float32, ) if len(tail_padding) > 0: parts.append(tail_padding) full_wav = np.concatenate(parts) if len(parts) > 1 else parts[0] # Echte Postprocessing-Effekte aus den UI-Reglern (kein Platzhalter). full_wav = audio_fx.apply_speed(full_wav, synthesis_speed) if denoise_enabled: full_wav = audio_fx.apply_denoise(full_wav, SAMPLE_RATE) out_path = make_output_path(output_prefix, voice_label, model_repo) sf.write(str(out_path), full_wav, SAMPLE_RATE) elapsed = time.perf_counter() - started audio_duration = get_audio_duration(out_path) rtf = elapsed / audio_duration if audio_duration > 0 else 0.0 elapsed_text = format_seconds(elapsed) audio_text = format_seconds(audio_duration) if audio_duration > 0 else "unbekannt" rtf_text = f"{rtf:.2f}x" if rtf > 0 else "unbekannt" write_jsonl( { "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "model": model_repo, "codec": codec_repo, "voice": voice_label, "chars": len(synthesis_text), "chunks": len(chunks), "elapsed": elapsed_text, "audio_duration": audio_text, "rtf": rtf_text, "output": str(out_path), } ) status = ( f"✅ Fertig.\n\n" f"**Modell:** `{model_repo}`\n\n" f"**Codec:** `{codec_repo}`\n\n" f"**Stimme:** `{voice_label}`\n\n" f"**Chunks:** `{len(chunks)}`\n\n" f"**Marker-Modus:** `{event_marker_mode or 'Marker unverändert lassen'}`\n\n" f"**Generierungsdauer:** `{elapsed_text}`\n\n" f"**Audio-Länge:** `{audio_text}`\n\n" f"**Realtime-Faktor:** `{rtf_text}`\n\n" f"**Gespeichert:** `{out_path}`" ) return str(out_path), str(out_path), status, load_history() except APP_HANDLED_ERRORS as exc: tb = traceback.format_exc() helpful = "" if "llama" in tb.lower() or "gguf" in model_repo.lower(): helpful = ( "\n\n💡 GGUF-Hinweis: Installiere `llama-cpp-python` am besten als Wheel, nicht per OpenBLAS-Source-Build:\n\n" "```powershell\n" "pip install llama-cpp-python --prefer-binary --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu\n" "```" ) if "espeak" in tb.lower() or "phonemizer" in tb.lower(): helpful += ( "\n\n💡 eSpeak-Hinweis: Prüfe `PHONEMIZER_ESPEAK_LIBRARY` und `PHONEMIZER_ESPEAK_PATH`." ) return ( None, None, f"❌ Fehler bei der Synthese:\n\n```text\n{exc}\n```{helpful}\n\n
Traceback\n\n```text\n{tb}\n```\n\n
", load_history(), ) def clear_model_cache(): _MODEL_CACHE.clear() _REF_CACHE.clear() _WHISPER_CACHE.clear() return "🧹 Modell-, Referenz- und Whisper-Cache geleert." CUSTOM_CSS = """ :root { --lara-orange: #ff8a2a; --lara-bg: #070b18; } .gradio-container { background: radial-gradient(circle at top left, rgba(255,138,42,0.13), transparent 28%), linear-gradient(135deg, #070b18 0%, #10182b 55%, #080a12 100%) !important; } #hero-box { border: 1px solid rgba(255,255,255,0.12); border-radius: 22px; padding: 22px; background: rgba(255,255,255,0.06); backdrop-filter: blur(14px); box-shadow: 0 0 40px rgba(255,138,42,0.12); } #hero-title h1, #hero-title h2, #hero-title h3 { color: #fff !important; } #hero-sub { color: rgba(255,255,255,0.72) !important; } button.primary { box-shadow: 0 0 24px rgba(255,138,42,0.28) !important; } """ def build_app() -> gr.Blocks: initial_voices = voice_choices() initial_voice = initial_voices[0] if initial_voices else None # Gradio 6: theme/css werden nicht mehr im Blocks-Konstruktor gesetzt, # sondern beim launch(). Deshalb hier nur title. with gr.Blocks(title="NeuTTS Gradio Studio", analytics_enabled=False) as demo: gr.Markdown( """

🎙️ NeuTTS Gradio Studio

Lokale TTS-Oberfläche für NeuTTS: Modelle wechseln, Stimmen speichern, Referenzen klonen und WAV-Dateien direkt auf die Festplatte schreiben.
""" ) with gr.Tab("🎧 Synthese"): with gr.Row(): with gr.Column(scale=7): input_text = gr.Textbox( label="Text", placeholder="Schreib hier rein, was die Stimme sprechen soll...", lines=8, value="Hallo Sascha, das ist NeuTTS in einer kleinen lokalen Gradio-Oberfläche. Wenn das läuft, bekommt die App ein Käsebrot.", ) with gr.Row(): model_dropdown = gr.Dropdown( label="Modell", choices=list(MODEL_PRESETS.keys()), value="Nano German · klein/schnell/deutsch", ) codec_dropdown = gr.Dropdown( label="Codec", choices=list(CODEC_PRESETS.keys()), value="NeuCodec · Standard", ) custom_model = gr.Textbox( label="Custom Hugging-Face-Model-Repo-ID optional", placeholder="z. B. neuphonic/neutts-nano-german-q4-gguf — leer lassen, wenn Dropdown genutzt wird", ) with gr.Row(): voice_dropdown = gr.Dropdown( label="Stimme", choices=initial_voices, value=initial_voice, allow_custom_value=False, ) refresh_btn = gr.Button("🔄 Stimmen neu laden") with gr.Accordion("⚙️ Optionen", open=True): with gr.Row(): backbone_device = gr.Dropdown( label="Backbone Device", choices=["cpu", "cuda", "gpu"], value="cpu", info="CPU für Windows sicher. Bei GGUF/GPU ggf. 'gpu' testen, wenn llama-cpp passend gebaut ist.", ) codec_device = gr.Dropdown( label="Codec Device", choices=["cpu", "cuda"], value="cpu", ) with gr.Row(): split_enabled = gr.Checkbox(label="Lange Texte automatisch splitten", value=True) max_chars = gr.Slider(label="Max. Zeichen pro Chunk", minimum=80, maximum=600, value=260, step=20) pause_ms = gr.Slider(label="Pause zwischen Chunks in ms", minimum=0, maximum=1200, value=220, step=20) tail_padding_ms = gr.Slider(label="Finale Stille am Ende in ms", minimum=0, maximum=2000, value=500, step=50) with gr.Row(): synthesis_speed = gr.Slider( label="Geschwindigkeit", minimum=0.5, maximum=2.0, value=1.0, step=0.05, info="Tempo der Ausgabe, pitch-erhaltend (time-stretch).", ) denoise_enabled = gr.Checkbox( label="Rauschunterdrückung", value=False, info="Stationäres Denoise am erzeugten Audio.", ) event_marker_mode = gr.Dropdown( label="Lach-/Emotionsmarker im TTS-Text", choices=["Marker unverändert lassen", "Marker in sprechbare Laute umwandeln"], value="Marker in sprechbare Laute umwandeln", info="Beispiel: [lacht] wird zu 'haha'. NeuTTS unterstützt Lachen nicht garantiert als eigenes Token.", ) output_prefix = gr.Textbox(label="Dateiname-Prefix", value="neutts_test") generate_btn = gr.Button("🔥 Sprache erzeugen", variant="primary") with gr.Column(scale=5): out_audio = gr.Audio(label="Ausgabe", type="filepath") out_file = gr.File(label="Gespeicherte WAV-Datei") status = gr.Markdown("Bereit. Kleine TTS-Maschine wartet auf Futter.") with gr.Tab("🗣️ Stimmen verwalten"): gr.Markdown( """ **Referenzregel:** Lade oben genau **ein** Medium hoch: Audio **oder** Video. Die App wandelt sofort lokal mit `tools/ffmpeg/bin/ffmpeg.exe` in mono/24kHz WAV um. Danach schneidest du direkt im angezeigten Audiofeld den relevanten Bereich, z. B. 10–20 Sekunden saubere Sprache ohne zweite Stimme. Lachen/Emotionen werden nicht zuverlässig automatisch von Whisper erkannt; dafür gibt es editierbare Marker und Stil-Metadaten. """ ) with gr.Row(): with gr.Column(scale=5): new_voice_name = gr.Textbox(label="Name der Stimme", placeholder="z. B. Lara_Test_01") reference_media = gr.File( label="Referenzmedium hochladen (Audio oder Video)", file_types=[ ".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac", ".wma", ".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".mpeg", ".mpg", ], type="filepath", ) media_status = gr.Markdown("Noch kein Medium vorbereitet. Lade oben Audio oder Video hoch; es wird automatisch zu mono/24kHz WAV gewandelt.") prepared_ref_audio = gr.Audio( label="Bearbeitbare Referenz-WAV (hier schneiden)", type="filepath", interactive=True, editable=True, format="wav", ) with gr.Accordion("📝 Referenztext transkribieren", open=True): with gr.Row(): whisper_model_dropdown = gr.Dropdown( label="Whisper-Modell", choices=list(WHISPER_MODEL_PRESETS.keys()), value="Base · guter Start/CPU", ) whisper_language = gr.Dropdown( label="Sprache", choices=["auto", "de", "en", "fr", "es"], value="de", ) with gr.Row(): whisper_device = gr.Dropdown( label="Whisper Device", choices=["cpu", "cuda"], value="cpu", ) whisper_compute_type = gr.Dropdown( label="Whisper Compute Type", choices=["int8", "int8_float32", "float32", "float16"], value="int8", info="CPU: int8. CUDA: float16 ist oft schneller, wenn passend installiert.", ) with gr.Row(): vad_filter_enabled = gr.Checkbox( label="VAD-Filter nutzen", value=True, info="An = sauberer Sprachtext. Aus = Lachen/Atmer bleiben eher im Audio, Whisper erkennt sie aber nicht garantiert als Text.", ) preserve_laughter_hint = gr.Checkbox( label="Lachen/Non-Speech als Marker berücksichtigen", value=True, info="Fügt editierbare Marker wie [lacht] hinzu, statt so zu tun, als hätte Whisper sie sicher erkannt.", ) emotion_label = gr.Dropdown( label="Emotionaler Stil der Referenz", choices=[ "", "neutral", "fröhlich", "lachend", "ruhig", "ernst", "aufgeregt", "zärtlich", "energisch", ], value="", allow_custom_value=True, ) non_speech_notes = gr.Textbox( label="Manuelle Lach-/Atem-/Emotionsmarker", placeholder="z. B. [lacht], [kichert], [seufzt], [atmet ein], fröhlich und locker", lines=2, ) transcribe_btn = gr.Button("📝 Referenz transkribieren") ref_text = gr.Textbox( label="Exakter Referenztext", lines=5, placeholder="Das muss exakt das sein, was in der Referenz-WAV gesprochen wird. Marker kannst du bewusst ergänzen, wenn du sie beim Stiltest nutzen willst.", ) save_voice_btn = gr.Button("💾 Stimme speichern", variant="primary") with gr.Column(scale=5): voice_status = gr.Markdown("Noch nichts gespeichert.") saved_ref_audio = gr.Audio(label="Gespeicherte Referenz", type="filepath") gr.Markdown( """ **Hinweis zu Lachen & Emotion:** NeuTTS kann Stil aus der Referenz übernehmen, aber ein echtes `[lacht]`-Token ist nicht garantiert. Für Tests kannst du im Synthese-Text Marker wie `[lacht]` verwenden und im Synthese-Tab in sprechbare Laute umwandeln lassen. """ ) with gr.Tab("📜 Verlauf"): history_df = gr.Dataframe( headers=HISTORY_COLUMNS, value=load_history(), label="Letzte Generierungen", interactive=False, wrap=True, ) with gr.Row(): reload_history_btn = gr.Button("🔄 Verlauf neu laden") clear_cache_btn = gr.Button("🧹 Modellcache leeren") cache_status = gr.Markdown(TORCH_PATCH_STATUS + "\n\n" + globals().get("PERSISTENCE_STATUS", "")) refresh_btn.click(refresh_voices, outputs=[voice_dropdown, status]) reference_media.change( prepare_reference_media, inputs=[reference_media], outputs=[prepared_ref_audio, media_status], api_name="prepare_reference", ) transcribe_btn.click( transcribe_reference_audio, inputs=[ prepared_ref_audio, reference_media, whisper_model_dropdown, whisper_language, whisper_device, whisper_compute_type, vad_filter_enabled, preserve_laughter_hint, emotion_label, non_speech_notes, ], outputs=[ref_text, voice_status], api_name="transcribe_reference", ) save_voice_btn.click( save_voice, inputs=[ new_voice_name, prepared_ref_audio, reference_media, ref_text, emotion_label, preserve_laughter_hint, non_speech_notes, ], outputs=[voice_dropdown, voice_status, saved_ref_audio], api_name="save_voice", ) generate_btn.click( synthesize, inputs=[ input_text, model_dropdown, custom_model, codec_dropdown, voice_dropdown, backbone_device, codec_device, split_enabled, max_chars, pause_ms, tail_padding_ms, event_marker_mode, output_prefix, synthesis_speed, denoise_enabled, ], outputs=[out_audio, out_file, status, history_df], api_name="synthesize", ) reload_history_btn.click(lambda: load_history(), outputs=[history_df]) clear_cache_btn.click(clear_model_cache, outputs=[cache_status]) return demo # Persistenz beim Start synchronisieren (vor dem UI-Aufbau, damit gespeicherte # Stimmen sofort in der Auswahl erscheinen). PERSISTENCE_STATUS = voice_persistence.sync_voices_from_dataset(VOICES_DIR) print(f"[neutts] {PERSISTENCE_STATUS}") # Hugging Face Spaces/Gradio Hot Reload suchen bevorzugt nach einer globalen Variable # namens `demo`. Dadurch vermeiden wir Hot-Reload-Suchmeldungen. demo = build_app() def launch_app(app: gr.Blocks = demo) -> None: """Startet lokal freundlich, aber Hugging-Face-Space-kompatibel.""" launch_kwargs = { "show_error": True, "theme": gr.themes.Soft(), "css": CUSTOM_CSS, # Gradio 6 meldet SSR als experimentell; auf Spaces erzeugt es hier # AsyncIO-Cleanup-Noise. Für diese App brauchen wir SSR nicht. "ssr_mode": False, } # Auf Hugging Face Spaces werden Host/Port vom Container gesetzt. # Lokal bleibt die App wie gewohnt auf 127.0.0.1:7860 und öffnet den Browser. is_hf_space = bool(os.getenv("SPACE_ID") or os.getenv("SPACE_HOST")) if not is_hf_space: launch_kwargs.update( { "server_name": "127.0.0.1", "server_port": 7860, "inbrowser": True, } ) app.queue(default_concurrency_limit=1, max_size=8).launch(**launch_kwargs) if __name__ == "__main__": launch_app()