diff --git a/.gitignore b/.gitignore index 30180d5..a2c1713 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ personal_samples/* data/ trim_history/ -.DS_Store \ No newline at end of file +.DS_Store +frontend/node_modules/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index d432353..981145b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ taterassistant.com -Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, generated Piper samples, device-captured samples, reviewed false-wake negatives, live training logs, and local wake-word links for Tater Native satellites. +Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, modern multilingual TTS ensembles, device-captured samples, reviewed false-wake negatives, live training logs, and local wake-word links for Tater Native satellites. Real samples come from device-captured wake audio, close misses, or manual uploads. Every saved sample is normalized to `16 kHz / mono / 16-bit PCM WAV` before training. @@ -79,6 +79,7 @@ If you change `REC_PORT`, open that port instead and use the same port in the sa ## What The UI Does +- The entire interface is reactive Vue 3 + TypeScript, following the same typed component pattern as Tater's newer UI surfaces. - `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training. - `Auto Training` transcribes real wake triggers, promotes phrase-misses to hard negatives, schedules retraining, and refreshes Tater Native satellites. - `Captured Audio` reviews clips sent by Tater Native or ESPHome sats, including wake hits, close misses, and false wakes. @@ -86,6 +87,16 @@ If you change `REC_PORT`, open that port instead and use the same port in the sa - `Wake Words` lists locally trained JSON/model links for live wake-word switching in Tater. - Popup consoles show colorized training logs while long-running jobs are active. +The production bundle is committed under `static/ui`, so neither NVIDIA Docker image needs Node.js. To change the UI, edit `frontend/src` and rebuild it before building the image: + +```bash +cd frontend +npm install +npm run build +``` + +`npm run build` type-checks every Vue component before writing the offline bundle copied into both the standard CUDA and Blackwell images. + --- ## Captured Audio Workflow @@ -196,8 +207,8 @@ The default Tater URL, `http://127.0.0.1:8501`, assumes the documented host netw ## Training Flow 1. Enter the wake phrase in `Trainer`. -2. Choose the language. -3. Optionally test pronunciation with `Test TTS`. +2. Choose the language and TTS source. +3. Optionally check browser pronunciation with `System preview`. 4. Review the positive and negative sample counts. 5. Click `Start training`. 6. Watch the popup training console. @@ -212,16 +223,21 @@ On RTX 50-series / Blackwell GPUs, the Blackwell Docker image keeps sample gener ## Language Support -The language picker is dynamic. +The language picker is built from OmniVoice's live catalog (currently more than 600 languages), with a bundled common-language fallback for offline startup. Languages covered by Qwen3-TTS and MOSS-TTS-Nano are automatically marked `Recommended`; OmniVoice-only languages are marked `Experimental` so lower-resource coverage is not presented as equal quality. -- `en` is always available. -- English keeps the existing dedicated generator model path. -- Non-English languages are discovered from the Piper voices catalog and any local Piper voice metadata. -- When a non-English language is selected, the trainer downloads all voices for that selected language only. -- Already-downloaded voices are reused. -- It does not download every language up front. +The selected code is sent directly to the supporting model. Model downloads happen only when a language is used, and the Hugging Face cache is persisted under `/data/.cache/huggingface`. The fetched language catalog is cached under `/data/.cache/omnivoice_languages.json`. -If the upstream Piper catalog is unavailable, already-installed local voices are used when available. +### TTS modes + +- `Four-provider ensemble` is the default. It uses OmniVoice for every catalog language, adds Qwen3-TTS and MOSS-TTS-Nano where supported, and adds Piper when a compatible model exists. +- `Modern only` uses the multilingual providers without Piper. +- `Piper only` preserves the previous generator as an explicit legacy fallback. + +Where a Piper voice is unavailable, the default route automatically continues with the modern providers. + +Qwen, OmniVoice, and Piper now generate final corpus candidates directly instead of cloning a 128-profile bank. Qwen provides 18,750 balanced voice conditions before an instruction repeats, and Piper uses every speaker in its installed model. MOSS Nano is clone-only, so each MOSS take uses a different already-accepted direct take as its carrier rather than cycling a small bank. + +Every generated file is normalized to `16 kHz / mono / 16-bit PCM WAV` and rejected if it contains static, broadband/high-frequency noise, silence, clipping, excessive duration/rambling, or an exact duplicate. A generation manifest records the planned and accepted provider counts and the applied safety limits. --- @@ -229,12 +245,14 @@ If the upstream Piper catalog is unavailable, already-installed local voices are The first training run downloads and prepares missing training assets into `/data`, including: -- Piper voices for the selected language +- isolated Python environments for each selected modern TTS engine +- selected TTS model weights and the direct generated corpus +- additional language-specific Piper voices only when hybrid or legacy Piper mode is selected - negative datasets and background data - the Python training environment - generated samples and augmented feature caches -After those assets are prepared, later runs reuse the local copies unless the mounted `/data` contents are deleted. +The three modern engines deliberately use separate environments under `/data/tts-envs/`; their required PyTorch and Transformers versions conflict with one another and with the trainer environment. Model weights can require many gigabytes, so allow extra disk space and time on the first run. After the assets are prepared, later runs reuse the local copies unless the mounted `/data` contents are deleted. --- @@ -319,6 +337,7 @@ That removes: - negative samples - captured inbox clips - downloaded Piper voices +- modern TTS environments, model weights, and completed direct-generated corpora - cached datasets - training environments - trained models @@ -343,4 +362,7 @@ Built on top of: - [microWakeWord](https://github.com/kahrendt/microWakeWord) - [piper-sample-generator](https://github.com/rhasspy/piper-sample-generator) +- [OmniVoice](https://github.com/k2-fsa/OmniVoice) +- [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS) +- [MOSS-TTS-Nano](https://github.com/OpenMOSS/MOSS-TTS-Nano) - [tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313) for the optional RTX 50-series / Blackwell image diff --git a/VERSION b/VERSION index aabe6ec..2bd5a0a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -21 +22 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index f893c11..58c5bbb 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,4 +1,19 @@ +- Fixed the training console so scrolling up pauses auto-follow and provides a Jump to latest control. +- Fixed trained wake-word cards and Copy URL to use the explicit JSON package URL instead of producing `undefined`. +- Replaced the 128-profile clone pipeline with direct final-corpus generation from Qwen, OmniVoice, and Piper; MOSS now uses a different accepted carrier for each take. +- Added strict provider-specific rejection for static, broadband/high-frequency noise, silence, clipping, excessive duration/rambling, and exact duplicate audio. +- Capped Qwen and MOSS decoding for a single short utterance, fixed OmniVoice to bounded wake-phrase durations, and let safer providers fill every rejected share. +- Expanded Qwen to 18,750 balanced combinations across gender, age, pitch, delivery, timbre, pace, and vocal weight before an instruction repeats. +- Shifted the reactive trainer UI from blue-black surfaces to Tater's graphite-grey and orange visual theme. +- Rebuilt the trainer interface as a reactive Vue 3 + TypeScript application using the same typed UI pattern as Tater. +- Preserved session setup, multilingual TTS routing, sample review/import/trim, Auto Training, secure Tater pairing, live logs, and wake-word publishing in the new component-driven UI. +- Updated the standard CUDA and Blackwell Dockerfiles to copy the complete prebuilt UI bundle; Node.js is not installed or required in the runtime image. +- Made OmniVoice, Qwen3-TTS, MOSS-TTS-Nano, and Piper the recommended four-provider route where a compatible Piper model is present. +- Added the live 646-language OmniVoice catalog, language quality tiers, exact per-engine routing, and normalized acoustic QA. +- Added persistent per-engine environments and Hugging Face caches that keep conflicting TTS dependencies separate from wake-word training. - Improved automatic review accuracy for short wake phrases that STT initially hears as similar-sounding words. - Added a conservative Faster Whisper confirmation pass that uses the currently configured wake phrase only when the unbiased transcript is already phonetically close. - Kept unconfirmed close transcripts in the manual review inbox instead of allowing them to become harmful negative training samples. - Added visible guided-transcript and review-reason details, plus retry support for ambiguous clips through Review Now. +- Locked the wake phrase, language, and TTS route while a session is active, and added Stop Session with clean full-process-tree training cancellation. +- Added a Data tab with per-dataset disk usage and file counts, plus confirmed, training-safe deletion for recordings, downloads, generated caches, speech models, and training results. diff --git a/cli/setup_modern_tts_envs b/cli/setup_modern_tts_envs new file mode 100755 index 0000000..39aeb9c --- /dev/null +++ b/cli/setup_modern_tts_envs @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +PROGPATH="$(realpath "$0")" +PROGDIR="$(dirname "${PROGPATH}")" + +KNOWN_ARGS=( data-dir engine gpu no-gpu ) +# shellcheck source=/dev/null +source "${PROGDIR}/shell.functions" + +ENGINE="${ENGINE:-${POSITIONAL_ARGS[0]:-}}" +case "${ENGINE}" in + omnivoice|qwen3|moss) ;; + *) + echo "Usage: setup_modern_tts_envs --engine= [--data-dir=/data]" >&2 + exit 2 + ;; +esac + +PYTHON_BIN="${MWW_TTS_PYTHON:-python3.12}" +command -v "${PYTHON_BIN}" >/dev/null 2>&1 || PYTHON_BIN=python3 + +if [ -z "${GPU:-}" ] ; then + GPU=false + if [ -c /dev/nvidiactl ] || { command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1 ; } ; then + GPU=true + fi +fi + +TTS_ROOT="${DATA_DIR}/tts-envs" +VENV="${TTS_ROOT}/${ENGINE}" +STACK_VERSION="modern-tts-v1" +MARKER="${VENV}/.stack-version" +mkdir -p "${TTS_ROOT}" "${DATA_DIR}/.cache/huggingface" + +case "${ENGINE}" in + omnivoice) + TORCH_VERSION="2.8.0" + TORCHAUDIO_VERSION="2.8.0" + PACKAGE_SPEC="git+https://github.com/k2-fsa/OmniVoice.git@28bc0889d92110491d726a9c79f26a895db5a074" + IMPORT_NAME="omnivoice" + STACK_ID="${STACK_VERSION}:omnivoice-28bc088:torch-${TORCH_VERSION}" + ;; + qwen3) + TORCH_VERSION="2.9.1" + TORCHAUDIO_VERSION="2.9.1" + PACKAGE_SPEC="qwen-tts==0.1.1" + IMPORT_NAME="qwen_tts" + STACK_ID="${STACK_VERSION}:qwen-tts-0.1.1:torch-${TORCH_VERSION}" + ;; + moss) + TORCH_VERSION="2.7.0" + TORCHAUDIO_VERSION="2.7.0" + PACKAGE_SPEC="git+https://github.com/OpenMOSS/MOSS-TTS-Nano.git@cc7bdf19c7639c0870dab22045a33b442760f6be" + IMPORT_NAME="moss_tts_nano" + STACK_ID="${STACK_VERSION}:moss-cc7bdf1:torch-${TORCH_VERSION}" + ;; +esac + +environment_ready() { + [ -x "${VENV}/bin/python" ] || return 1 + [ -f "${MARKER}" ] || return 1 + [ "$(cat "${MARKER}")" = "${STACK_ID}" ] || return 1 + "${VENV}/bin/python" - "${IMPORT_NAME}" "${GPU}" <<'PY' >/dev/null 2>&1 +import importlib +import sys +import torch + +importlib.import_module(sys.argv[1]) +expect_cuda = sys.argv[2].lower() == "true" +if expect_cuda and not torch.cuda.is_available(): + raise SystemExit("NVIDIA GPU was detected but this environment cannot use CUDA") +if torch.cuda.is_available(): + torch.zeros(1, device="cuda") +PY +} + +if environment_ready ; then + echo "✅ Reusing ${ENGINE} TTS environment: ${VENV}" + exit 0 +fi + +echo "===== Preparing isolated ${ENGINE} TTS environment =====" +rm -rf "${VENV}" +"${PYTHON_BIN}" -m venv "${VENV}" +PY="${VENV}/bin/python" +"${PY}" -m pip install -U pip setuptools wheel + +if ${GPU} ; then + TORCH_INDEX="${MWW_TTS_TORCH_INDEX:-https://download.pytorch.org/whl/cu128}" + echo "→ Installing CUDA torch ${TORCH_VERSION} from ${TORCH_INDEX}" + "${PY}" -m pip install \ + "torch==${TORCH_VERSION}" \ + "torchaudio==${TORCHAUDIO_VERSION}" \ + --index-url "${TORCH_INDEX}" +else + echo "→ Installing CPU torch ${TORCH_VERSION}" + "${PY}" -m pip install \ + "torch==${TORCH_VERSION}" \ + "torchaudio==${TORCHAUDIO_VERSION}" +fi + +echo "→ Installing ${PACKAGE_SPEC}" +"${PY}" -m pip install "${PACKAGE_SPEC}" "huggingface_hub[hf_xet]" + +printf '%s\n' "${STACK_ID}" > "${MARKER}" + +if ! environment_ready ; then + echo "❌ ${ENGINE} environment failed its import/CUDA check." >&2 + exit 1 +fi + +echo "✅ ${ENGINE} TTS environment ready: ${VENV}" diff --git a/cli/shell.functions b/cli/shell.functions index 01c63b0..1135ef6 100644 --- a/cli/shell.functions +++ b/cli/shell.functions @@ -12,6 +12,8 @@ DEFAULT_SAMPLES=50000 DEFAULT_BATCH_SIZE=100 DEFAULT_TRAINING_STEPS=40000 DEFAULT_LANGUAGE=en +DEFAULT_TTS_MODE=hybrid +DEFAULT_TTS_VOICE_COUNT=128 [ -f "${DATA_DIR}/.defaults.env" ] && source "${DATA_DIR}/.defaults.env" || : @@ -19,6 +21,8 @@ DEFAULT_LANGUAGE=en : "${BATCH_SIZE:=${DEFAULT_BATCH_SIZE}}" : "${TRAINING_STEPS:=${DEFAULT_TRAINING_STEPS}}" : "${LANGUAGE:=${DEFAULT_LANGUAGE}}" +: "${TTS_MODE:=${DEFAULT_TTS_MODE}}" +: "${TTS_VOICE_COUNT:=${DEFAULT_TTS_VOICE_COUNT}}" : "${CLEANUP_WORK_DIR:=false}" : "${CLEANUP_ARCHIVES:=false}" : "${CLEANUP_INTERMEDIATE_FILES:=false}" diff --git a/cli/tts_generate_samples.py b/cli/tts_generate_samples.py new file mode 100755 index 0000000..eeae1c7 --- /dev/null +++ b/cli/tts_generate_samples.py @@ -0,0 +1,1469 @@ +#!/usr/bin/env python3 +"""Generate a normalized wake-word corpus from the modern TTS ensemble. + +Heavy model dependencies live in per-engine virtual environments. This +orchestrator itself is standard-library-only so it can safely run from the +trainer environment without changing TensorFlow, PyTorch, or Transformers. +""" + +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import math +import os +import random +import shutil +import subprocess +import sys +import wave +from array import array +from collections import Counter +from itertools import product +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from tts_config import ( # noqa: E402 + DEFAULT_TTS_MODE, + ENGINE_MOSS, + ENGINE_OMNIVOICE, + ENGINE_PIPER, + ENGINE_QWEN3, + QWEN_LANGUAGE_NAMES, + distribute_samples, + engines_for_language, + language_for_engine, + normalize_tts_mode, +) + + +GENERATOR_VERSION = "modern-tts-v15-four-provider-direct-corpus-safe-limits" +VOICE_BANK_VERSION = "modern-tts-voice-bank-v1-native-random-qualified-single-utterance" +COMPATIBLE_VOICE_BANK_VERSIONS = { + VOICE_BANK_VERSION, + "modern-tts-v11-native-random-qualified-reference-only-semantic-single-utterance-voices", +} +OMNIVOICE_MODEL = "k2-fsa/OmniVoice" +SPEEDS = (0.85, 0.95, 1.0, 1.05, 1.15) +VOICE_PROFILE_STRIDE = 293 +OMNIVOICE_PROMPT_RETRY_ROUNDS = 4 +OMNIVOICE_CORPUS_RETRY_ROUNDS = 3 +MOSS_CORPUS_RETRY_ROUNDS = 3 +VOICE_BANK_REPLACEMENT_ROUNDS = 6 +OMNIVOICE_REPLACEMENT_FACTOR = 2.0 +OMNIVOICE_POSITION_TEMPERATURE = 5.0 +OMNIVOICE_CLASS_TEMPERATURE = 0.0 +DIRECT_CANDIDATE_FACTORS = { + ENGINE_OMNIVOICE: 1.50, + ENGINE_QWEN3: 1.08, + ENGINE_MOSS: 1.25, + ENGINE_PIPER: 1.05, +} + +CARRIER_PROMPT_TEMPLATES = { + "ar": "بصوت هادئ وطبيعي أقول {phrase} بوضوح، ثم أواصل الحديث بإيقاع ثابت.", + "cs": "Klidným a přirozeným hlasem zřetelně řeknu {phrase} a potom pokračuji rovnoměrným tempem.", + "da": "Med en rolig og naturlig stemme siger jeg {phrase} tydeligt og fortsætter derefter i et jævnt tempo.", + "de": "Mit ruhiger und natürlicher Stimme sage ich deutlich {phrase} und spreche danach in gleichmäßigem Tempo weiter.", + "el": "Με ήρεμη και φυσική φωνή λέω καθαρά {phrase} και μετά συνεχίζω να μιλάω με σταθερό ρυθμό.", + "en": "In a calm and natural voice, I say {phrase} clearly, then continue speaking at an even pace.", + "es": "Con una voz tranquila y natural, digo {phrase} con claridad y después sigo hablando a un ritmo constante.", + "fa": "با صدایی آرام و طبیعی، عبارت {phrase} را واضح می‌گویم و سپس با ریتمی یکنواخت ادامه می‌دهم.", + "fr": "D’une voix calme et naturelle, je dis clairement {phrase}, puis je continue à parler à un rythme régulier.", + "hu": "Nyugodt és természetes hangon tisztán kimondom, hogy {phrase}, majd egyenletes tempóban folytatom.", + "it": "Con una voce calma e naturale, dico chiaramente {phrase} e poi continuo a parlare a un ritmo regolare.", + "ja": "落ち着いた自然な声で {phrase} とはっきり言い、そのまま一定の速さで話し続けます。", + "ko": "차분하고 자연스러운 목소리로 {phrase}라고 또렷하게 말한 뒤 일정한 속도로 계속 말합니다.", + "pl": "Spokojnym i naturalnym głosem wyraźnie mówię {phrase}, a potem kontynuuję w równym tempie.", + "pt": "Com uma voz calma e natural, digo {phrase} com clareza e depois continuo falando em um ritmo constante.", + "ru": "Спокойным и естественным голосом я чётко произношу {phrase}, а затем продолжаю говорить в ровном темпе.", + "sv": "Med en lugn och naturlig röst säger jag {phrase} tydligt och fortsätter sedan i en jämn takt.", + "tr": "Sakin ve doğal bir sesle {phrase} ifadesini açıkça söylüyor, ardından düzenli bir hızda konuşmaya devam ediyorum.", + "zh": "我会用平静自然的声音清楚地说 {phrase},然后保持均匀的语速继续说话。", +} + + +def log(message: str) -> None: + print(message, flush=True) + + +def run(command: list[str], *, env: dict[str, str] | None = None) -> None: + log("→ " + " ".join(command)) + # Some upstream CLIs terminate their entire process group after a fatal + # worker error. Give each model command its own group so that behavior + # cannot kill the trainer/orchestrator process. + subprocess.run(command, check=True, env=env, start_new_session=True) + + +def run_with_batch_retry( + command: list[str], + batch_flag: str, + *, + env: dict[str, str] | None = None, +) -> None: + """Retry a failed batched model command with a single-item batch.""" + + batch_index = command.index(batch_flag) + 1 + preferred = int(command[batch_index]) + try: + run(command, env=env) + except subprocess.CalledProcessError: + if preferred <= 1: + raise + log(f"⚠️ Batch size {preferred} failed; retrying with batch size 1") + retry_command = list(command) + retry_command[batch_index] = "1" + run(retry_command, env=env) + + +def write_jsonl(path: Path, entries: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as stream: + for entry in entries: + stream.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +def phrase_key(phrase: str) -> str: + return hashlib.sha256(phrase.encode("utf-8")).hexdigest()[:16] + + +def reference_text(phrase: str) -> str: + clean = phrase.strip().rstrip(".!?") + return clean + "." + + +def duration_bounds(phrase: str, language: str) -> tuple[float, float, float]: + """Return a natural target and strict min/max for one wake utterance.""" + + clean = phrase.replace("_", " ").strip() + words = [word for word in clean.split() if word] + if len(words) > 1: + units = len(words) + else: + units = max(1.0, len(clean) / (2.5 if language in {"zh", "ja", "ko"} else 5.0)) + target = min(3.2, max(0.9, 0.55 + (0.48 * units))) + minimum = max(0.25, min(0.65, target * 0.35)) + maximum = min(5.0, max(1.8, target * 1.8)) + return minimum, target, maximum + + +def stable_prompt_text(phrase: str, language: str = "en") -> str: + clean = phrase.strip().rstrip(".!?") + template = CARRIER_PROMPT_TEMPLATES.get(language.strip().lower().split("_", 1)[0]) + if template: + return template.format(phrase=clean) + # Experimental OmniVoice languages may not have a trustworthy carrier + # translation yet. A single clean utterance is safer than repeating the + # wake phrase, which can make a cloning prompt collapse into humming. + return clean + "." + + +def qwen_descriptions(language_name: str, count: int) -> list[str]: + genders = ("female", "male") + ages = ("child", "teenager", "young adult", "middle-aged adult", "elderly adult") + pitches = ("low pitch", "medium pitch", "high pitch") + deliveries = ( + "calm neutral delivery", + "bright energetic delivery", + "soft careful delivery", + "confident resonant delivery", + "casual conversational delivery", + ) + textures = ("clear", "warm", "slightly breathy", "crisp", "gently rough") + paces = ("slow", "measured", "natural", "brisk", "quick") + weights = ("light", "balanced", "compact", "full-bodied", "resonant") + combinations = list(product(genders, ages, pitches, deliveries, textures, paces, weights)) + descriptions = [] + # Walking the Cartesian product sequentially clusters the leading traits + # (the first 375 combinations are all female). A coprime stride retains a + # deterministic, non-repeating order while balancing every trait early. + for index in range(count): + combination_index = (index * VOICE_PROFILE_STRIDE) % len(combinations) + gender, age, pitch, delivery, texture, pace, weight = combinations[combination_index] + descriptions.append( + f"A distinct {age} {gender} speaker with a {texture} timbre, " + f"{pitch}, {weight} vocal weight, and {delivery}, speaking native " + f"{language_name} at a {pace} pace. Say only the supplied text once." + ) + return descriptions + + +def omnivoice_stability_args() -> list[str]: + # A zero temperature can deterministically lock a difficult voice condition + # into decoder collapse. Retain upstream sampling and qualify each native + # carrier through a short reference clone before it can enter the bank. + return [ + "--position_temperature", + str(OMNIVOICE_POSITION_TEMPERATURE), + "--class_temperature", + str(OMNIVOICE_CLASS_TEMPERATURE), + ] + + +def read_pcm_metrics(path: Path) -> tuple[float, float, float]: + try: + with wave.open(str(path), "rb") as wav_file: + frames = wav_file.getnframes() + rate = wav_file.getframerate() + width = wav_file.getsampwidth() + channels = wav_file.getnchannels() + raw = wav_file.readframes(frames) + except Exception: + return (0.0, 0.0, 1.0) + if rate <= 0 or width != 2 or channels <= 0 or not raw: + return (0.0, 0.0, 1.0) + samples = array("h") + samples.frombytes(raw) + if sys.byteorder != "little": + samples.byteswap() + if not samples: + return (0.0, 0.0, 1.0) + peak = max(abs(value) for value in samples) / 32767.0 + rms = math.sqrt(sum(value * value for value in samples) / len(samples)) / 32767.0 + clipped = sum(1 for value in samples if abs(value) >= 32760) / len(samples) + return (frames / rate, rms, clipped if peak > 0 else 1.0) + + +def valid_reference(path: Path) -> bool: + duration, rms, clipped = read_pcm_metrics(path) + return 0.2 <= duration <= 5.0 and rms >= 0.003 and clipped <= 0.08 + + +def valid_prompt_reference(path: Path) -> bool: + duration, rms, clipped = read_pcm_metrics(path) + return 0.5 <= duration <= 10.0 and rms >= 0.003 and clipped <= 0.08 + + +def valid_sample(path: Path) -> bool: + duration, rms, clipped = read_pcm_metrics(path) + return 0.12 <= duration <= 5.0 and rms >= 0.002 and clipped <= 0.08 + + +class Generator: + def __init__(self, args: argparse.Namespace): + self.args = args + self.spoken_phrase = args.phrase.replace("_", " ").strip() + self.data_dir = args.data_dir.resolve() + self.output_dir = args.output_dir.resolve() + if self.output_dir == Path(self.output_dir.anchor) or self.output_dir == self.data_dir: + raise ValueError(f"Refusing unsafe TTS output directory: {self.output_dir}") + self.tools_dir = self.data_dir / "tools" + self.tts_envs = self.data_dir / "tts-envs" + self.hf_home = self.data_dir / ".cache" / "huggingface" + self.work_root = self.output_dir.parent + self.build_dir = self.work_root / ".wake_word_samples.build" + self.raw_dir = self.build_dir / "raw" + self.final_dir = self.build_dir / "final" + self.voice_bank_dir = ( + self.data_dir + / "voice-bank" + / args.language + / phrase_key(self.spoken_phrase) + ) + self.reference_text = reference_text(self.spoken_phrase) + self.stable_prompt_text = stable_prompt_text(self.spoken_phrase, args.language) + self.omnivoice_language = language_for_engine(ENGINE_OMNIVOICE, args.language) + self.env = dict(os.environ) + self.env["HF_HOME"] = str(self.hf_home) + self.env["HUGGINGFACE_HUB_CACHE"] = str(self.hf_home / "hub") + self.speed_by_path: dict[Path, float] = {} + self.actual_counts: dict[str, int] = {} + self.reference_qa_batch = 0 + self.accepted_hashes: set[str] = set() + self.direct_attempt = Counter() + self.minimum_duration, self.target_duration, self.maximum_duration = duration_bounds( + self.spoken_phrase, self.args.language + ) + + def piper_models(self) -> list[Path]: + root = self.tools_dir / "piper-sample-generator" + if self.args.language == "en": + model = root / "models" / "en_US-libritts_r-medium.pt" + return [model] if model.is_file() else [] + return sorted((root / "voices").glob(f"{self.args.language}_*.onnx")) + + def piper_available(self) -> bool: + return bool(self.piper_models()) + + def engines(self) -> list[str]: + return engines_for_language( + self.args.language, + self.args.tts_mode, + piper_available=self.piper_available(), + ) + + def signature(self) -> dict: + engines = self.engines() + return { + "generator_version": GENERATOR_VERSION, + "phrase": self.args.phrase, + "language": self.args.language, + "tts_mode": self.args.tts_mode, + "samples": self.args.samples, + "engines": engines, + "models": { + "omnivoice": OMNIVOICE_MODEL, + "qwen_design": "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign", + "moss": "OpenMOSS-Team/MOSS-TTS-Nano", + "piper": [str(path) for path in self.piper_models()], + }, + "corpus_strategy": "direct_unique_candidates_with_provider_safety_gates", + "duration_seconds": [self.minimum_duration, self.maximum_duration], + } + + def cache_hit(self) -> bool: + manifest_path = self.output_dir / ".generation_manifest.json" + if not manifest_path.is_file(): + return False + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + return False + return ( + manifest.get("signature") == self.signature() + and len(list(self.output_dir.glob("*.wav"))) == self.args.samples + ) + + def ensure_environment(self, engine: str) -> Path: + if engine == ENGINE_PIPER: + return self.data_dir / ".venv" / "bin" / "python" + run( + [ + str(ROOT_DIR / "cli" / "setup_modern_tts_envs"), + f"--engine={engine}", + f"--data-dir={self.data_dir}", + ], + env=self.env, + ) + python = self.tts_envs / engine / "bin" / "python" + if not python.is_file(): + raise RuntimeError(f"Missing {engine} Python environment: {python}") + return python + + def _generate_omni_bank(self, count: int, start: int, destination: Path) -> list[dict]: + if count <= 0: + return [] + self.ensure_environment(ENGINE_OMNIVOICE) + prompt_dir = destination / ".omnivoice-prompts" + prompt_dir.mkdir(parents=True, exist_ok=True) + # Generate unconstrained OmniVoice carriers natively. Explicit multi- + # attribute voice instructions caused substantially more decoder + # collapse in testing. The short clone below is the qualification + # probe; only probes that pass speech/semantic QA enter the bank. + prompt_entries = [ + { + "id": f"omni_prompt_{start + index:04d}", + "text": self.stable_prompt_text, + "language_id": self.omnivoice_language, + } + for index in range(count) + ] + prompt_input = destination / f"omni_prompt_{start:04d}.jsonl" + write_jsonl(prompt_input, prompt_entries) + prompt_input_flag = "--test_list" + prompt_batch_flag = "--batch_size" + prompt_command = [ + str(self.tts_envs / ENGINE_OMNIVOICE / "bin" / "omnivoice-infer-batch"), + "--model", + OMNIVOICE_MODEL, + prompt_input_flag, + str(prompt_input), + "--res_dir", + str(prompt_dir), + prompt_batch_flag, + str(max(1, min(self.args.batch_size, 8))), + "--lang_id", + self.omnivoice_language, + ] + omnivoice_stability_args() + try: + run_with_batch_retry(prompt_command, prompt_batch_flag, env=self.env) + except subprocess.CalledProcessError as error: + log(f"⚠️ Voice seed batch exited early; checking individual outputs: {error}") + + def missing_prompts() -> list[dict]: + return [ + entry + for entry in prompt_entries + if not valid_prompt_reference(prompt_dir / f"{entry['id']}.wav") + ] + + for retry_round in range(1, OMNIVOICE_PROMPT_RETRY_ROUNDS + 1): + missing = missing_prompts() + if not missing: + break + for entry in missing: + (prompt_dir / f"{entry['id']}.wav").unlink(missing_ok=True) + log( + f"→ Voice seed repair round {retry_round}: retrying " + f"{len(missing)} missing or invalid prompt(s) individually" + ) + retry_input = destination / f"omni_prompt_{start:04d}.retry-{retry_round}.jsonl" + write_jsonl(retry_input, missing) + retry_command = list(prompt_command) + retry_command[retry_command.index(prompt_input_flag) + 1] = str(retry_input) + retry_command[retry_command.index(prompt_batch_flag) + 1] = "1" + try: + run(retry_command, env=self.env) + except subprocess.CalledProcessError as error: + log(f"⚠️ Voice seed repair round {retry_round} exited early: {error}") + + unresolved = missing_prompts() + if unresolved: + log( + f"⚠️ OmniVoice could not create {len(unresolved)}/{count} stable seed " + "prompt(s); replacement rounds will retry those profiles." + ) + + entries = [] + for index, prompt_entry in enumerate(prompt_entries): + prompt_path = prompt_dir / f"{prompt_entry['id']}.wav" + if not valid_prompt_reference(prompt_path): + continue + entries.append( + { + "id": f"omni_ref_{start + index:04d}", + "text": self.reference_text, + "language_id": self.omnivoice_language, + "ref_audio": str(prompt_path), + "ref_text": self.stable_prompt_text, + # OmniVoice's batch API documents voice instructions for + # voice-design mode when no reference audio is present. + # The Qwen carrier already defines this voice, so cloning + # is deliberately reference-only to avoid conflicting + # conditioning on very short wake-word utterances. + "voice_description": "automatic random voice", + "omnivoice_prompt_path": str(prompt_path), + "omnivoice_prompt_text": self.stable_prompt_text, + } + ) + clone_input = destination / f"omni_clone_{start:04d}.jsonl" + write_jsonl(clone_input, entries) + clone_command = [ + str(self.tts_envs / ENGINE_OMNIVOICE / "bin" / "omnivoice-infer-batch"), + "--model", + OMNIVOICE_MODEL, + "--test_list", + str(clone_input), + "--res_dir", + str(destination), + "--batch_size", + str(max(1, min(self.args.batch_size, 8))), + "--lang_id", + self.omnivoice_language, + ] + omnivoice_stability_args() + if not entries: + return [] + run_with_batch_retry(clone_command, "--batch_size", env=self.env) + return entries + + def _generate_qwen_bank(self, count: int, destination: Path, start: int = 0) -> list[dict]: + if count <= 0: + return [] + python = self.ensure_environment(ENGINE_QWEN3) + language_name = QWEN_LANGUAGE_NAMES[self.args.language] + descriptions = qwen_descriptions(language_name, start + count)[start:] + entries = [ + { + "id": f"qwen_ref_{start + index:04d}", + "text": self.reference_text, + "language_name": language_name, + "instruct": descriptions[index], + "seed": 11000 + start + index, + } + for index in range(count) + ] + input_path = destination / "qwen_bank.jsonl" + write_jsonl(input_path, entries) + run_with_batch_retry( + [ + str(python), + str(ROOT_DIR / "cli" / "tts_qwen_worker.py"), + "--mode", + "bank", + "--input-jsonl", + str(input_path), + "--output-dir", + str(destination), + "--batch-size", + str(max(1, min(self.args.batch_size, 4))), + ], + "--batch-size", + env=self.env, + ) + return entries + + def _reference_qa_python(self) -> Path: + candidates = [] + configured = os.environ.get("REC_VENV_DIR") + if configured: + candidates.append(Path(configured) / "bin" / "python") + candidates.extend( + ( + self.data_dir / ".recorder-venv" / "bin" / "python", + self.data_dir.parent.parent / "recorder-venv" / "bin" / "python", + ) + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + raise RuntimeError( + "Reference QA requires the trainer recorder environment with " + "Faster Whisper and Silero VAD installed." + ) + + def _validate_generated_references( + self, + generated: list[tuple[dict, str]], + destination: Path, + ) -> list[dict]: + candidates = [] + generated_by_id = {} + acoustically_rejected = [] + for entry, source in generated: + path = destination / f"{entry['id']}.wav" + if not path.is_file() or not valid_reference(path): + acoustically_rejected.append(path.name) + continue + candidates.append({"id": entry["id"], "path": str(path)}) + generated_by_id[entry["id"]] = (entry, source, path) + if acoustically_rejected: + examples = ", ".join(acoustically_rejected[:5]) + suffix = "" if len(acoustically_rejected) <= 5 else ", …" + log( + f"⚠️ Acoustic QA rejected {len(acoustically_rejected)} reference(s): " + f"{examples}{suffix}" + ) + if not candidates: + return [] + + self.reference_qa_batch += 1 + qa_input = destination / f"reference_qa_{self.reference_qa_batch:02d}.jsonl" + qa_output = destination / f"reference_qa_{self.reference_qa_batch:02d}.results.jsonl" + write_jsonl(qa_input, candidates) + run( + [ + str(self._reference_qa_python()), + str(ROOT_DIR / "cli" / "tts_reference_qa.py"), + "--input-jsonl", + str(qa_input), + "--output-jsonl", + str(qa_output), + "--phrase", + self.spoken_phrase, + "--language", + self.args.language, + "--download-root", + str(self.data_dir / "auto_train_models"), + ], + env=self.env, + ) + qa_results = { + result["id"]: result + for line in qa_output.read_text(encoding="utf-8").splitlines() + if line.strip() + for result in (json.loads(line),) + } + + voices = [] + rejected_reasons: Counter[str] = Counter() + rejected_examples: dict[str, list[str]] = {} + for candidate in candidates: + entry, source, path = generated_by_id[candidate["id"]] + qa = qa_results.get(entry["id"]) + if not qa or not qa.get("accepted"): + reason = (qa or {}).get("reason", "missing_qa_result") + transcript = (qa or {}).get("transcript", "") + rejected_reasons[reason] += 1 + examples = rejected_examples.setdefault(reason, []) + if len(examples) < 3: + examples.append(f"{path.name}={transcript!r}") + continue + voice = { + "id": entry["id"], + "source": source, + "path": str(path), + "ref_text": self.reference_text, + "language": self.args.language, + "instruct": entry.get( + "voice_description", + entry.get("instruct", "automatic random voice"), + ), + "qa": qa, + } + if entry.get("omnivoice_prompt_path"): + voice["omnivoice_prompt_path"] = entry["omnivoice_prompt_path"] + voice["omnivoice_prompt_text"] = entry["omnivoice_prompt_text"] + voices.append(voice) + if rejected_reasons: + summary = ", ".join( + f"{reason}={count}" for reason, count in sorted(rejected_reasons.items()) + ) + examples = "; ".join( + f"{reason}: {', '.join(items)}" + for reason, items in sorted(rejected_examples.items()) + ) + log(f"⚠️ Reference QA rejected {sum(rejected_reasons.values())}: {summary}") + if examples: + log(f" Examples: {examples}") + return voices + + def ensure_voice_bank(self) -> list[dict]: + qwen_target = self.args.voice_count // 2 if self.args.language in QWEN_LANGUAGE_NAMES else 0 + omni_target = self.args.voice_count - qwen_target + source_targets = Counter( + { + ENGINE_OMNIVOICE: omni_target, + **({ENGINE_QWEN3: qwen_target} if qwen_target else {}), + } + ) + manifest_path = self.voice_bank_dir / "manifest.json" + if manifest_path.is_file(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + voices = manifest.get("voices") or [] + source_counts = Counter(item.get("source") for item in voices) + if ( + manifest.get("version") in COMPATIBLE_VOICE_BANK_VERSIONS + and manifest.get("reference_text") == self.reference_text + and manifest.get("source_targets") == dict(source_targets) + and len(voices) >= self.args.voice_count + and all(source_counts.get(source, 0) >= target for source, target in source_targets.items()) + and all( + Path(item["path"]).is_file() + and ( + not item.get("omnivoice_prompt_path") + or Path(item["omnivoice_prompt_path"]).is_file() + ) + for item in voices + ) + ): + log(f"✅ Reusing {len(voices)} cached voice profiles for {self.args.language}") + return voices[: self.args.voice_count] + except Exception: + pass + + building = self.voice_bank_dir.with_name(self.voice_bank_dir.name + ".building") + shutil.rmtree(building, ignore_errors=True) + building.mkdir(parents=True, exist_ok=True) + generated: list[tuple[dict, str]] = [] + + try: + for entry in self._generate_omni_bank(omni_target, 0, building): + generated.append((entry, ENGINE_OMNIVOICE)) + except Exception as error: + log(f"⚠️ OmniVoice bank pass failed; its replacement rounds will retry: {error}") + + if qwen_target: + try: + for entry in self._generate_qwen_bank(qwen_target, building): + generated.append((entry, ENGINE_QWEN3)) + except Exception as error: + log(f"⚠️ Qwen voice-design pass failed; its replacement rounds will retry: {error}") + + targets = source_targets + voices = self._validate_generated_references(generated, building) + next_index = {ENGINE_OMNIVOICE: self.args.voice_count * 2, ENGINE_QWEN3: self.args.voice_count * 2} + for replacement_round in range(1, VOICE_BANK_REPLACEMENT_ROUNDS + 1): + accepted_counts = Counter(voice["source"] for voice in voices) + deficits = { + source: target - accepted_counts.get(source, 0) + for source, target in targets.items() + if target > accepted_counts.get(source, 0) + } + if not deficits: + break + replacements: list[tuple[dict, str]] = [] + for source, missing in deficits.items(): + requested = max( + missing, + math.ceil( + missing + * (OMNIVOICE_REPLACEMENT_FACTOR if source == ENGINE_OMNIVOICE else 1.1) + ), + ) + start = next_index[source] + next_index[source] += requested + log( + f"→ QA replacement round {replacement_round}: generating " + f"{requested} {source} candidate(s) for {missing} missing profile(s)" + ) + if source == ENGINE_QWEN3: + try: + entries = self._generate_qwen_bank(requested, building, start) + except Exception as error: + log(f"⚠️ Qwen replacement round {replacement_round} failed: {error}") + continue + else: + try: + entries = self._generate_omni_bank(requested, start, building) + except Exception as error: + log(f"⚠️ OmniVoice replacement round {replacement_round} failed: {error}") + continue + replacements.extend((entry, source) for entry in entries) + accepted_replacements = self._validate_generated_references(replacements, building) + for voice in accepted_replacements: + source = voice["source"] + if accepted_counts.get(source, 0) < targets[source]: + voices.append(voice) + accepted_counts[source] = accepted_counts.get(source, 0) + 1 + + accepted_counts = Counter(voice["source"] for voice in voices) + source_deficits = { + source: target - accepted_counts.get(source, 0) + for source, target in targets.items() + if accepted_counts.get(source, 0) < target + } + if source_deficits: + raise RuntimeError( + f"Voice bank did not reach its validated source mix: {source_deficits}." + ) + + self.voice_bank_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.rmtree(self.voice_bank_dir, ignore_errors=True) + shutil.move(str(building), str(self.voice_bank_dir)) + for voice in voices: + for key in ("path", "omnivoice_prompt_path"): + if voice.get(key): + relative_path = Path(voice[key]).relative_to(building) + voice[key] = str(self.voice_bank_dir / relative_path) + manifest = { + "version": VOICE_BANK_VERSION, + "language": self.args.language, + "reference_text": self.reference_text, + "source_targets": dict(source_targets), + "voices": voices, + } + (self.voice_bank_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + log(f"✅ Created {len(voices)} reusable voice profiles") + return voices[: self.args.voice_count] + + def make_entries( + self, + engine: str, + count: int, + voices: list[dict], + destination: Path, + prefix: str = "", + ) -> list[dict]: + rng = random.Random(24051984 + count + sum(ord(ch) for ch in engine + prefix)) + entries = [] + for index in range(count): + voice_pool = voices + if engine == ENGINE_OMNIVOICE: + stable_omni_voices = [voice for voice in voices if voice.get("omnivoice_prompt_path")] + if not stable_omni_voices: + raise RuntimeError( + "OmniVoice generation requires a validated long-form seed prompt." + ) + voice_pool = stable_omni_voices + voice = voice_pool[index % len(voice_pool)] + item_id = f"{engine}_{prefix}{index:06d}" + # MOSS short utterances are fragile under post-generation tempo + # changes; preserve the exact audio that passed semantic QA. + speed = 1.0 if engine == ENGINE_MOSS else SPEEDS[index % len(SPEEDS)] + entry = { + "id": item_id, + "text": self.spoken_phrase, + "ref_audio": voice.get("omnivoice_prompt_path", voice["path"]) + if engine == ENGINE_OMNIVOICE + else voice["path"], + "ref_text": voice.get("omnivoice_prompt_text", voice["ref_text"]) + if engine == ENGINE_OMNIVOICE + else voice["ref_text"], + "seed": rng.randrange(1, 2**31 - 1), + } + if engine == ENGINE_OMNIVOICE: + entry["language_id"] = self.omnivoice_language + elif engine == ENGINE_QWEN3: + entry["language_name"] = QWEN_LANGUAGE_NAMES[self.args.language] + entries.append(entry) + self.speed_by_path[(destination / f"{item_id}.wav").resolve()] = speed + return entries + + def _repair_generated_corpus( + self, + engine: str, + entries: list[dict], + destination: Path, + generation_command: list[str], + prefix: str, + *, + speech_only: bool, + input_flag: str, + batch_flag: str | None = None, + ) -> list[Path]: + """Repair engine failures before they can enter the final corpus.""" + + pending = {entry["id"]: entry for entry in entries} + accepted_ids: set[str] = set() + label = prefix or "main" + retry_rounds = ( + OMNIVOICE_CORPUS_RETRY_ROUNDS if speech_only else MOSS_CORPUS_RETRY_ROUNDS + ) + gate_name = "speech" if speech_only else "semantic" + for qa_round in range(1, retry_rounds + 2): + if engine == ENGINE_MOSS: + for item_id in pending: + path = destination / f"{item_id}.wav" + if not path.is_file(): + continue + qa_path = path.with_suffix(".qa.wav") + try: + subprocess.run( + [ + self.args.ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(path), + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "pcm_s16le", + str(qa_path), + ], + check=True, + ) + qa_path.replace(path) + except subprocess.CalledProcessError: + qa_path.unlink(missing_ok=True) + candidates = [ + {"id": item_id, "path": str(destination / f"{item_id}.wav")} + for item_id in pending + if (destination / f"{item_id}.wav").is_file() + ] + qa_input = self.build_dir / f"{engine}_{label}.{gate_name}-{qa_round}.jsonl" + qa_output = self.build_dir / f"{engine}_{label}.{gate_name}-{qa_round}.results.jsonl" + write_jsonl(qa_input, candidates) + if candidates: + qa_command = [ + str(self._reference_qa_python()), + str(ROOT_DIR / "cli" / "tts_reference_qa.py"), + "--input-jsonl", + str(qa_input), + "--output-jsonl", + str(qa_output), + "--phrase", + self.spoken_phrase, + "--language", + self.args.language, + "--download-root", + str(self.data_dir / "auto_train_models"), + ] + if speech_only: + qa_command.append("--speech-only") + run( + qa_command, + env=self.env, + ) + round_accepted = { + result["id"] + for line in qa_output.read_text(encoding="utf-8").splitlines() + if line.strip() + for result in (json.loads(line),) + if result.get("accepted") + } + else: + round_accepted = set() + accepted_ids.update(round_accepted) + pending = { + item_id: entry + for item_id, entry in pending.items() + if item_id not in round_accepted + } + if not pending: + break + for item_id in pending: + (destination / f"{item_id}.wav").unlink(missing_ok=True) + if qa_round > retry_rounds: + log( + f"⚠️ {engine} {gate_name} gate dropped {len(pending)} persistent " + "bad decode(s); another engine will fill them." + ) + break + log( + f"→ {engine} {gate_name} gate repair {qa_round}: regenerating " + f"{len(pending)} bad decode(s)" + + (" without STT" if speech_only else "") + ) + retry_input = self.build_dir / f"{engine}_{label}.{gate_name}-retry-{qa_round}.jsonl" + write_jsonl(retry_input, list(pending.values())) + retry_command = list(generation_command) + retry_command[retry_command.index(input_flag) + 1] = str(retry_input) + if batch_flag: + run_with_batch_retry(retry_command, batch_flag, env=self.env) + else: + run(retry_command, env=self.env) + return [ + destination / f"{entry['id']}.wav" + for entry in entries + if entry["id"] in accepted_ids + ] + + def generate_engine(self, engine: str, count: int, voices: list[dict], prefix: str = "") -> list[Path]: + if count <= 0: + return [] + destination_name = engine if not prefix else f"{engine}_{prefix.rstrip('_')}" + destination = self.raw_dir / destination_name + destination.mkdir(parents=True, exist_ok=True) + requested = count + max(2, math.ceil(count * 0.01)) + + if engine == ENGINE_PIPER: + python = self.data_dir / ".venv" / "bin" / "python" + generator = self.tools_dir / "piper-sample-generator" / "generate_samples.py" + command = [ + str(python), + str(generator), + self.spoken_phrase, + "--max-samples", + str(requested), + "--batch-size", + str(self.args.batch_size), + "--output-dir", + str(destination), + "--max-speakers", + "100", + ] + for model in self.piper_models(): + command.extend(("--model", str(model))) + run(command, env=self.env) + for index, path in enumerate(sorted(destination.glob("*.wav"))): + self.speed_by_path[path.resolve()] = SPEEDS[index % len(SPEEDS)] + return sorted(destination.glob("*.wav")) + + entries = self.make_entries(engine, requested, voices, destination, prefix=prefix) + input_path = self.build_dir / f"{engine}_{prefix or 'main'}.jsonl" + write_jsonl(input_path, entries) + python = self.ensure_environment(engine) + if engine == ENGINE_OMNIVOICE: + generation_command = [ + str(self.tts_envs / engine / "bin" / "omnivoice-infer-batch"), + "--model", + OMNIVOICE_MODEL, + "--test_list", + str(input_path), + "--res_dir", + str(destination), + "--batch_size", + str(max(1, min(self.args.batch_size, 8))), + "--lang_id", + self.omnivoice_language, + ] + omnivoice_stability_args() + run_with_batch_retry( + generation_command, + "--batch_size", + env=self.env, + ) + return self._repair_generated_corpus( + engine, + entries, + destination, + generation_command, + prefix, + speech_only=True, + input_flag="--test_list", + batch_flag="--batch_size", + ) + elif engine == ENGINE_QWEN3: + run_with_batch_retry( + [ + str(python), + str(ROOT_DIR / "cli" / "tts_qwen_worker.py"), + "--mode", + "generate", + "--input-jsonl", + str(input_path), + "--output-dir", + str(destination), + "--batch-size", + str(max(1, min(self.args.batch_size, 4))), + ], + "--batch-size", + env=self.env, + ) + elif engine == ENGINE_MOSS: + generation_command = [ + str(python), + str(ROOT_DIR / "cli" / "tts_moss_worker.py"), + "--input-jsonl", + str(input_path), + "--output-dir", + str(destination), + ] + run(generation_command, env=self.env) + return self._repair_generated_corpus( + engine, + entries, + destination, + generation_command, + prefix, + speech_only=False, + input_flag="--input-jsonl", + ) + return sorted(destination.glob(f"{engine}_{prefix}*.wav")) + + def make_direct_entries( + self, + engine: str, + count: int, + destination: Path, + reference_paths: list[Path], + prefix: str = "", + ) -> list[dict]: + """Describe unique final candidates; no reusable 128-voice bank.""" + + start = self.direct_attempt[engine] + self.direct_attempt[engine] += count + rng = random.Random(24051984 + start + sum(ord(ch) for ch in engine + prefix)) + descriptions = ( + qwen_descriptions(QWEN_LANGUAGE_NAMES[self.args.language], start + count)[start:] + if engine == ENGINE_QWEN3 + else [] + ) + duration_scales = (0.82, 0.91, 1.0, 1.09, 1.18) + entries = [] + for index in range(count): + absolute_index = start + index + item_id = f"{engine}_{prefix}{absolute_index:07d}" + entry = { + "id": item_id, + "text": self.reference_text, + "seed": rng.randrange(1, 2**31 - 1), + "minimum_duration": self.minimum_duration, + "maximum_duration": self.maximum_duration, + } + if engine == ENGINE_OMNIVOICE: + entry.update( + { + "language_id": self.omnivoice_language, + "duration": round( + min( + self.maximum_duration * 0.9, + max( + self.minimum_duration * 1.5, + self.target_duration * duration_scales[absolute_index % len(duration_scales)], + ), + ), + 3, + ), + } + ) + elif engine == ENGINE_QWEN3: + entry.update( + { + "language_name": QWEN_LANGUAGE_NAMES[self.args.language], + "instruct": descriptions[index], + } + ) + elif engine == ENGINE_MOSS: + if index >= len(reference_paths): + raise RuntimeError("MOSS direct corpus generation exhausted unique accepted references.") + entry.update( + { + "ref_audio": str(reference_paths[index]), + "ref_text": self.reference_text, + } + ) + entries.append(entry) + self.speed_by_path[(destination / f"{item_id}.wav").resolve()] = 1.0 + return entries + + def generate_direct_engine( + self, + engine: str, + count: int, + reference_paths: list[Path], + prefix: str = "", + ) -> tuple[list[dict], list[Path]]: + if count <= 0: + return [], [] + destination_name = engine if not prefix else f"{engine}_{prefix.rstrip('_')}" + destination = self.raw_dir / destination_name + destination.mkdir(parents=True, exist_ok=True) + requested = max(count, math.ceil(count * DIRECT_CANDIDATE_FACTORS[engine])) + if engine == ENGINE_MOSS: + requested = min(requested, len(reference_paths)) + if requested < count: + log( + f"⚠️ MOSS has only {requested} unique accepted carrier(s) for " + f"{count} requested take(s); a direct provider will fill the remainder." + ) + + if engine == ENGINE_PIPER: + command = [ + str(self.data_dir / ".venv" / "bin" / "python"), + str(self.tools_dir / "piper-sample-generator" / "generate_samples.py"), + self.spoken_phrase, + "--max-samples", + str(requested), + "--batch-size", + str(self.args.batch_size), + "--output-dir", + str(destination), + ] + for model in self.piper_models(): + command.extend(("--model", str(model))) + run(command, env=self.env) + paths = sorted(destination.glob("*.wav")) + entries = [ + { + "id": path.stem, + "minimum_duration": self.minimum_duration, + "maximum_duration": self.maximum_duration, + } + for path in paths + ] + return entries, paths + + entries = self.make_direct_entries(engine, requested, destination, reference_paths, prefix) + input_path = self.build_dir / f"{engine}_{prefix or 'main'}.direct.jsonl" + write_jsonl(input_path, entries) + python = self.ensure_environment(engine) + if engine == ENGINE_OMNIVOICE: + command = [ + str(self.tts_envs / engine / "bin" / "omnivoice-infer-batch"), + "--model", + OMNIVOICE_MODEL, + "--test_list", + str(input_path), + "--res_dir", + str(destination), + "--batch_size", + str(max(1, min(self.args.batch_size, 8))), + "--lang_id", + self.omnivoice_language, + "--num_step", + "32", + "--denoise", + "True", + "--postprocess_output", + "True", + ] + omnivoice_stability_args() + run_with_batch_retry(command, "--batch_size", env=self.env) + elif engine == ENGINE_QWEN3: + command = [ + str(python), + str(ROOT_DIR / "cli" / "tts_qwen_worker.py"), + "--mode", + "direct", + "--input-jsonl", + str(input_path), + "--output-dir", + str(destination), + "--batch-size", + str(max(1, min(self.args.batch_size, 4))), + ] + run_with_batch_retry(command, "--batch-size", env=self.env) + elif engine == ENGINE_MOSS: + run( + [ + str(python), + str(ROOT_DIR / "cli" / "tts_moss_worker.py"), + "--input-jsonl", + str(input_path), + "--output-dir", + str(destination), + ], + env=self.env, + ) + return entries, [ + destination / f"{entry['id']}.wav" + for entry in entries + if (destination / f"{entry['id']}.wav").is_file() + ] + + def qualify_direct_candidates( + self, + engine: str, + entries: list[dict], + paths: list[Path], + prefix: str = "", + ) -> list[Path]: + entries_by_id = {entry["id"]: entry for entry in entries} + candidates = [] + for path in paths: + entry = entries_by_id.get(path.stem, {}) + candidates.append( + { + "id": path.stem, + "path": str(path), + "minimum_duration": entry.get("minimum_duration", self.minimum_duration), + "maximum_duration": entry.get("maximum_duration", self.maximum_duration), + } + ) + if not candidates: + return [] + label = prefix or "main" + qa_input = self.build_dir / f"{engine}_{label}.direct-qa.jsonl" + qa_output = self.build_dir / f"{engine}_{label}.direct-qa.results.jsonl" + write_jsonl(qa_input, candidates) + run( + [ + str(self._reference_qa_python()), + str(ROOT_DIR / "cli" / "tts_reference_qa.py"), + "--input-jsonl", + str(qa_input), + "--output-jsonl", + str(qa_output), + "--phrase", + self.spoken_phrase, + "--language", + self.args.language, + "--download-root", + str(self.data_dir / "auto_train_models"), + "--speech-only", + "--profile", + engine, + ], + env=self.env, + ) + results = [ + json.loads(line) + for line in qa_output.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + accepted_ids = {result["id"] for result in results if result.get("accepted")} + rejected = Counter(result.get("reason", "unknown") for result in results if not result.get("accepted")) + if rejected: + log( + f"⚠️ {engine} safety gate rejected {sum(rejected.values())} candidate(s): " + + ", ".join(f"{reason}={count}" for reason, count in sorted(rejected.items())) + ) + return [path for path in paths if path.stem in accepted_ids] + + def normalize(self, paths: list[Path], start_index: int, limit: int) -> list[Path]: + accepted = [] + self.final_dir.mkdir(parents=True, exist_ok=True) + for path in paths: + if len(accepted) >= limit: + break + final_path = self.final_dir / f"{start_index + len(accepted)}.wav" + speed = self.speed_by_path.get(path.resolve(), 1.0) + temp_path = final_path.with_suffix(".tmp.wav") + command = [ + self.args.ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(path), + "-vn", + "-af", + f"atempo={speed}", + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "pcm_s16le", + str(temp_path), + ] + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError: + temp_path.unlink(missing_ok=True) + continue + digest = hashlib.sha256(temp_path.read_bytes()).hexdigest() if temp_path.is_file() else "" + if valid_sample(temp_path) and digest and digest not in self.accepted_hashes: + temp_path.replace(final_path) + self.accepted_hashes.add(digest) + accepted.append(final_path) + else: + temp_path.unlink(missing_ok=True) + return accepted + + def generate(self) -> None: + if self.cache_hit(): + log("✅ Reusing the matching direct-generated TTS corpus.") + return + + engines = self.engines() + if not engines: + raise RuntimeError( + f"No TTS engine is available for language={self.args.language} mode={self.args.tts_mode}." + ) + shutil.rmtree(self.build_dir, ignore_errors=True) + self.raw_dir.mkdir(parents=True, exist_ok=True) + self.final_dir.mkdir(parents=True, exist_ok=True) + plan = distribute_samples(self.args.samples, engines) + log(f"===== Direct TTS corpus plan ({self.args.tts_mode}, {self.args.language}) =====") + for engine, count in plan.items(): + log(f" {engine}: {count} sample(s)") + log( + f" safety duration: {self.minimum_duration:.2f}–{self.maximum_duration:.2f}s; " + "static, silence, clipping, rambling, and exact duplicates are rejected" + ) + + accepted: list[Path] = [] + successful_engines: list[str] = [] + ordered_engines = [ + engine + for engine in (ENGINE_QWEN3, ENGINE_PIPER, ENGINE_OMNIVOICE, ENGINE_MOSS) + if engine in plan + ] + for engine in ordered_engines: + count = plan[engine] + try: + entries, raw_paths = self.generate_direct_engine(engine, count, list(accepted)) + qualified_paths = self.qualify_direct_candidates(engine, entries, raw_paths) + requested_accepts = min(count, self.args.samples - len(accepted)) + normalized = self.normalize(qualified_paths, len(accepted), requested_accepts) + accepted.extend(normalized) + self.actual_counts[engine] = len(normalized) + if normalized: + successful_engines.append(engine) + log(f"✅ {engine}: accepted {len(normalized)} normalized sample(s)") + except Exception as error: + self.actual_counts[engine] = 0 + log(f"⚠️ {engine} generation failed; another engine will fill its share: {error}") + + missing = self.args.samples - len(accepted) + fallback_candidates = [ + engine + for engine in (ENGINE_QWEN3, ENGINE_PIPER, ENGINE_OMNIVOICE, ENGINE_MOSS) + if engine in successful_engines + or (engine == ENGINE_PIPER and engine in engines and self.piper_available()) + ] + for attempt in range(6): + if missing <= 0 or not fallback_candidates: + break + engine = fallback_candidates[attempt % len(fallback_candidates)] + log(f"→ Filling {missing} rejected/missing sample(s) with {engine}") + try: + entries, raw_paths = self.generate_direct_engine( + engine, + missing, + list(accepted), + prefix=f"fallback{attempt}_", + ) + qualified_paths = self.qualify_direct_candidates( + engine, + entries, + raw_paths, + prefix=f"fallback{attempt}_", + ) + normalized = self.normalize(qualified_paths, len(accepted), missing) + accepted.extend(normalized) + self.actual_counts[engine] = self.actual_counts.get(engine, 0) + len(normalized) + except Exception as error: + log(f"⚠️ {engine} fallback failed: {error}") + missing = self.args.samples - len(accepted) + + if len(accepted) < self.args.samples: + raise RuntimeError( + f"Only {len(accepted)} of {self.args.samples} samples passed normalization and QA." + ) + + # Remove any accepted overage and ensure contiguous numeric file names. + for path in list(self.final_dir.glob("*.wav")): + try: + index = int(path.stem) + except ValueError: + path.unlink(missing_ok=True) + continue + if index >= self.args.samples: + path.unlink(missing_ok=True) + + manifest = { + "signature": self.signature(), + "planned_counts": plan, + "actual_counts": self.actual_counts, + "voice_bank": "", + "generation_strategy": { + "direct_final_candidates": True, + "reusable_profile_bank": False, + "moss_unique_accepted_carriers": True, + "piper_all_model_speakers": True, + }, + "qa": { + "audio_format": "16 kHz mono PCM16 WAV", + "duration_seconds": [self.minimum_duration, self.maximum_duration], + "minimum_rms": 0.004, + "maximum_clipped_ratio": 0.01, + "provider_specific_acoustic_gate": True, + "static_and_broadband_noise_gate": True, + "exact_duplicate_gate": True, + "qwen_max_acoustic_tokens": 48, + "moss_max_acoustic_frames": 64, + "omnivoice_fixed_short_duration": True, + }, + } + (self.final_dir / ".generation_manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + shutil.rmtree(self.output_dir, ignore_errors=True) + shutil.move(str(self.final_dir), str(self.output_dir)) + shutil.rmtree(self.build_dir, ignore_errors=True) + log(f"✅ Generated {self.args.samples} ensemble sample(s) in {self.output_dir}") + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + result.add_argument("phrase") + result.add_argument("--language", default="en") + result.add_argument("--tts-mode", default=DEFAULT_TTS_MODE) + result.add_argument("--samples", type=int, default=50000) + result.add_argument("--batch-size", type=int, default=8) + result.add_argument( + "--voice-count", + type=int, + default=128, + help=argparse.SUPPRESS, # accepted for compatibility; direct generation ignores it + ) + result.add_argument("--data-dir", type=Path, default=Path("/data")) + result.add_argument("--output-dir", type=Path, required=True) + result.add_argument("--ffmpeg", default=shutil.which("ffmpeg") or "ffmpeg") + result.add_argument("--dry-run", action="store_true") + return result + + +def main() -> int: + args = parser().parse_args() + args.language = args.language.strip().lower().replace("-", "_") + args.tts_mode = normalize_tts_mode(args.tts_mode) + if args.samples < 1: + raise SystemExit("--samples must be positive") + if args.voice_count < 1: + raise SystemExit("--voice-count must be positive") + generator = Generator(args) + if args.dry_run: + engines = generator.engines() + print( + json.dumps( + { + "signature": generator.signature(), + "plan": distribute_samples(args.samples, engines), + "piper_available": generator.piper_available(), + }, + indent=2, + ) + ) + return 0 + + lock_dir = generator.data_dir / ".locks" + lock_dir.mkdir(parents=True, exist_ok=True) + with (lock_dir / "tts-gpu.lock").open("w", encoding="utf-8") as lock_file: + log("→ Waiting for the TTS GPU lock") + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + generator.hf_home.mkdir(parents=True, exist_ok=True) + generator.generate() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/tts_moss_worker.py b/cli/tts_moss_worker.py new file mode 100755 index 0000000..6bc870c --- /dev/null +++ b/cli/tts_moss_worker.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Persistent-process MOSS-TTS-Nano voice-cloning worker.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import torch +from transformers import AutoModelForCausalLM + +from moss_tts_nano.defaults import ( + DEFAULT_AUDIO_TOKENIZER_PATH, + DEFAULT_CHECKPOINT_PATH, +) + + +MOSS_AUDIO_TOKENIZER_TYPE = "moss-audio-tokenizer-nano" + + +def read_jsonl(path: Path) -> list[dict]: + entries = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + entries.append(json.loads(line)) + return entries + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--input-jsonl", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--checkpoint", default=str(DEFAULT_CHECKPOINT_PATH)) + parser.add_argument( + "--audio-tokenizer", + default=str(DEFAULT_AUDIO_TOKENIZER_PATH), + ) + args = parser.parse_args() + + entries = read_jsonl(args.input_jsonl) + if not entries: + return 0 + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + if device.type == "cuda": + dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 + else: + dtype = torch.float32 + + model = AutoModelForCausalLM.from_pretrained( + args.checkpoint, + trust_remote_code=True, + ) + model.to(device=device, dtype=dtype) + if hasattr(model, "_set_attention_implementation"): + model._set_attention_implementation("sdpa") + model.eval() + + args.output_dir.mkdir(parents=True, exist_ok=True) + for index, item in enumerate(entries, start=1): + seed = int(item.get("seed", index)) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + output_path = args.output_dir / f"{item['id']}.wav" + model.inference( + text=str(item["text"]), + output_audio_path=str(output_path), + mode="voice_clone", + prompt_text=str(item["ref_text"]), + prompt_audio_path=str(item["ref_audio"]), + reference_audio_path=None, + text_tokenizer_path=None, + audio_tokenizer_type=MOSS_AUDIO_TOKENIZER_TYPE, + audio_tokenizer_pretrained_name_or_path=args.audio_tokenizer, + device=device, + nq=None, + max_new_frames=64, + voice_clone_max_text_tokens=32, + voice_clone_max_memory_per_sample_gb=1.0, + do_sample=True, + use_kv_cache=True, + text_temperature=1.0, + text_top_p=1.0, + text_top_k=50, + audio_temperature=0.7, + audio_top_p=0.9, + audio_top_k=25, + audio_repetition_penalty=1.3, + ) + if index % 10 == 0 or index == len(entries): + print(f"MOSS generated {index}/{len(entries)}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/tts_qwen_worker.py b/cli/tts_qwen_worker.py new file mode 100755 index 0000000..efce142 --- /dev/null +++ b/cli/tts_qwen_worker.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Persistent-process Qwen3-TTS worker used by the sample orchestrator.""" + +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path + +import soundfile as sf +import torch +from qwen_tts import Qwen3TTSModel + + +VOICE_DESIGN_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign" +VOICE_CLONE_MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base" + + +def read_jsonl(path: Path) -> list[dict]: + entries = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + entries.append(json.loads(line)) + return entries + + +def chunks(values: list, size: int): + for index in range(0, len(values), size): + yield values[index : index + size] + + +def runtime() -> tuple[str, torch.dtype]: + if torch.cuda.is_available(): + dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 + return "cuda:0", dtype + return "cpu", torch.float32 + + +def load_model(model_id: str) -> Qwen3TTSModel: + device, dtype = runtime() + return Qwen3TTSModel.from_pretrained( + model_id, + device_map=device, + dtype=dtype, + attn_implementation="sdpa", + ) + + +def build_bank(entries: list[dict], output_dir: Path, batch_size: int) -> None: + model = load_model(VOICE_DESIGN_MODEL) + output_dir.mkdir(parents=True, exist_ok=True) + for batch in chunks(entries, max(1, batch_size)): + seed = int(batch[0].get("seed", 0)) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + wavs, sample_rate = model.generate_voice_design( + text=[str(item["text"]) for item in batch], + language=[str(item["language_name"]) for item in batch], + instruct=[str(item["instruct"]) for item in batch], + ) + for item, wav in zip(batch, wavs): + sf.write(output_dir / f"{item['id']}.wav", wav, sample_rate) + + +def generate_direct(entries: list[dict], output_dir: Path, batch_size: int) -> None: + """Create every final corpus candidate with a fresh voice design.""" + + model = load_model(VOICE_DESIGN_MODEL) + output_dir.mkdir(parents=True, exist_ok=True) + completed = 0 + for batch in chunks(entries, max(1, batch_size)): + seed = int(batch[0].get("seed", completed + 1)) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + wavs, sample_rate = model.generate_voice_design( + text=[str(item["text"]) for item in batch], + language=[str(item["language_name"]) for item in batch], + instruct=[str(item["instruct"]) for item in batch], + # Qwen emits 12 acoustic frames per second. Four seconds is a hard + # wake-phrase ceiling and prevents decoder rambling. + max_new_tokens=48, + temperature=0.8, + top_k=50, + top_p=0.9, + repetition_penalty=1.12, + ) + for item, wav in zip(batch, wavs): + sf.write(output_dir / f"{item['id']}.wav", wav, sample_rate) + completed += 1 + if completed % 25 == 0 or completed == len(entries): + print(f"Qwen direct generation created {completed}/{len(entries)}", flush=True) + + +def generate(entries: list[dict], output_dir: Path, batch_size: int) -> None: + model = load_model(VOICE_CLONE_MODEL) + output_dir.mkdir(parents=True, exist_ok=True) + grouped: dict[tuple[str, str, str], list[dict]] = defaultdict(list) + for item in entries: + key = ( + str(item["ref_audio"]), + str(item["ref_text"]), + str(item["language_name"]), + ) + grouped[key].append(item) + + for (ref_audio, ref_text, language_name), group in grouped.items(): + prompt = model.create_voice_clone_prompt( + ref_audio=ref_audio, + ref_text=ref_text, + x_vector_only_mode=False, + ) + for batch in chunks(group, max(1, batch_size)): + seed = int(batch[0].get("seed", 0)) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + wavs, sample_rate = model.generate_voice_clone( + text=[str(item["text"]) for item in batch], + language=[language_name] * len(batch), + voice_clone_prompt=prompt, + ) + for item, wav in zip(batch, wavs): + sf.write(output_dir / f"{item['id']}.wav", wav, sample_rate) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=("bank", "direct", "generate"), required=True) + parser.add_argument("--input-jsonl", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--batch-size", type=int, default=4) + args = parser.parse_args() + + entries = read_jsonl(args.input_jsonl) + if not entries: + return 0 + if args.mode == "bank": + build_bank(entries, args.output_dir, args.batch_size) + elif args.mode == "direct": + generate_direct(entries, args.output_dir, args.batch_size) + else: + generate(entries, args.output_dir, args.batch_size) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/tts_reference_qa.py b/cli/tts_reference_qa.py new file mode 100644 index 0000000..6a92757 --- /dev/null +++ b/cli/tts_reference_qa.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""Batch semantic and speech-presence QA for synthetic voice references.""" + +from __future__ import annotations + +import argparse +import json +import re +import unicodedata +import wave +from difflib import SequenceMatcher +from pathlib import Path +from typing import Any + + +MIN_PHRASE_SIMILARITY = 0.68 +MIN_SPEECH_RATIO = 0.20 + +ACOUSTIC_LIMITS = { + "omnivoice": { + "minimum_speech_ratio": 0.25, + "maximum_spectral_flatness": 0.18, + "maximum_high_frequency_ratio": 0.30, + "maximum_zero_crossing_rate": 0.28, + }, + "qwen3": { + "minimum_speech_ratio": 0.15, + "maximum_spectral_flatness": 0.25, + "maximum_high_frequency_ratio": 0.35, + "maximum_zero_crossing_rate": 0.32, + "vad_bypass_flatness": 0.11, + }, + "moss": { + "minimum_speech_ratio": 0.20, + "maximum_spectral_flatness": 0.22, + "maximum_high_frequency_ratio": 0.32, + "maximum_zero_crossing_rate": 0.30, + "vad_bypass_flatness": 0.10, + }, + "piper": { + "minimum_speech_ratio": 0.18, + "maximum_spectral_flatness": 0.22, + "maximum_high_frequency_ratio": 0.32, + "maximum_zero_crossing_rate": 0.30, + "vad_bypass_flatness": 0.10, + }, +} + + +def normalize_text(value: Any) -> str: + text = unicodedata.normalize("NFKC", str(value or "")).casefold().replace("_", " ") + text = re.sub(r"[^\w]+", " ", text, flags=re.UNICODE) + return re.sub(r"\s+", " ", text).strip() + + +def phrase_similarity(transcript: Any, expected_phrase: Any) -> float: + transcript_words = normalize_text(transcript).split() + phrase_words = normalize_text(expected_phrase).split() + if not transcript_words or not phrase_words: + return 0.0 + phrase_token = "".join(phrase_words) + best_score = 0.0 + minimum_words = max(1, len(phrase_words) - 1) + maximum_words = min(len(transcript_words), len(phrase_words) + 1) + for word_count in range(minimum_words, maximum_words + 1): + for start in range(0, len(transcript_words) - word_count + 1): + candidate = "".join(transcript_words[start : start + word_count]) + best_score = max(best_score, SequenceMatcher(None, candidate, phrase_token).ratio()) + return best_score + + +def transcript_matches_phrase(transcript: Any, expected_phrase: Any) -> bool: + transcript_words = normalize_text(transcript).split() + phrase_words = normalize_text(expected_phrase).split() + if not transcript_words or not phrase_words: + return False + transcript_token = "".join(transcript_words) + phrase_token = "".join(phrase_words) + complete_phrase = transcript_token.count(phrase_token) == 1 + has_full_word_shape = len(transcript_words) >= len(phrase_words) + has_single_utterance_shape = len(transcript_words) <= len(phrase_words) + 1 + repeats_expected_word = any( + transcript_words.count(word) > phrase_words.count(word) + for word in set(phrase_words) + ) + return has_single_utterance_shape and not repeats_expected_word and ( + complete_phrase + or ( + has_full_word_shape + and phrase_similarity(transcript, expected_phrase) >= MIN_PHRASE_SIMILARITY + ) + ) + + +def semantic_rejection_reason( + transcript: Any, + expected_phrase: Any, + detected_speech_ratio: float, +) -> str: + """Distinguish obvious decoder collapse from an uncertain ASR mismatch.""" + + transcript_words = normalize_text(transcript).split() + phrase_words = normalize_text(expected_phrase).split() + transcript_token = "".join(transcript_words) + phrase_token = "".join(phrase_words) + if phrase_token and transcript_token.count(phrase_token) > 1: + return "repeated_phrase" + if any( + transcript_words.count(word) > phrase_words.count(word) + for word in set(phrase_words) + ): + return "repeated_phrase" + if not transcript_token: + return "no_speech_detected" if detected_speech_ratio < MIN_SPEECH_RATIO else "decoder_collapse" + # OmniVoice's failed diffusion samples commonly become one sustained + # vowel/hum (Whisper renders these as "ehhhh", "aaaa", or "hmm"). + if len(transcript_token) >= 3 and set(transcript_token) <= set("aeiouhmy"): + return "decoder_collapse" + return "phrase_mismatch" + + +def read_resampled_audio(path: Path): + import numpy as np + + with wave.open(str(path), "rb") as stream: + channels = stream.getnchannels() + sample_width = stream.getsampwidth() + sample_rate = stream.getframerate() + frames = stream.getnframes() + raw = stream.readframes(frames) + if channels < 1 or sample_width != 2 or sample_rate <= 0 or not raw: + raise ValueError("expected PCM16 WAV audio") + audio = np.frombuffer(raw, dtype=" 1: + audio = audio.reshape(-1, channels).mean(axis=1) + audio /= 32768.0 + if sample_rate != 16000: + output_length = max(1, round(len(audio) * 16000 / sample_rate)) + source_positions = np.arange(len(audio), dtype=np.float64) + target_positions = np.arange(output_length, dtype=np.float64) * (sample_rate / 16000) + audio = np.interp(target_positions, source_positions, audio).astype(np.float32) + return audio + + +def speech_ratio(path: Path, vad_model) -> float: + import torch + from silero_vad import get_speech_timestamps + + audio = read_resampled_audio(path) + timestamps = get_speech_timestamps( + torch.from_numpy(audio), + vad_model, + sampling_rate=16000, + threshold=0.5, + ) + speech_samples = sum(item["end"] - item["start"] for item in timestamps) + return speech_samples / max(1, len(audio)) + + +def acoustic_metrics(path: Path) -> dict[str, float]: + """Return inexpensive measurements that separate speech from static.""" + + import numpy as np + + audio = read_resampled_audio(path) + if not len(audio): + raise ValueError("empty audio") + centered = audio - float(np.mean(audio)) + peak = float(np.max(np.abs(centered))) + rms = float(np.sqrt(np.mean(np.square(centered)))) + clipped_ratio = float(np.mean(np.abs(audio) >= 0.999)) + zero_crossing_rate = float(np.mean(centered[:-1] * centered[1:] < 0)) if len(centered) > 1 else 1.0 + + frame_size = 512 + hop = 256 + spectra = [] + window = np.hanning(frame_size).astype(np.float32) + padded = np.pad(centered, (0, max(0, frame_size - len(centered)))) + for start in range(0, max(1, len(padded) - frame_size + 1), hop): + frame = padded[start : start + frame_size] + if len(frame) < frame_size: + frame = np.pad(frame, (0, frame_size - len(frame))) + if float(np.sqrt(np.mean(np.square(frame)))) < 0.001: + continue + spectra.append(np.square(np.abs(np.fft.rfft(frame * window)))) + if spectra: + power = np.mean(np.stack(spectra), axis=0) + 1e-12 + useful = power[3:] + spectral_flatness = float(np.exp(np.mean(np.log(useful))) / np.mean(useful)) + frequencies = np.fft.rfftfreq(frame_size, 1.0 / 16000.0) + high_frequency_ratio = float( + np.sum(power[frequencies >= 4000.0]) / max(1e-12, np.sum(power[frequencies >= 80.0])) + ) + else: + spectral_flatness = 1.0 + high_frequency_ratio = 1.0 + return { + "duration": len(audio) / 16000.0, + "rms": rms, + "peak": peak, + "clipped_ratio": clipped_ratio, + "dc_offset": abs(float(np.mean(audio))), + "spectral_flatness": spectral_flatness, + "high_frequency_ratio": high_frequency_ratio, + "zero_crossing_rate": zero_crossing_rate, + } + + +def acoustic_rejection_reason( + metrics: dict[str, float], + detected_speech_ratio: float, + profile: str, + minimum_duration: float, + maximum_duration: float, +) -> str: + limits = ACOUSTIC_LIMITS[profile] + if metrics["duration"] < minimum_duration: + return "too_short" + if metrics["duration"] > maximum_duration: + return "too_long_or_rambling" + if metrics["rms"] < 0.004: + return "too_quiet" + if metrics["rms"] > 0.55 or metrics["clipped_ratio"] > 0.01: + return "clipped_or_overdriven" + if metrics["dc_offset"] > 0.05: + return "dc_offset" + if metrics["spectral_flatness"] > limits["maximum_spectral_flatness"]: + return "static_or_broadband_noise" + if metrics["high_frequency_ratio"] > limits["maximum_high_frequency_ratio"]: + return "high_frequency_noise" + if metrics["zero_crossing_rate"] > limits["maximum_zero_crossing_rate"]: + return "noise_like_waveform" + vad_bypass = limits.get("vad_bypass_flatness", -1.0) + if detected_speech_ratio < limits["minimum_speech_ratio"] and metrics["spectral_flatness"] > vad_bypass: + return "no_speech_detected" + return "accepted" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--input-jsonl", type=Path, required=True) + parser.add_argument("--output-jsonl", type=Path, required=True) + parser.add_argument("--phrase", required=True) + parser.add_argument("--language", required=True) + parser.add_argument("--download-root", type=Path, required=True) + parser.add_argument( + "--speech-only", + action="store_true", + help="Use VAD only; intended for fast corpus-wide decoder-collapse filtering.", + ) + parser.add_argument( + "--profile", + choices=tuple(ACOUSTIC_LIMITS), + help="Apply strict provider-specific corpus safety limits.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + entries = [ + json.loads(line) + for line in args.input_jsonl.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + from silero_vad import load_silero_vad + + vad_model = load_silero_vad(onnx=True) + language = args.language.strip().lower().split("_", 1)[0] + try: + from faster_whisper.tokenizer import _LANGUAGE_CODES + + semantic_checked = not args.speech_only and language in set(_LANGUAGE_CODES) + except Exception: + semantic_checked = False + + whisper_model = None + if semantic_checked: + import ctranslate2 + from faster_whisper import WhisperModel + + device = "cuda" if int(ctranslate2.get_cuda_device_count()) > 0 else "cpu" + compute_type = "float16" if device == "cuda" else "int8" + model_name = "small.en" if language == "en" else "small" + args.download_root.mkdir(parents=True, exist_ok=True) + whisper_model = WhisperModel( + model_name, + device=device, + compute_type=compute_type, + download_root=str(args.download_root), + ) + + results = [] + for entry in entries: + path = Path(entry["path"]) + try: + detected_speech_ratio = speech_ratio(path, vad_model) + metrics = acoustic_metrics(path) + except Exception as error: + results.append( + { + "id": entry["id"], + "accepted": False, + "reason": f"speech_detection_failed: {error}", + "transcript": "", + "similarity": 0.0, + "speech_ratio": 0.0, + "semantic_checked": semantic_checked, + } + ) + continue + + acoustic_reason = "accepted" + if args.profile: + acoustic_reason = acoustic_rejection_reason( + metrics, + detected_speech_ratio, + args.profile, + float(entry.get("minimum_duration", 0.25)), + float(entry.get("maximum_duration", 5.0)), + ) + + transcript = "" + similarity = 0.0 + if acoustic_reason != "accepted": + accepted = False + reason = acoustic_reason + elif whisper_model is not None: + segments, _info = whisper_model.transcribe( + str(path), + language=language, + beam_size=1, + condition_on_previous_text=False, + ) + transcript = re.sub( + r"\s+", + " ", + " ".join(str(segment.text or "").strip() for segment in segments), + ).strip() + similarity = phrase_similarity(transcript, args.phrase) + accepted = transcript_matches_phrase(transcript, args.phrase) + reason = ( + "accepted" + if accepted + else semantic_rejection_reason(transcript, args.phrase, detected_speech_ratio) + ) + else: + accepted = True if args.profile else detected_speech_ratio >= MIN_SPEECH_RATIO + reason = "accepted" if accepted else "no_speech_detected" + + results.append( + { + "id": entry["id"], + "accepted": accepted, + "reason": reason, + "transcript": transcript, + "similarity": round(similarity, 4), + "speech_ratio": round(detected_speech_ratio, 4), + "acoustic_metrics": {key: round(value, 6) for key, value in metrics.items()}, + "semantic_checked": semantic_checked, + } + ) + + args.output_jsonl.parent.mkdir(parents=True, exist_ok=True) + with args.output_jsonl.open("w", encoding="utf-8") as stream: + for result in results: + stream.write(json.dumps(result, ensure_ascii=False) + "\n") + accepted_count = sum(bool(result["accepted"]) for result in results) + print(f"Reference QA accepted {accepted_count}/{len(results)} clip(s)", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/wake_word_sample_generator b/cli/wake_word_sample_generator index 6dd4be0..3de03ba 100755 --- a/cli/wake_word_sample_generator +++ b/cli/wake_word_sample_generator @@ -1,12 +1,13 @@ #!/bin/bash -set -e +set -euo pipefail -PROGPATH=$(realpath "$0") -PROGDIR=$(dirname "${PROGPATH}") +PROGPATH="$(realpath "$0")" +PROGDIR="$(dirname "${PROGPATH}")" -KNOWN_ARGS=( samples batch-size data-dir language ) +KNOWN_ARGS=( samples batch-size data-dir language tts-mode tts-voice-count ) +# shellcheck source=/dev/null source "${PROGDIR}/shell.functions" -WAKE_WORD="${POSITIONAL_ARGS[0]}" +WAKE_WORD="${POSITIONAL_ARGS[0]:-}" if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2 @@ -16,147 +17,48 @@ fi if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then cat <&2 Usage: $0 [ --samples= ] [ --batch-size= ] - [ --language= ] - ---samples: The number of samples to generate for the wake word. - Default: ${DEFAULT_SAMPLES} - ---batch-size: How many samples should be generated at a time. The more - samples, the more memory is needed. - Default: ${DEFAULT_BATCH_SIZE} - ---language: Language for TTS voice selection. - "en" uses the multi-speaker LibriTTS-R generator. - Other languages (e.g. "nl") use single-speaker ONNX - voices and cycle between them for variety. - Default: ${DEFAULT_LANGUAGE} - - The word to generate samples for. - Required. + [ --language= ] [ --tts-mode= ] + [ --tts-voice-count= ] +--samples: Number of samples to generate. Default: ${DEFAULT_SAMPLES} +--batch-size: Generation batch size. Default: ${DEFAULT_BATCH_SIZE} +--language: TTS language code. Default: ${DEFAULT_LANGUAGE} +--tts-mode: modern, hybrid, or piper. Default: ${DEFAULT_TTS_MODE} +--tts-voice-count: Deprecated compatibility option; direct generation ignores it. + Required phrase to synthesize. EOF exit 1 fi -# shellcheck source=/dev/null -source "${DATA_DIR}/.venv/bin/activate" +case "${TTS_MODE}" in + modern|hybrid|piper) ;; + *) + echo "ERROR: --tts-mode must be modern, hybrid, or piper." >&2 + exit 2 + ;; +esac WORK_DIR="${DATA_DIR}/work" -mkdir -p "${WORK_DIR}" || : -cd "${WORK_DIR}" - -PSG="${DATA_DIR}/tools/piper-sample-generator" -MODELS_DIR="${PSG}/models" -VOICES_DIR="${PSG}/voices" SAMPLES_DIR="${WORK_DIR}/wake_word_samples" - -mkdir -p "${SAMPLES_DIR}" || : - -# --------------------------------------------------------------------------- -# Build the --model argument(s) based on language -# --------------------------------------------------------------------------- -declare -a MODEL_ARGS=() -MODEL_TAG="" - -if [ "${LANGUAGE}" == "en" ] ; then - # English: use the multi-speaker LibriTTS-R generator (.pt) - MODEL_NAME="en_US-libritts_r-medium.pt" - MODEL_FILE="${MODELS_DIR}/${MODEL_NAME}" - if [ ! -f "${MODEL_FILE}" ] ; then - echo "ERROR: English model ${MODEL_FILE} not found. Run setup_python_venv first." >&2 - exit 1 - fi - MODEL_ARGS=( --model "${MODEL_FILE}" ) - MODEL_TAG="${MODEL_NAME}" -else - # Non-English: find all ONNX voices matching the language prefix - # e.g. LANGUAGE=nl matches nl_NL-pim-medium.onnx, nl_BE-nathalie-medium.onnx, etc. - shopt -s nullglob - voice_files=( "${VOICES_DIR}/${LANGUAGE}"_*.onnx ) - shopt -u nullglob - - if [ ${#voice_files[@]} -eq 0 ] ; then - echo "ERROR: No ONNX voice files found for language '${LANGUAGE}' in ${VOICES_DIR}/" >&2 - echo " Expected files matching: ${LANGUAGE}_*.onnx" >&2 - echo " Run setup_python_venv to download voice models." >&2 - exit 1 - fi - - echo " Using ${#voice_files[@]} voice(s) for language '${LANGUAGE}':" - MODEL_TAG="${LANGUAGE}" - for vf in "${voice_files[@]}" ; do - vname="$(basename "${vf}")" - echo " - ${vname}" - MODEL_ARGS+=( --model "${vf}" ) - MODEL_TAG="${MODEL_TAG}+${vname}" - done -fi - -REGENERATE=false - -if [ "${SAMPLES}" -eq 1 ] ; then - echo "===== Generating ${SAMPLES} sample of '${WAKE_WORD}' (language=${LANGUAGE}) =====" - wake_word_filename="${WAKE_WORD//[ \`~\!@#\$%^&*\(\)\{\}\[\]\|\;\'\"<>.?\/]/_}" - - mkdir -p "${WORK_DIR}/test_sample" || : - "${PSG}/generate_samples.py" "${WAKE_WORD}" \ - "${MODEL_ARGS[@]}" \ - --max-samples ${SAMPLES} \ - --batch-size ${BATCH_SIZE} \ - --output-dir "${WORK_DIR}/test_sample" \ - --max-speakers 100 2>&1 | sed -r -e "s/(DEBUG|INFO):__main__:/ /g" - mv "${WORK_DIR}/test_sample/0.wav" "${WORK_DIR}/test_sample/${wake_word_filename}.wav" - echo "Sample available at ${WORK_DIR}/test_sample/${wake_word_filename}.wav" - echo "Play it from your host." - exit 0 -fi - -grep -q "${WAKE_WORD}:${SAMPLES}:${MODEL_TAG}" "${WORK_DIR}/last_wake_word" &>/dev/null || REGENERATE=true - -# Double check that the number of existing samples matches SAMPLES -existing_samples=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l) -[ "${existing_samples}" -eq "${SAMPLES}" ] || REGENERATE=true +mkdir -p "${WORK_DIR}" START_TS=$EPOCHSECONDS +echo "===== Generating ${SAMPLES} wake-word samples (language=${LANGUAGE}, tts=${TTS_MODE}) =====" -if ! ${REGENERATE} ; then - echo "Sample generation not required" - echo - exit 0 -fi +python3 "${PROGDIR}/tts_generate_samples.py" "${WAKE_WORD}" \ + --samples="${SAMPLES}" \ + --batch-size="${BATCH_SIZE}" \ + --language="${LANGUAGE}" \ + --tts-mode="${TTS_MODE}" \ + --voice-count="${TTS_VOICE_COUNT}" \ + --data-dir="${DATA_DIR}" \ + --output-dir="${SAMPLES_DIR}" -echo -e "\n===== Generating ${SAMPLES} wake word samples in batches of ${BATCH_SIZE} (language=${LANGUAGE}) =====" -export TF_CPP_MIN_LOG_LEVEL=9 -export TF_FORCE_GPU_ALLOW_GROWTH=true -export TF_GPU_ALLOCATOR=cuda_malloc_async -export TF_XLA_FLAGS="--tf_xla_auto_jit=0" -export NVIDIA_TF32_OVERRIDE=1 -export TF_CUDNN_WORKSPACE_LIMIT_IN_MB=512 -export GLOG_minloglevel=2 -export GRPC_VERBOSITY=ERROR - -echo " Generating samples" -rm -rf "${SAMPLES_DIR}" || : -mkdir -p "${SAMPLES_DIR}" || : -python "${PROGDIR}/run_generator_with_progress.py" \ - --generator "${PSG}/generate_samples.py" \ - --output-dir "${SAMPLES_DIR}" \ - --max-samples ${SAMPLES} \ - -- \ - "${WAKE_WORD}" \ - "${MODEL_ARGS[@]}" \ - --max-samples ${SAMPLES} \ - --batch-size ${BATCH_SIZE} \ - --output-dir "${SAMPLES_DIR}" - -generated_files=$(find "${SAMPLES_DIR}" -name '*.wav' | wc -l) +generated_files=$(find "${SAMPLES_DIR}" -maxdepth 1 -name '*.wav' | wc -l) if [ "${generated_files}" -ne "${SAMPLES}" ] ; then - echo "ERROR: only generated ${generated_files} files" >&2 + echo "ERROR: only generated ${generated_files} of ${SAMPLES} files" >&2 exit 1 fi -echo "${WAKE_WORD}:${SAMPLES}:${MODEL_TAG}" > "${WORK_DIR}/last_wake_word" -echo + END_TS=$EPOCHSECONDS print_elapsed_time "${START_TS}" "${END_TS}" "Generated ${SAMPLES} wake word samples." - -exit 0 diff --git a/dockerfile b/dockerfile index c1821db..da48e19 100644 --- a/dockerfile +++ b/dockerfile @@ -6,7 +6,8 @@ ENV DEBIAN_FRONTEND=noninteractive # System deps RUN apt-get update && apt-get install -y --no-install-recommends \ python3.12 python3.12-venv python3.12-dev python3-pip python-is-python3 \ - git wget curl unzip patch ninja-build ca-certificates nano less libgomp1 \ + git wget curl unzip patch ninja-build build-essential cmake pkg-config \ + ca-certificates nano less libgomp1 ffmpeg sox libsox-fmt-all libsndfile1 espeak-ng \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /data @@ -27,14 +28,16 @@ COPY --chown=root:root --chmod=0755 \ requirements.txt \ /root/mww-scripts/ +COPY --chown=root:root --chmod=0644 tts_config.py /root/mww-scripts/tts_config.py + # CLI folder COPY --chown=root:root cli/ /root/mww-scripts/cli/ # Make all CLI scripts executable (avoids "Permission denied") RUN chmod -R a+x /root/mww-scripts/cli -# Static UI for trainer -COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html +# Prebuilt Vue/TypeScript UI (Node.js is not required at runtime) +COPY --chown=root:root static/ /root/mww-scripts/static/ # trainer server CMD ["/bin/bash", "-lc", "/root/mww-scripts/run.sh"] diff --git a/dockerfile.blackwell b/dockerfile.blackwell index 78dce55..2f593a1 100644 --- a/dockerfile.blackwell +++ b/dockerfile.blackwell @@ -13,7 +13,8 @@ ENV MWW_BLACKWELL_TF_WHEEL_URL=https://github.com/chivitiH/tensorflow-blackwell- # Python 3.13 is used only for the Blackwell TensorFlow training step. RUN apt-get update && apt-get install -y --no-install-recommends \ software-properties-common ca-certificates curl git wget unzip patch \ - ninja-build nano less libgomp1 \ + ninja-build build-essential cmake pkg-config nano less libgomp1 \ + ffmpeg sox libsox-fmt-all libsndfile1 espeak-ng \ && add-apt-repository -y ppa:deadsnakes/ppa \ && apt-get update \ && apt-get install -y --no-install-recommends \ @@ -41,14 +42,16 @@ COPY --chown=root:root --chmod=0755 \ requirements.txt \ /root/mww-scripts/ +COPY --chown=root:root --chmod=0644 tts_config.py /root/mww-scripts/tts_config.py + # CLI folder COPY --chown=root:root cli/ /root/mww-scripts/cli/ # Make all CLI scripts executable (avoids "Permission denied") RUN chmod -R a+x /root/mww-scripts/cli -# Static UI for trainer -COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html +# Prebuilt Vue/TypeScript UI (Node.js is not required at runtime) +COPY --chown=root:root static/ /root/mww-scripts/static/ # trainer server CMD ["/bin/bash", "-lc", "/root/mww-scripts/run.sh"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2fe733e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1199 @@ +{ + "name": "microwakeword-trainer-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "microwakeword-trainer-ui", + "version": "1.0.0", + "dependencies": { + "vue": "3.5.40" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.8", + "typescript": "5.9.3", + "vite": "8.2.0", + "vue-tsc": "3.3.9" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.9.tgz", + "integrity": "sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.9.tgz", + "integrity": "sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.9" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bf3d44c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "microwakeword-trainer-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "vue-tsc --noEmit && vite build", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "3.5.40" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.8", + "typescript": "5.9.3", + "vite": "8.2.0", + "vue-tsc": "3.3.9" + } +} diff --git a/frontend/src/TrainerApp.vue b/frontend/src/TrainerApp.vue new file mode 100644 index 0000000..2cff898 --- /dev/null +++ b/frontend/src/TrainerApp.vue @@ -0,0 +1,323 @@ + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..d25d096 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,43 @@ +export type JsonRecord = Record; + +export async function request(path: string, options: RequestInit = {}): Promise { + const response = await fetch(path, { + credentials: "same-origin", + ...options, + headers: { + Accept: "application/json", + ...(options.headers || {}), + }, + }); + const contentType = response.headers.get("content-type") || ""; + const body = contentType.includes("application/json") + ? await response.json() + : await response.text(); + if (!response.ok) { + const message = typeof body === "object" && body + ? body.error || body.detail || body.message + : body; + throw new Error(String(message || `Request failed (${response.status})`)); + } + return body as T; +} + +export function getJson(path: string): Promise { + return request(path); +} + +export function postJson(path: string, body: unknown = {}): Promise { + return request(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export function putJson(path: string, body: unknown): Promise { + return request(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} diff --git a/frontend/src/components/AudioTrimModal.vue b/frontend/src/components/AudioTrimModal.vue new file mode 100644 index 0000000..af84af7 --- /dev/null +++ b/frontend/src/components/AudioTrimModal.vue @@ -0,0 +1,235 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..9b987cd --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from "vue"; +import TrainerApp from "./TrainerApp.vue"; +import "./trainer.css"; + +const root = document.getElementById("trainer-app"); + +if (!root) { + throw new Error("Missing #trainer-app mount point"); +} + +createApp(TrainerApp).mount(root); diff --git a/frontend/src/trainer.css b/frontend/src/trainer.css new file mode 100644 index 0000000..6f9f8fb --- /dev/null +++ b/frontend/src/trainer.css @@ -0,0 +1,217 @@ +:root { + color-scheme: dark; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #f3f1ee; + background: #0d0d0e; + font-synthesis: none; + --bg: #0d0d0e; + --surface: rgba(29, 29, 31, .9); + --surface-solid: #1c1c1e; + --surface-2: rgba(43, 43, 46, .8); + --line: rgba(255, 255, 255, .1); + --line-strong: rgba(255, 255, 255, .18); + --text: #f3f1ee; + --muted: #aaa6a0; + --orange: #ff9134; + --orange-2: #ffb267; + --violet: #77736e; + --blue: #a8a5a1; + --green: #44dda5; + --red: #ff6c7d; + --yellow: #ffc561; + --shadow: 0 24px 70px rgba(0, 0, 0, .32); +} + +* { box-sizing: border-box; } +html { min-height: 100%; background: var(--bg); } +body { min-width: 320px; min-height: 100vh; margin: 0; background: radial-gradient(circle at 78% -10%, rgba(255, 145, 52, .08), transparent 34%), linear-gradient(145deg, #121213, #0d0d0e 60%, #151413); } +button, input, select { font: inherit; } +button, .button { + min-height: 42px; padding: 9px 16px; border: 1px solid var(--line-strong); border-radius: 12px; + color: var(--text); background: rgba(48, 48, 51, .86); font-weight: 700; cursor: pointer; + transition: border-color .18s ease, transform .18s ease, background .18s ease, box-shadow .18s ease; +} +button:hover:not(:disabled), .button:hover:not(:disabled) { transform: translateY(-1px); border-color: rgba(255, 145, 52, .55); background: rgba(62, 61, 61, .94); } +button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid rgba(255, 145, 52, .88); outline-offset: 2px; } +button:disabled { opacity: .43; cursor: not-allowed; } +.button.primary { color: #18100a; border-color: #ffad63; background: linear-gradient(135deg, var(--orange), #ffb45f); box-shadow: 0 10px 28px rgba(255, 126, 35, .19); } +.button.primary:hover:not(:disabled) { background: linear-gradient(135deg, #ffa04c, #ffc078); } +.button.danger { border-color: rgba(255, 108, 125, .54); color: #fff; background: rgba(255, 78, 101, .2); } +.button.ghost { background: transparent; } +.button.large { min-width: min(100%, 360px); min-height: 54px; font-size: 16px; } + +.app-shell { position: relative; width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 30px 0 80px; } +.ambient { position: fixed; z-index: -1; width: 380px; height: 380px; border-radius: 50%; filter: blur(95px); opacity: .16; pointer-events: none; } +.ambient-one { top: -160px; right: 4vw; background: var(--violet); } +.ambient-two { bottom: -180px; left: -70px; background: var(--orange); } +.app-header { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 24px; } +.brand { display: flex; align-items: center; gap: 16px; } +.brand-mark { position: relative; display: grid; place-items: center; overflow: hidden; flex: 0 0 auto; width: 58px; height: 58px; border: 1px solid rgba(255, 164, 82, .4); border-radius: 19px; background: radial-gradient(circle at 50% 36%, #383330, #191819 72%); box-shadow: inset 0 1px rgba(255,255,255,.12), 0 14px 36px rgba(0,0,0,.25); } +.brand-mark img { display: block; width: 56px; height: 56px; object-fit: contain; filter: drop-shadow(0 5px 9px rgba(0, 0, 0, .36)); } +.brand h1, .hero h2, .panel h3, .modal h2 { font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; } +.brand h1 { margin: 2px 0 1px; font-size: clamp(22px, 3vw, 31px); letter-spacing: -.035em; } +.brand p, .hero p, .panel p, .modal p { margin: 0; color: var(--muted); line-height: 1.55; } +.brand p { font-size: 13px; } +.eyebrow { color: var(--orange-2); font-size: 10px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; } +.header-status { display: flex; align-items: center; gap: 10px; } +.live-dot, .session-chip { display: inline-flex; align-items: center; min-height: 34px; padding: 7px 11px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: rgba(24, 24, 25, .78); font-size: 12px; font-weight: 700; } +.live-dot i { width: 7px; height: 7px; margin-right: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(68,221,165,.1); } + +.tabs { position: sticky; top: 12px; z-index: 20; display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; padding: 6px; margin-bottom: 18px; border: 1px solid var(--line); border-radius: 16px; background: rgba(20, 20, 21, .9); box-shadow: 0 14px 36px rgba(0,0,0,.2); backdrop-filter: blur(18px); } +.tabs button { position: relative; min-height: 42px; padding: 8px; border-color: transparent; color: var(--muted); background: transparent; font-size: 13px; } +.tabs button.active { color: #fff; border-color: rgba(255, 152, 65, .42); background: linear-gradient(135deg, rgba(255,145,52,.22), rgba(92,89,86,.22)); box-shadow: inset 0 1px rgba(255,255,255,.05); } +.tabs button b { display: inline-grid; place-items: center; min-width: 18px; height: 18px; margin-left: 7px; padding: 0 4px; border-radius: 99px; color: #23120b; background: var(--orange); font-size: 10px; } +.tab-short { display: none; } + +.main-content { display: grid; gap: 16px; } +.hero, .panel, .native-notice { border: 1px solid var(--line); border-radius: 22px; background: var(--surface); box-shadow: var(--shadow); backdrop-filter: blur(18px); } +.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 30px; min-height: 210px; padding: 34px; } +.hero::after { content: ""; position: absolute; right: -45px; bottom: -95px; width: 290px; height: 290px; border-radius: 50%; background: radial-gradient(circle, rgba(255,145,52,.22), transparent 67%); } +.auto-hero::after { background: radial-gradient(circle, rgba(255,145,52,.16), transparent 67%); } +.capture-hero::after { background: radial-gradient(circle, rgba(190,184,177,.12), transparent 67%); } +.firmware-hero::after { background: radial-gradient(circle, rgba(255,145,52,.13), transparent 67%); } +.hero > * { position: relative; z-index: 1; } +.hero h2 { max-width: 760px; margin: 8px 0; font-size: clamp(27px, 5vw, 48px); line-height: 1.02; letter-spacing: -.05em; } +.hero p { max-width: 720px; font-size: 15px; } +.hero-pill { flex: 0 0 auto; } +.step-row { display: grid; gap: 7px; min-width: 165px; } +.step-row span { display: flex; align-items: center; gap: 8px; color: #d5d1cc; font-size: 12px; font-weight: 700; } +.step-row b, .number { display: inline-grid; place-items: center; flex: 0 0 auto; width: 32px; height: 32px; border-radius: 11px; color: #26150b; background: linear-gradient(135deg, var(--orange), #ffc175); font-size: 12px; } + +.panel { padding: 26px; } +.panel-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 23px; } +.panel-head h3 { margin: 0 0 3px; font-size: 20px; letter-spacing: -.025em; } +.panel-head p { font-size: 13px; } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.phrase-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.field { display: grid; align-content: start; gap: 7px; } +.field > span { color: #ddd9d4; font-size: 12px; font-weight: 800; letter-spacing: .01em; } +.field.wide { grid-column: 1 / -1; } +.field input, .field select { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line-strong); border-radius: 12px; color: var(--text); background: rgba(15, 15, 16, .82); } +.field select { appearance: auto; } +.field input:disabled, .field select:disabled { opacity: 1; cursor: not-allowed; color: #aaa7a3; border-color: rgba(151, 147, 142, .22); background: rgba(70, 69, 68, .72); -webkit-text-fill-color: #aaa7a3; } +.field small, .dropzone small, .progress-card small, .stack > small { color: var(--muted); font-size: 11px; line-height: 1.45; } +.row { display: flex; align-items: center; gap: 9px; } +.row.space { justify-content: space-between; } +.form-actions { margin-top: 16px; } +.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.stats article { display: grid; gap: 5px; min-height: 105px; padding: 17px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18, 18, 19, .62); } +.stats span { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +.stats strong { align-self: end; font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; font-size: 30px; } +.stats .format-value { font-size: 15px; line-height: 1.35; } +.train-action { display: grid; place-items: center; padding: 29px 0 19px; } +.panel-footer { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding-top: 17px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; } + +.pill { display: inline-flex; align-items: center; width: fit-content; min-height: 29px; padding: 5px 10px; border: 1px solid var(--line-strong); border-radius: 999px; color: #d1cdc8; background: rgba(48, 47, 47, .74); font-size: 11px; font-weight: 800; white-space: nowrap; } +.pill.success { color: #8bf2cc; border-color: rgba(68,221,165,.35); background: rgba(36, 160, 118, .13); } +.pill.warning { color: #ffd58a; border-color: rgba(255,197,97,.36); background: rgba(214, 146, 36, .13); } +.pill.error { color: #ffabb5; border-color: rgba(255,108,125,.36); background: rgba(220, 68, 88, .13); } +.toggle-list { display: grid; gap: 9px; margin-bottom: 18px; } +.toggle-list.compact { margin: 15px 0 0; } +.toggle-list label { display: flex; align-items: flex-start; gap: 12px; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: rgba(18, 18, 19, .56); cursor: pointer; } +.toggle-list input { width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--orange); } +.toggle-list label > span { display: grid; gap: 3px; } +.toggle-list small { color: var(--muted); line-height: 1.45; } +.link-row { display: flex; align-items: center; gap: 10px; margin-top: 15px; } +.action-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; } +.audit, .transcript { padding: 13px; border: 1px solid rgba(255,145,52,.22); border-radius: 13px; color: #d2cec9; background: rgba(255, 145, 52, .055); font-size: 12px; line-height: 1.55; } +.action-panel .audit { margin-top: 15px; } + +.audio-list, .word-list { display: grid; gap: 12px; } +.audio-card { display: grid; gap: 13px; padding: 17px; border: 1px solid var(--line); border-radius: 17px; background: rgba(18, 18, 19, .64); } +.audio-card header, .audio-card footer { display: flex; justify-content: space-between; align-items: flex-start; gap: 15px; } +.audio-card header > div:first-child { display: grid; min-width: 0; gap: 3px; } +.audio-card header strong { overflow-wrap: anywhere; } +.audio-card small, .audio-card footer > span { color: var(--muted); font-size: 11px; line-height: 1.5; } +.audio-card audio { width: 100%; height: 42px; } +.audio-card footer { align-items: center; } +.audio-card footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; } +.audio-card footer button { min-height: 36px; padding: 6px 11px; font-size: 11px; } +.meta-row { display: flex; flex-wrap: wrap; gap: 6px; } +.meta-row span { padding: 4px 8px; border: 1px solid var(--line); border-radius: 99px; color: #bdb8b2; background: rgba(50,49,49,.68); font-size: 10px; } +.empty-state { display: grid; place-items: center; min-height: 130px; padding: 24px; border: 1px dashed var(--line-strong); border-radius: 15px; color: var(--muted); text-align: center; } +.toolbar { flex-wrap: wrap; margin-bottom: 14px; } +.segment-control { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: rgba(16,16,17,.68); } +.segment-control button { min-height: 34px; padding: 5px 9px; border-color: transparent; background: transparent; font-size: 11px; } +.segment-control button.active { border-color: rgba(255,145,52,.28); background: rgba(255,145,52,.14); } +.segment-control b { margin-left: 4px; color: var(--orange-2); } +.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; color: var(--muted); font-size: 12px; } +.dropzone { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 18px; min-height: 100px; margin-bottom: 14px; padding: 19px; border: 1px dashed rgba(255,145,52,.45); border-radius: 16px; background: rgba(255,145,52,.05); cursor: pointer; } +.dropzone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } +.dropzone span { display: grid; gap: 5px; } +.dropzone > b { padding: 8px 12px; border-radius: 10px; background: rgba(255,145,52,.15); color: var(--orange-2); font-size: 12px; white-space: nowrap; } +.progress-card { display: grid; gap: 9px; margin-top: 15px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: rgba(18,18,19,.64); } +.progress-card > div:first-child { display: flex; justify-content: space-between; gap: 10px; } +.progress-card span { color: var(--orange-2); font-size: 12px; } +.progress-track { overflow: hidden; height: 7px; border-radius: 99px; background: rgba(255,255,255,.07); } +.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--orange), var(--violet)); transition: width .2s ease; } +.native-notice { display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: var(--muted); font-size: 12px; } +.native-notice strong { color: var(--green); } +.word-list article { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18,18,19,.64); } +.word-list article > div { display: grid; min-width: 0; gap: 6px; } +.word-list a { overflow-wrap: anywhere; color: var(--orange-2); font-size: 11px; text-decoration: none; } + +.data-hero::after { background: radial-gradient(circle, rgba(176, 171, 164, .15), transparent 67%); } +.data-panel { padding-bottom: 18px; } +.data-list { display: grid; gap: 9px; } +.data-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 18px; padding: 15px 16px; border: 1px solid var(--line); border-radius: 15px; background: rgba(18, 18, 19, .64); } +.data-row.empty { background: rgba(18, 18, 19, .34); } +.data-copy { display: grid; min-width: 0; gap: 6px; } +.data-title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.data-title strong { font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; font-size: 15px; } +.data-title code { overflow-wrap: anywhere; padding: 3px 7px; border: 1px solid var(--line); border-radius: 7px; color: #aaa6a0; background: rgba(55, 54, 53, .55); font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; } +.data-copy small, .data-note, .data-usage span { color: var(--muted); font-size: 11px; line-height: 1.45; } +.data-note { color: #c7a57d; } +.data-usage { display: grid; min-width: 105px; gap: 4px; text-align: right; } +.data-usage strong { color: var(--orange-2); font-family: ui-rounded, "SF Pro Rounded", system-ui, sans-serif; font-size: 16px; } +.data-row.empty .data-usage strong { color: #8c8883; } +.data-row > button { min-width: 82px; } +.data-warning { margin-top: 14px !important; padding: 11px 13px; border: 1px solid rgba(255, 197, 97, .3); border-radius: 12px; color: #ffd58a !important; background: rgba(214, 146, 36, .09); font-size: 12px; } + +.loading-panel { display: flex; justify-content: center; align-items: center; gap: 12px; min-height: 400px; color: var(--muted); } +.spinner { width: 22px; height: 22px; border: 2px solid rgba(255,255,255,.14); border-top-color: var(--orange); border-radius: 50%; animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(5, 5, 6, .8); backdrop-filter: blur(12px); } +.modal { overflow: auto; width: min(680px, 100%); max-height: calc(100vh - 40px); padding: 23px; border: 1px solid var(--line-strong); border-radius: 21px; background: #1c1c1e; box-shadow: 0 36px 100px rgba(0,0,0,.55); } +.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 18px; } +.modal-head h2 { margin: 4px 0; font-size: 24px; } +.console-modal { width: min(980px, 100%); } +.console-actions { flex-wrap: wrap; justify-content: flex-end; } +.console-follow { min-height: 34px; padding: 6px 11px; border-color: rgba(255,145,52,.42); color: var(--orange-2); background: rgba(255,145,52,.12); font-size: 11px; } +.console-log { overflow: auto; display: block; min-height: 430px; max-height: calc(100vh - 190px); margin: 0; padding: 17px; border: 1px solid rgba(255,145,52,.18); border-radius: 14px; color: #cbc6c0; background: #0b0b0c; font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; } +.console-log span { display: block; min-height: 1.65em; } +.console-log .success { color: #73e4b9; }.console-log .error { color: #ff8290; }.console-log .warning { color: #ffd079; }.console-log .heading { color: var(--orange-2); font-weight: 700; } +.stack { display: grid; gap: 14px; } +.pairing-code { text-align: center; font: 700 28px/1 ui-rounded, "SF Pro Rounded", system-ui, sans-serif; letter-spacing: .14em; text-transform: uppercase; } +.link-success { display: grid; place-items: center; gap: 11px; padding: 30px; text-align: center; } +.link-success i { display: grid; place-items: center; width: 54px; height: 54px; border: 1px solid rgba(68,221,165,.4); border-radius: 50%; color: var(--green); background: rgba(68,221,165,.12); font-size: 25px; font-style: normal; } +.link-success span { color: var(--muted); font-size: 12px; } +.trim-modal { width: min(820px, 100%); } +.waveform { width: 100%; height: 210px; margin: 16px 0; border: 1px solid var(--line); border-radius: 14px; background: #0d0d0e; } +.range-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; margin-bottom: 13px; } +.range-grid label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; } +.range-grid input { width: 100%; accent-color: var(--orange); } +.modal-actions { justify-content: flex-end; margin-top: 14px; } +.muted { color: var(--muted); } +.toast { position: fixed; z-index: 200; right: 22px; bottom: 22px; max-width: min(420px, calc(100% - 44px)); padding: 13px 16px; border: 1px solid rgba(68,221,165,.38); border-radius: 13px; color: #eafff7; background: rgba(20, 72, 56, .95); box-shadow: 0 18px 45px rgba(0,0,0,.4); font-size: 13px; font-weight: 700; } +.toast.warning { border-color: rgba(255,197,97,.45); background: rgba(93, 65, 22, .97); }.toast.error { border-color: rgba(255,108,125,.45); background: rgba(94, 31, 43, .97); } +.toast-enter-active, .toast-leave-active { transition: opacity .2s ease, transform .2s ease; }.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); } + +@media (max-width: 920px) { .tab-full { display: none; }.tab-short { display: inline; } } + +@media (max-width: 780px) { + .app-shell { width: min(100% - 22px, 1180px); padding-top: 17px; } + .app-header { align-items: flex-start; }.header-status { display: none; } + .tabs { top: 7px; }.tab-full { display: none; }.tab-short { display: inline; } + .hero { align-items: flex-start; min-height: unset; padding: 24px; }.step-row { display: none; } + .panel { padding: 19px; }.panel-head { grid-template-columns: auto minmax(0, 1fr); }.panel-head > :last-child:not(:nth-child(2)) { grid-column: 1 / -1; } + .form-grid, .phrase-form, .stats, .action-grid, .range-grid { grid-template-columns: 1fr; }.field.wide { grid-column: auto; } + .audio-card header, .audio-card footer, .word-list article, .panel-footer { flex-direction: column; align-items: stretch; } + .audio-card footer > div { justify-content: flex-start; }.word-list article > button { width: 100%; } + .sample-head .segment-control { grid-column: 1 / -1; }.segment-control button { flex: 1; } + .data-row { grid-template-columns: 1fr auto; }.data-copy { grid-column: 1 / -1; }.data-usage { text-align: left; }.data-row > button { min-width: 96px; } + .modal-backdrop { padding: 8px; }.modal { max-height: calc(100vh - 16px); padding: 17px; }.modal-head { flex-direction: column; }.console-actions { justify-content: flex-start; }.console-log { min-height: 55vh; } +} + +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } } diff --git a/frontend/src/trainerStore.ts b/frontend/src/trainerStore.ts new file mode 100644 index 0000000..8a274d3 --- /dev/null +++ b/frontend/src/trainerStore.ts @@ -0,0 +1,580 @@ +import { computed, reactive } from "vue"; +import { getJson, postJson, putJson, request, type JsonRecord } from "./api"; +import type { + AudioItem, + AutoTrainForm, + AutoTrainPayload, + CapturedPayload, + LanguageOption, + ManagedDataItem, + ManagedDataPayload, + SampleBucket, + SamplesPayload, + SessionPayload, + ToastState, + TrainingState, + ViewName, + WakeWordItem, +} from "./types"; + +const emptyTraining = (): TrainingState => ({ running: false, exit_code: null, log_lines: [] }); +const emptySamples = (): SamplesPayload => ({ personal: [], negative: [], personal_count: 0, negative_count: 0 }); +const emptyCaptured = (): CapturedPayload => ({ items: [], captured_count: 0, personal_count: 0, negative_count: 0 }); +const emptyManagedData = (): ManagedDataPayload => ({ items: [], total_size_bytes: 0, total_file_count: 0 }); + +const defaultAutoForm = (): AutoTrainForm => ({ + enabled: false, + wake_phrase: "", + language: "en", + stt_engine: "faster_whisper", + minimum_transcript_chars: 2, + delete_confirmed_wakes: false, + promote_close_misses: false, + schedule_hours: 24, + minimum_new_negatives: 3, + advertised_base_url: "", + tater_url: "http://127.0.0.1:8501", + notify_satellites: true, +}); + +export const trainer = reactive({ + activeView: "trainer" as ViewName, + initialized: false, + busy: new Set(), + phrase: "", + language: "en", + ttsMode: "hybrid", + languages: [{ code: "en", label: "English (en)", engines: ["omnivoice"] }] as LanguageOption[], + session: {} as SessionPayload, + samples: emptySamples(), + captured: emptyCaptured(), + training: emptyTraining(), + auto: {} as AutoTrainPayload, + autoForm: defaultAutoForm(), + wakeWords: [] as WakeWordItem[], + managedData: emptyManagedData(), + selectedFiles: [] as File[], + sampleBucket: "personal" as SampleBucket, + samplePage: { personal: 0, negative: 0 }, + uploadProgress: 0, + uploadLabel: "No upload in progress", + uploadDetail: "Choose files and upload when you are ready.", + consoleOpen: false, + taterLinkOpen: false, + trimItem: null as AudioItem | null, + trimBucket: "personal" as SampleBucket, + toast: { message: "", tone: "success", serial: 0 } as ToastState, +}); + +let autoTimer = 0; +let trainingTimer = 0; + +export const personalCount = computed(() => Number(trainer.samples.personal_count ?? trainer.session.takes_received ?? 0)); +export const negativeCount = computed(() => Number(trainer.samples.negative_count ?? trainer.captured.negative_count ?? 0)); +export const currentLanguage = computed(() => + trainer.languages.find((item) => item.code === trainer.language) || trainer.languages[0], +); +export const ttsRoute = computed(() => { + const engines = currentLanguage.value?.engines?.length ? currentLanguage.value.engines : ["omnivoice"]; + const selected = trainer.ttsMode === "piper" + ? engines.filter((engine) => engine === "piper") + : trainer.ttsMode === "hybrid" + ? engines + : engines.filter((engine) => engine !== "piper"); + const labels: Record = { omnivoice: "OmniVoice", qwen3: "Qwen3", moss: "MOSS", piper: "Piper" }; + const quality = trainer.ttsMode === "piper" ? "Legacy" : titleCase(currentLanguage.value?.quality || "experimental"); + return `${selected.map((engine) => labels[engine] || engine).join(" + ") || "Unavailable"} · ${quality}`; +}); +export const hasConsole = computed(() => Boolean( + trainer.training.running || trainer.training.exit_code !== null || trainer.training.log_lines?.length, +)); +export const selectedSamples = computed(() => trainer.samples[trainer.sampleBucket] || []); +export const autoLinked = computed(() => Boolean(trainer.auto.trainer_link?.linked)); +export const sttEngines = computed(() => { + const rows = trainer.auto.stt_engines; + return Array.isArray(rows) && rows.length + ? rows + : [{ id: "faster_whisper", label: "Faster Whisper" }, { id: "parakeet_onnx", label: "Parakeet ONNX" }]; +}); + +function titleCase(value: unknown): string { + return String(value || "").replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +export function isBusy(name?: string): boolean { + return name ? trainer.busy.has(name) : trainer.busy.size > 0; +} + +function setBusy(name: string, active: boolean): void { + if (active) trainer.busy.add(name); + else trainer.busy.delete(name); +} + +export function notify(message: unknown, tone: ToastState["tone"] = "success"): void { + trainer.toast = { message: String(message || ""), tone, serial: trainer.toast.serial + 1 }; +} + +function reportError(error: unknown, fallback: string): void { + notify(error instanceof Error ? error.message : fallback, "error"); +} + +function applySession(payload: SessionPayload): void { + trainer.session = payload || {}; + if (Array.isArray(payload.available_languages) && payload.available_languages.length) { + trainer.languages = payload.available_languages; + } + if (payload.raw_phrase) trainer.phrase = payload.raw_phrase; + if (payload.language) trainer.language = payload.language; + if (payload.tts_mode) trainer.ttsMode = payload.tts_mode; + if (payload.training) trainer.training = payload.training; +} + +export async function refreshSession(): Promise { + const payload = await getJson("/api/session"); + applySession(payload); + return payload; +} + +export async function startSession(): Promise { + if (!trainer.phrase.trim()) { + notify("Enter a wake phrase first.", "warning"); + return; + } + setBusy("session", true); + try { + const payload = await postJson("/api/start_session", { + phrase: trainer.phrase.trim(), + language: trainer.language, + tts_mode: trainer.ttsMode, + }); + applySession(payload); + notify(`Session ${payload.safe_word || "started"} is ready.`); + } catch (error) { + reportError(error, "Session failed to start."); + } finally { + setBusy("session", false); + } +} + +export async function stopSession(): Promise { + const wasTraining = Boolean(trainer.training.running); + if (wasTraining && !window.confirm("Training is running. Stop training cleanly and end this session?")) { + return; + } + setBusy("session", true); + if (trainingTimer) { + window.clearInterval(trainingTimer); + trainingTimer = 0; + } + try { + const payload = await postJson("/api/stop_session"); + applySession(payload); + notify(wasTraining ? "Training stopped cleanly and the session ended." : "Session ended. You can edit the wake phrase now."); + } catch (error) { + if (wasTraining) beginTrainingPoll(); + reportError(error, "Session could not be stopped."); + } finally { + setBusy("session", false); + } +} + +export function previewPhrase(): void { + if (!trainer.phrase.trim() || !("speechSynthesis" in window)) return; + const utterance = new SpeechSynthesisUtterance(trainer.phrase.trim()); + utterance.lang = trainer.language; + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(utterance); +} + +export function ensureSupportedTtsMode(): void { + const engines = currentLanguage.value?.engines || []; + const modern = engines.some((engine) => engine !== "piper"); + const piper = engines.includes("piper"); + if (trainer.ttsMode === "modern" && !modern) trainer.ttsMode = "piper"; + if (trainer.ttsMode === "hybrid" && !(modern && piper)) trainer.ttsMode = modern ? "modern" : "piper"; + if (trainer.ttsMode === "piper" && !piper) trainer.ttsMode = "modern"; +} + +export async function refreshSamples(quiet = false): Promise { + if (!quiet) setBusy("samples", true); + try { + const payload = await getJson("/api/samples"); + trainer.samples = { ...emptySamples(), ...payload }; + for (const bucket of ["personal", "negative"] as const) { + const lastPage = Math.max(0, Math.ceil((trainer.samples[bucket]?.length || 0) / 50) - 1); + trainer.samplePage[bucket] = Math.min(trainer.samplePage[bucket], lastPage); + } + return payload; + } finally { + if (!quiet) setBusy("samples", false); + } +} + +export async function refreshCaptured(quiet = false): Promise { + if (!quiet) setBusy("captured", true); + try { + const payload = await getJson("/api/captured_audio"); + trainer.captured = { ...emptyCaptured(), ...payload }; + return payload; + } finally { + if (!quiet) setBusy("captured", false); + } +} + +export function selectFiles(event: Event): void { + const input = event.target as HTMLInputElement; + trainer.selectedFiles = Array.from(input.files || []); +} + +function uploadOne(file: File, index: number, total: number): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const data = new FormData(); + data.append("file", file, file.name); + xhr.open("POST", "/api/upload_personal_sample"); + xhr.responseType = "json"; + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return; + trainer.uploadProgress = Math.round(((index + event.loaded / event.total) / total) * 100); + trainer.uploadLabel = `Uploading ${file.name} (${index + 1}/${total})`; + trainer.uploadDetail = "Sending and normalizing the recording."; + }; + xhr.onload = () => { + const body = xhr.response || {}; + if (xhr.status >= 200 && xhr.status < 300) resolve(body); + else reject(new Error(body.error || `Upload failed for ${file.name}`)); + }; + xhr.onerror = () => reject(new Error(`Upload failed for ${file.name}`)); + xhr.send(data); + }); +} + +export async function uploadSelectedFiles(input?: HTMLInputElement | null): Promise { + if (!trainer.session.safe_word) { + notify("Start a trainer session before uploading samples.", "warning"); + return; + } + if (!trainer.selectedFiles.length) return; + setBusy("upload", true); + try { + const files = [...trainer.selectedFiles]; + for (let index = 0; index < files.length; index += 1) await uploadOne(files[index], index, files.length); + trainer.uploadProgress = 100; + trainer.uploadLabel = "Upload complete"; + trainer.uploadDetail = `${files.length} sample${files.length === 1 ? "" : "s"} saved in the required training format.`; + trainer.selectedFiles = []; + if (input) input.value = ""; + await Promise.all([refreshSession(), refreshSamples(true)]); + notify("Personal samples uploaded."); + } catch (error) { + trainer.uploadProgress = 0; + reportError(error, "Sample upload failed."); + } finally { + setBusy("upload", false); + } +} + +export async function reviewCaptured(item: AudioItem, action: "approve_personal" | "mark_negative" | "discard"): Promise { + if (action === "discard" && !window.confirm(`Discard ${item.saved_as} from the captured-audio inbox?`)) return; + setBusy("review", true); + try { + await postJson(`/api/captured_audio/${encodeURIComponent(item.saved_as)}/${action}`); + await Promise.all([refreshSession(), refreshCaptured(true), refreshSamples(true)]); + notify(action === "approve_personal" ? "Clip added to personal samples." : action === "mark_negative" ? "Clip marked negative." : "Clip discarded."); + } catch (error) { + reportError(error, "Review action failed."); + } finally { + setBusy("review", false); + } +} + +export async function removeSample(item: AudioItem, bucket: SampleBucket): Promise { + if (!window.confirm(`Remove ${item.saved_as} from ${bucket} samples?`)) return; + setBusy("review", true); + try { + await request(`/api/samples/${bucket}/${encodeURIComponent(item.saved_as)}`, { method: "DELETE" }); + await refreshSamples(true); + notify("Sample removed."); + } catch (error) { + reportError(error, "Sample removal failed."); + } finally { + setBusy("review", false); + } +} + +export async function revertSample(item: AudioItem, bucket: SampleBucket): Promise { + if (!window.confirm(`Revert ${item.saved_as} to its pre-trim version?`)) return; + const form = new FormData(); + form.append("bucket", bucket); + form.append("file_name", item.saved_as); + setBusy("review", true); + try { + await request("/api/samples/revert", { method: "POST", body: form }); + await refreshSamples(true); + notify("Original sample restored."); + } catch (error) { + reportError(error, "Sample revert failed."); + } finally { + setBusy("review", false); + } +} + +export async function clearSamples(bucket: SampleBucket): Promise { + const count = bucket === "personal" ? personalCount.value : negativeCount.value; + if (!count || !window.confirm(`Clear ${count} ${bucket} sample${count === 1 ? "" : "s"}?`)) return; + setBusy("review", true); + try { + await postJson(bucket === "personal" ? "/api/reset_recordings" : "/api/reset_negative_samples"); + await Promise.all([refreshSession(), refreshSamples(true), refreshCaptured(true)]); + notify(`${titleCase(bucket)} samples cleared.`); + } catch (error) { + reportError(error, "Samples could not be cleared."); + } finally { + setBusy("review", false); + } +} + +function applyAuto(payload: AutoTrainPayload, populate: boolean): void { + trainer.auto = payload || {}; + if (!populate) return; + trainer.autoForm = { ...defaultAutoForm(), ...(payload.config || {}) }; + if (!trainer.autoForm.wake_phrase) trainer.autoForm.wake_phrase = trainer.session.raw_phrase || ""; + if (!trainer.autoForm.language) trainer.autoForm.language = trainer.session.language || "en"; +} + +export async function refreshAuto(populate = false): Promise { + const payload = await getJson("/api/auto_train"); + applyAuto(payload, populate); + return payload; +} + +export async function saveAuto(): Promise { + setBusy("auto", true); + try { + const payload = await putJson("/api/auto_train", trainer.autoForm); + applyAuto(payload, true); + notify(payload.config?.enabled ? "Auto Training saved and enabled." : "Auto Training saved."); + } catch (error) { + reportError(error, "Auto Training settings failed to save."); + } finally { + setBusy("auto", false); + } +} + +export async function runAutoAction(action: "review_now" | "train_now" | "notify_now"): Promise { + setBusy("auto", true); + try { + const payload = await postJson("/api/auto_train/action", { action }); + applyAuto(payload, false); + if (action === "train_now") { + trainer.consoleOpen = true; + beginTrainingPoll(); + } + notify(action === "review_now" ? `${Number(payload.queued || 0)} clips queued for review.` : action === "train_now" ? "Training started." : "Wake word published."); + } catch (error) { + reportError(error, "Auto Training action failed."); + } finally { + setBusy("auto", false); + } +} + +export async function claimTater(taterUrl: string, pairingCode: string): Promise { + setBusy("link", true); + try { + await postJson("/api/tater_link/claim", { tater_url: taterUrl.trim(), pairing_code: pairingCode.trim() }); + trainer.autoForm.tater_url = taterUrl.trim(); + await refreshAuto(false); + notify("Trainer linked securely to Tater."); + return true; + } catch (error) { + reportError(error, "Tater link failed."); + return false; + } finally { + setBusy("link", false); + } +} + +export async function unlinkTater(): Promise { + if (!window.confirm("Unlink this trainer from Tater?")) return; + setBusy("auto", true); + try { + await postJson("/api/tater_link/unlink"); + await refreshAuto(false); + notify("Trainer unlinked from Tater.", "warning"); + } catch (error) { + reportError(error, "Tater unlink failed."); + } finally { + setBusy("auto", false); + } +} + +export async function refreshWakeWords(quiet = false): Promise { + if (!quiet) setBusy("firmware", true); + try { + const payload = await getJson("/api/trained_wake_words/catalog"); + trainer.wakeWords = Array.isArray(payload.wake_words) ? payload.wake_words : []; + } finally { + if (!quiet) setBusy("firmware", false); + } +} + +export async function refreshManagedData(): Promise { + setBusy("data", true); + try { + const payload = await getJson("/api/data"); + trainer.managedData = { ...emptyManagedData(), ...payload }; + return payload; + } finally { + setBusy("data", false); + } +} + +export async function deleteManagedData(item: ManagedDataItem): Promise { + if (!item.file_count) return; + const details = `${formatBytes(item.size_bytes)} · ${Number(item.file_count).toLocaleString()} file${item.file_count === 1 ? "" : "s"}`; + const rebuild = item.rebuild_note ? `\n\n${item.rebuild_note}` : ""; + if (!window.confirm(`Permanently delete ${item.label} (${details})?${rebuild}\n\nThis cannot be undone.`)) return; + setBusy("data-delete", true); + try { + const payload = await request(`/api/data/${encodeURIComponent(item.id)}`, { method: "DELETE" }); + trainer.managedData = { ...emptyManagedData(), ...payload }; + await Promise.allSettled([ + refreshSession(), + refreshSamples(true), + refreshCaptured(true), + refreshWakeWords(true), + ]); + notify(`${item.label} deleted. ${formatBytes(item.size_bytes)} released.`); + } catch (error) { + reportError(error, `${item.label} could not be deleted.`); + } finally { + setBusy("data-delete", false); + } +} + +export async function copyWakeWord(url: string): Promise { + try { + await navigator.clipboard.writeText(url); + notify("Wake-word JSON URL copied."); + } catch (error) { + reportError(error, "Clipboard unavailable."); + } +} + +export async function startTraining(): Promise { + await Promise.all([refreshSession(), refreshSamples(true)]); + let allowNoPersonal = false; + if (!personalCount.value) { + allowNoPersonal = window.confirm("No positive samples are saved. Train anyway without personal voices?"); + if (!allowNoPersonal) return; + } + setBusy("training-start", true); + trainer.training = { running: true, exit_code: null, log_lines: ["Waiting for training output…"] }; + trainer.consoleOpen = true; + try { + await postJson("/api/train", { allow_no_personal: allowNoPersonal }); + beginTrainingPoll(); + } catch (error) { + trainer.training = { running: false, exit_code: 1, log_lines: [error instanceof Error ? error.message : String(error)] }; + reportError(error, "Training could not start."); + } finally { + setBusy("training-start", false); + } +} + +export function beginTrainingPoll(): void { + if (trainingTimer) return; + const poll = async () => { + try { + const payload = await getJson("/api/train_status"); + trainer.training = { ...emptyTraining(), ...(payload.training || {}) }; + if (!trainer.training.running) { + window.clearInterval(trainingTimer); + trainingTimer = 0; + await Promise.all([refreshSamples(true), refreshWakeWords(true)]); + notify(trainer.training.exit_code === 0 ? "Training finished successfully." : `Training ended with exit ${trainer.training.exit_code}.`, trainer.training.exit_code === 0 ? "success" : "error"); + } + } catch { + // A temporary request failure should not stop the live poll. + } + }; + void poll(); + trainingTimer = window.setInterval(() => void poll(), 1500); +} + +export async function initializeTrainer(): Promise { + setBusy("bootstrap", true); + try { + await Promise.allSettled([ + refreshSession(), + refreshSamples(true), + refreshCaptured(true), + refreshAuto(true), + refreshWakeWords(true), + ]); + ensureSupportedTtsMode(); + try { + const payload = await getJson("/api/train_status"); + trainer.training = { ...emptyTraining(), ...(payload.training || {}) }; + if (trainer.training.running) { + trainer.consoleOpen = true; + beginTrainingPoll(); + } + } catch { + // Remaining panels can still function when status is temporarily unavailable. + } + autoTimer = window.setInterval(() => { + if (trainer.activeView === "auto" && !isBusy("auto")) void refreshAuto(false).catch(() => undefined); + }, 2500); + trainer.initialized = true; + } finally { + setBusy("bootstrap", false); + } +} + +export function disposeTrainer(): void { + window.clearInterval(autoTimer); + window.clearInterval(trainingTimer); + autoTimer = 0; + trainingTimer = 0; +} + +export function formatTimestamp(value: unknown): string { + if (!value) return ""; + const parsed = new Date(String(value)); + return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString(); +} + +export function formatBytes(value: unknown): string { + const bytes = Math.max(0, Number(value) || 0); + if (bytes < 1024) return `${Math.round(bytes)} B`; + const units = ["KB", "MB", "GB", "TB"]; + let amount = bytes / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && amount >= 1024; index += 1) { + amount /= 1024; + unit = units[index]; + } + return `${amount >= 10 ? amount.toFixed(1) : amount.toFixed(2)} ${unit}`; +} + +export function describeFormat(info: JsonRecord | undefined): string { + if (!info) return "16 kHz · mono · 16-bit WAV"; + const rate = Number(info.sample_rate || info.sample_rate_hz || 16000); + const channels = Number(info.channels || 1) === 1 ? "mono" : `${info.channels} channels`; + const bits = Number(info.bits_per_sample || info.sample_width_bits || 16); + return `${Math.round(rate / 1000)} kHz · ${channels} · ${bits}-bit`; +} + +export function captureTone(item: AudioItem): { label: string; tone: string } { + if (item.blocked_by_vad) return { label: "Blocked by VAD", tone: "warning" }; + const type = String(item.event_type || "").toLowerCase(); + if (type.includes("close")) return { label: item.capture_label || "Close miss", tone: "warning" }; + if (type.includes("false")) return { label: item.capture_label || "False trigger", tone: "error" }; + if (type.includes("wake") || type.includes("detect")) return { label: item.capture_label || "Wake trigger", tone: "success" }; + return { label: item.capture_label || "Captured", tone: "neutral" }; +} + +export function itemAudioUrl(item: AudioItem, bucket: SampleBucket | "captured"): string { + return item.audio_url || `/api/audio/${bucket}/${encodeURIComponent(item.saved_as)}`; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..476afce --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,105 @@ +import type { JsonRecord } from "./api"; + +export type ViewName = "trainer" | "auto" | "firmware" | "captured" | "samples" | "data"; +export type SampleBucket = "personal" | "negative"; + +export interface LanguageOption extends JsonRecord { + code: string; + label: string; + engines?: string[]; + quality?: string; +} + +export interface TrainingState extends JsonRecord { + running: boolean; + exit_code: number | null; + log_lines: string[]; +} + +export interface SessionPayload extends JsonRecord { + safe_word?: string; + raw_phrase?: string; + language?: string; + tts_mode?: string; + takes_received?: number; + available_languages?: LanguageOption[]; + training?: TrainingState; +} + +export interface AudioItem extends JsonRecord { + saved_as: string; + original_name?: string; + audio_url?: string; + final_format?: JsonRecord; +} + +export interface SamplesPayload extends JsonRecord { + personal: AudioItem[]; + negative: AudioItem[]; + personal_count: number; + negative_count: number; +} + +export interface CapturedPayload extends JsonRecord { + items: AudioItem[]; + captured_count: number; + personal_count: number; + negative_count: number; +} + +export interface AutoTrainForm extends JsonRecord { + enabled: boolean; + wake_phrase: string; + language: string; + stt_engine: string; + minimum_transcript_chars: number; + delete_confirmed_wakes: boolean; + promote_close_misses: boolean; + schedule_hours: number; + minimum_new_negatives: number; + advertised_base_url: string; + tater_url: string; + notify_satellites: boolean; +} + +export interface AutoTrainPayload extends JsonRecord { + config?: Partial; + state?: JsonRecord; + runtime?: JsonRecord; + trainer_link?: JsonRecord; + advertised_base_url?: string; +} + +export interface WakeWordItem extends JsonRecord { + key?: string; + label?: string; + url?: string; + json_url?: string; + jsonUrl?: string; + model_url?: string; + modelUrl?: string; +} + +export interface ManagedDataItem extends JsonRecord { + id: string; + label: string; + category: string; + description: string; + location: string; + size_bytes: number; + file_count: number; + exists: boolean; + rebuild_note?: string; +} + +export interface ManagedDataPayload extends JsonRecord { + items: ManagedDataItem[]; + total_size_bytes: number; + total_file_count: number; +} + +export interface ToastState { + message: string; + tone: "success" | "warning" | "error"; + serial: number; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..fe128ae --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..7140174 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { resolve } from "node:path"; + +export default defineConfig({ + plugins: [vue()], + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + build: { + outDir: resolve(import.meta.dirname, "../static/ui"), + emptyOutDir: true, + lib: { + entry: resolve(import.meta.dirname, "src/main.ts"), + formats: ["es"], + fileName: () => "trainer-ui.js", + }, + cssCodeSplit: false, + rollupOptions: { + output: { + assetFileNames: (assetInfo) => + assetInfo.name?.endsWith(".css") ? "trainer-ui.css" : "[name][extname]", + }, + }, + }, +}); diff --git a/static/images/tater-wake-word-trainer.png b/static/images/tater-wake-word-trainer.png new file mode 100644 index 0000000..402beab Binary files /dev/null and b/static/images/tater-wake-word-trainer.png differ diff --git a/static/index.html b/static/index.html index 8bff3d4..f869ece 100644 --- a/static/index.html +++ b/static/index.html @@ -1,3410 +1,16 @@ - - - - - microWakeWord Personal Samples - - - - -
-
-
- -
-

microWakeWord Trainer Studio

-

Train wake words, review captured clips, and publish live model URLs for Tater Native satellites.

-
-
-
- -
- - - - - -
- -
-
-
-
-
Training Studio
-

Build a Personal Wake Word

-

Start with a phrase, review your positive and negative sample counts, then launch the training pipeline with a live console so every step is visible.

-
- 1 Phrase + voice - 2 Review sample counts - 3 Train model -
-
-
-
- -
-
-
- 1 -
-

Phrase + Voice

-

Name the wake phrase and choose the language/voice set used to generate training audio.

-
-
- No session -
- -
- - -
- - -
-
-
- -
-
-
- 2 -
-

Train Wake Word

-

Device-captured positives and reviewed negatives are used when present. Manual samples are managed from the Samples tab.

-
-
- Not started -
-
-
- Positive Samples - 0 -
-
- Negative Samples - 0 -
-
- Training Format - 16 kHz / mono / 16-bit WAV -
-
-
- -
-
- The training console opens automatically when a run starts. - -
-
-
- - - - - - - - -
- - - - - - - - - + + + + + + + Wake Word Studio + + + +
+ + + diff --git a/static/ui/trainer-ui.css b/static/ui/trainer-ui.css new file mode 100644 index 0000000..8858440 --- /dev/null +++ b/static/ui/trainer-ui.css @@ -0,0 +1,2 @@ +:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;color:#f3f1ee;font-synthesis:none;--bg:#0d0d0e;--surface:#1d1d1fe6;--surface-solid:#1c1c1e;--surface-2:#2b2b2ecc;--line:#ffffff1a;--line-strong:#ffffff2e;--text:#f3f1ee;--muted:#aaa6a0;--orange:#ff9134;--orange-2:#ffb267;--violet:#77736e;--blue:#a8a5a1;--green:#44dda5;--red:#ff6c7d;--yellow:#ffc561;--shadow:0 24px 70px #00000052;background:#0d0d0e;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{background:var(--bg);min-height:100%}body{background:radial-gradient(circle at 78% -10%,#ff913414,#0000 34%),linear-gradient(145deg,#121213,#0d0d0e 60%,#151413);min-width:320px;min-height:100vh;margin:0}button,input,select{font:inherit}button,.button{border:1px solid var(--line-strong);min-height:42px;color:var(--text);cursor:pointer;background:#303033db;border-radius:12px;padding:9px 16px;font-weight:700;transition:border-color .18s,transform .18s,background .18s,box-shadow .18s}button:hover:not(:disabled),.button:hover:not(:disabled){background:#3e3d3df0;border-color:#ff91348c;transform:translateY(-1px)}button:focus-visible,input:focus-visible,select:focus-visible{outline-offset:2px;outline:2px solid #ff9134e0}button:disabled{opacity:.43;cursor:not-allowed}.button.primary{color:#18100a;background:linear-gradient(135deg, var(--orange), #ffb45f);border-color:#ffad63;box-shadow:0 10px 28px #ff7e2330}.button.primary:hover:not(:disabled){background:linear-gradient(135deg,#ffa04c,#ffc078)}.button.danger{color:#fff;background:#ff4e6533;border-color:#ff6c7d8a}.button.ghost{background:0 0}.button.large{min-width:min(100%,360px);min-height:54px;font-size:16px}.app-shell{width:min(1180px,100% - 36px);margin:0 auto;padding:30px 0 80px;position:relative}.ambient{z-index:-1;filter:blur(95px);opacity:.16;pointer-events:none;border-radius:50%;width:380px;height:380px;position:fixed}.ambient-one{background:var(--violet);top:-160px;right:4vw}.ambient-two{background:var(--orange);bottom:-180px;left:-70px}.app-header{justify-content:space-between;align-items:center;gap:24px;margin-bottom:24px;display:flex}.brand{align-items:center;gap:16px;display:flex}.brand-mark{background:radial-gradient(circle at 50% 36%,#383330,#191819 72%);border:1px solid #ffa45266;border-radius:19px;flex:none;place-items:center;width:58px;height:58px;display:grid;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff1f,0 14px 36px #00000040}.brand-mark img{object-fit:contain;filter:drop-shadow(0 5px 9px #0000005c);width:56px;height:56px;display:block}.brand h1,.hero h2,.panel h3,.modal h2{font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif}.brand h1{letter-spacing:-.035em;margin:2px 0 1px;font-size:clamp(22px,3vw,31px)}.brand p,.hero p,.panel p,.modal p{color:var(--muted);margin:0;line-height:1.55}.brand p{font-size:13px}.eyebrow{color:var(--orange-2);letter-spacing:.18em;text-transform:uppercase;font-size:10px;font-weight:800}.header-status{align-items:center;gap:10px;display:flex}.live-dot,.session-chip{border:1px solid var(--line);min-height:34px;color:var(--muted);background:#181819c7;border-radius:999px;align-items:center;padding:7px 11px;font-size:12px;font-weight:700;display:inline-flex}.live-dot i{background:var(--green);border-radius:50%;width:7px;height:7px;margin-right:7px;box-shadow:0 0 0 4px #44dda51a}.tabs{z-index:20;border:1px solid var(--line);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#141415e6;border-radius:16px;grid-template-columns:repeat(6,1fr);gap:5px;margin-bottom:18px;padding:6px;display:grid;position:sticky;top:12px;box-shadow:0 14px 36px #0003}.tabs button{min-height:42px;color:var(--muted);background:0 0;border-color:#0000;padding:8px;font-size:13px;position:relative}.tabs button.active{color:#fff;background:linear-gradient(135deg,#ff913438,#5c595638);border-color:#ff98416b;box-shadow:inset 0 1px #ffffff0d}.tabs button b{color:#23120b;background:var(--orange);border-radius:99px;place-items:center;min-width:18px;height:18px;margin-left:7px;padding:0 4px;font-size:10px;display:inline-grid}.tab-short{display:none}.main-content{gap:16px;display:grid}.hero,.panel,.native-notice{border:1px solid var(--line);background:var(--surface);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);border-radius:22px}.hero{justify-content:space-between;align-items:flex-end;gap:30px;min-height:210px;padding:34px;display:flex;position:relative;overflow:hidden}.hero:after{content:"";background:radial-gradient(circle,#ff913438,#0000 67%);border-radius:50%;width:290px;height:290px;position:absolute;bottom:-95px;right:-45px}.auto-hero:after{background:radial-gradient(circle,#ff913429,#0000 67%)}.capture-hero:after{background:radial-gradient(circle,#beb8b11f,#0000 67%)}.firmware-hero:after{background:radial-gradient(circle,#ff913421,#0000 67%)}.hero>*{z-index:1;position:relative}.hero h2{letter-spacing:-.05em;max-width:760px;margin:8px 0;font-size:clamp(27px,5vw,48px);line-height:1.02}.hero p{max-width:720px;font-size:15px}.hero-pill{flex:none}.step-row{gap:7px;min-width:165px;display:grid}.step-row span{color:#d5d1cc;align-items:center;gap:8px;font-size:12px;font-weight:700;display:flex}.step-row b,.number{color:#26150b;background:linear-gradient(135deg, var(--orange), #ffc175);border-radius:11px;flex:none;place-items:center;width:32px;height:32px;font-size:12px;display:inline-grid}.panel{padding:26px}.panel-head{grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;margin-bottom:23px;display:grid}.panel-head h3{letter-spacing:-.025em;margin:0 0 3px;font-size:20px}.panel-head p{font-size:13px}.form-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.phrase-form{grid-template-columns:repeat(2,minmax(0,1fr))}.field{align-content:start;gap:7px;display:grid}.field>span{color:#ddd9d4;letter-spacing:.01em;font-size:12px;font-weight:800}.field.wide{grid-column:1/-1}.field input,.field select{border:1px solid var(--line-strong);width:100%;min-height:46px;color:var(--text);background:#0f0f10d1;border-radius:12px;padding:10px 13px}.field select{appearance:auto}.field input:disabled,.field select:disabled{opacity:1;cursor:not-allowed;color:#aaa7a3;-webkit-text-fill-color:#aaa7a3;background:#464544b8;border-color:#97938e38}.field small,.dropzone small,.progress-card small,.stack>small{color:var(--muted);font-size:11px;line-height:1.45}.row{align-items:center;gap:9px;display:flex}.row.space{justify-content:space-between}.form-actions{margin-top:16px}.stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;display:grid}.stats article{border:1px solid var(--line);background:#1212139e;border-radius:15px;gap:5px;min-height:105px;padding:17px;display:grid}.stats span{color:var(--muted);text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:700}.stats strong{align-self:end;font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:30px}.stats .format-value{font-size:15px;line-height:1.35}.train-action{place-items:center;padding:29px 0 19px;display:grid}.panel-footer{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:14px;padding-top:17px;font-size:12px;display:flex}.pill{border:1px solid var(--line-strong);color:#d1cdc8;white-space:nowrap;background:#302f2fbd;border-radius:999px;align-items:center;width:fit-content;min-height:29px;padding:5px 10px;font-size:11px;font-weight:800;display:inline-flex}.pill.success{color:#8bf2cc;background:#24a07621;border-color:#44dda559}.pill.warning{color:#ffd58a;background:#d6922421;border-color:#ffc5615c}.pill.error{color:#ffabb5;background:#dc445821;border-color:#ff6c7d5c}.toggle-list{gap:9px;margin-bottom:18px;display:grid}.toggle-list.compact{margin:15px 0 0}.toggle-list label{border:1px solid var(--line);cursor:pointer;background:#1212138f;border-radius:14px;align-items:flex-start;gap:12px;padding:13px;display:flex}.toggle-list input{width:18px;height:18px;accent-color:var(--orange);margin:2px 0 0}.toggle-list label>span{gap:3px;display:grid}.toggle-list small{color:var(--muted);line-height:1.45}.link-row{align-items:center;gap:10px;margin-top:15px;display:flex}.action-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;display:grid}.audit,.transcript{color:#d2cec9;background:#ff91340e;border:1px solid #ff913438;border-radius:13px;padding:13px;font-size:12px;line-height:1.55}.action-panel .audit{margin-top:15px}.audio-list,.word-list{gap:12px;display:grid}.audio-card{border:1px solid var(--line);background:#121213a3;border-radius:17px;gap:13px;padding:17px;display:grid}.audio-card header,.audio-card footer{justify-content:space-between;align-items:flex-start;gap:15px;display:flex}.audio-card header>div:first-child{gap:3px;min-width:0;display:grid}.audio-card header strong{overflow-wrap:anywhere}.audio-card small,.audio-card footer>span{color:var(--muted);font-size:11px;line-height:1.5}.audio-card audio{width:100%;height:42px}.audio-card footer{align-items:center}.audio-card footer>div{flex-wrap:wrap;justify-content:flex-end;gap:7px;display:flex}.audio-card footer button{min-height:36px;padding:6px 11px;font-size:11px}.meta-row{flex-wrap:wrap;gap:6px;display:flex}.meta-row span{border:1px solid var(--line);color:#bdb8b2;background:#323131ad;border-radius:99px;padding:4px 8px;font-size:10px}.empty-state{border:1px dashed var(--line-strong);min-height:130px;color:var(--muted);text-align:center;border-radius:15px;place-items:center;padding:24px;display:grid}.toolbar{flex-wrap:wrap;margin-bottom:14px}.segment-control{border:1px solid var(--line);background:#101011ad;border-radius:12px;gap:4px;padding:4px;display:flex}.segment-control button{background:0 0;border-color:#0000;min-height:34px;padding:5px 9px;font-size:11px}.segment-control button.active{background:#ff913424;border-color:#ff913447}.segment-control b{color:var(--orange-2);margin-left:4px}.pagination{color:var(--muted);justify-content:center;align-items:center;gap:12px;margin-top:16px;font-size:12px;display:flex}.dropzone{cursor:pointer;background:#ff91340d;border:1px dashed #ff913473;border-radius:16px;justify-content:space-between;align-items:center;gap:18px;min-height:100px;margin-bottom:14px;padding:19px;display:flex;position:relative}.dropzone input{opacity:0;cursor:pointer;position:absolute;inset:0}.dropzone span{gap:5px;display:grid}.dropzone>b{color:var(--orange-2);white-space:nowrap;background:#ff913426;border-radius:10px;padding:8px 12px;font-size:12px}.progress-card{border:1px solid var(--line);background:#121213a3;border-radius:14px;gap:9px;margin-top:15px;padding:14px;display:grid}.progress-card>div:first-child{justify-content:space-between;gap:10px;display:flex}.progress-card span{color:var(--orange-2);font-size:12px}.progress-track{background:#ffffff12;border-radius:99px;height:7px;overflow:hidden}.progress-track i{border-radius:inherit;background:linear-gradient(90deg, var(--orange), var(--violet));height:100%;transition:width .2s;display:block}.native-notice{color:var(--muted);align-items:center;gap:12px;padding:14px 18px;font-size:12px;display:flex}.native-notice strong{color:var(--green)}.word-list article{border:1px solid var(--line);background:#121213a3;border-radius:15px;justify-content:space-between;align-items:center;gap:20px;padding:16px;display:flex}.word-list article>div{gap:6px;min-width:0;display:grid}.word-list a{overflow-wrap:anywhere;color:var(--orange-2);font-size:11px;text-decoration:none}.data-hero:after{background:radial-gradient(circle,#b0aba426,#0000 67%)}.data-panel{padding-bottom:18px}.data-list{gap:9px;display:grid}.data-row{border:1px solid var(--line);background:#121213a3;border-radius:15px;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;padding:15px 16px;display:grid}.data-row.empty{background:#12121357}.data-copy{gap:6px;min-width:0;display:grid}.data-title{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.data-title strong{font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:15px}.data-title code{overflow-wrap:anywhere;border:1px solid var(--line);color:#aaa6a0;background:#3736358c;border-radius:7px;padding:3px 7px;font:10px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}.data-copy small,.data-note,.data-usage span{color:var(--muted);font-size:11px;line-height:1.45}.data-note{color:#c7a57d}.data-usage{text-align:right;gap:4px;min-width:105px;display:grid}.data-usage strong{color:var(--orange-2);font-family:ui-rounded,SF Pro Rounded,system-ui,sans-serif;font-size:16px}.data-row.empty .data-usage strong{color:#8c8883}.data-row>button{min-width:82px}.data-warning{background:#d6922417;border:1px solid #ffc5614d;border-radius:12px;padding:11px 13px;font-size:12px;color:#ffd58a!important;margin-top:14px!important}.loading-panel{min-height:400px;color:var(--muted);justify-content:center;align-items:center;gap:12px;display:flex}.spinner{border:2px solid #ffffff24;border-top-color:var(--orange);border-radius:50%;width:22px;height:22px;animation:.8s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.modal-backdrop{z-index:100;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#050506cc;place-items:center;padding:20px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line-strong);background:#1c1c1e;border-radius:21px;width:min(680px,100%);max-height:calc(100vh - 40px);padding:23px;overflow:auto;box-shadow:0 36px 100px #0000008c}.modal-head{justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:18px;display:flex}.modal-head h2{margin:4px 0;font-size:24px}.console-modal{width:min(980px,100%)}.console-actions{flex-wrap:wrap;justify-content:flex-end}.console-follow{min-height:34px;color:var(--orange-2);background:#ff91341f;border-color:#ff91346b;padding:6px 11px;font-size:11px}.console-log{color:#cbc6c0;white-space:pre-wrap;background:#0b0b0c;border:1px solid #ff91342e;border-radius:14px;min-height:430px;max-height:calc(100vh - 190px);margin:0;padding:17px;font:12px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace;display:block;overflow:auto}.console-log span{min-height:1.65em;display:block}.console-log .success{color:#73e4b9}.console-log .error{color:#ff8290}.console-log .warning{color:#ffd079}.console-log .heading{color:var(--orange-2);font-weight:700}.stack{gap:14px;display:grid}.pairing-code{text-align:center;letter-spacing:.14em;text-transform:uppercase;font:700 28px/1 ui-rounded,SF Pro Rounded,system-ui,sans-serif}.link-success{text-align:center;place-items:center;gap:11px;padding:30px;display:grid}.link-success i{width:54px;height:54px;color:var(--green);background:#44dda51f;border:1px solid #44dda566;border-radius:50%;place-items:center;font-size:25px;font-style:normal;display:grid}.link-success span{color:var(--muted);font-size:12px}.trim-modal{width:min(820px,100%)}.waveform{border:1px solid var(--line);background:#0d0d0e;border-radius:14px;width:100%;height:210px;margin:16px 0}.range-grid{grid-template-columns:1fr 1fr;gap:13px;margin-bottom:13px;display:grid}.range-grid label{color:var(--muted);gap:7px;font-size:11px;display:grid}.range-grid input{width:100%;accent-color:var(--orange)}.modal-actions{justify-content:flex-end;margin-top:14px}.muted{color:var(--muted)}.toast{z-index:200;color:#eafff7;background:#144838f2;border:1px solid #44dda561;border-radius:13px;max-width:min(420px,100% - 44px);padding:13px 16px;font-size:13px;font-weight:700;position:fixed;bottom:22px;right:22px;box-shadow:0 18px 45px #0006}.toast.warning{background:#5d4116f7;border-color:#ffc56173}.toast.error{background:#5e1f2bf7;border-color:#ff6c7d73}.toast-enter-active,.toast-leave-active{transition:opacity .2s,transform .2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translateY(10px)}@media (width<=920px){.tab-full{display:none}.tab-short{display:inline}}@media (width<=780px){.app-shell{width:min(100% - 22px,1180px);padding-top:17px}.app-header{align-items:flex-start}.header-status{display:none}.tabs{top:7px}.tab-full{display:none}.tab-short{display:inline}.hero{min-height:unset;align-items:flex-start;padding:24px}.step-row{display:none}.panel{padding:19px}.panel-head{grid-template-columns:auto minmax(0,1fr)}.panel-head>:last-child:not(:nth-child(2)){grid-column:1/-1}.form-grid,.phrase-form,.stats,.action-grid,.range-grid{grid-template-columns:1fr}.field.wide{grid-column:auto}.audio-card header,.audio-card footer,.word-list article,.panel-footer{flex-direction:column;align-items:stretch}.audio-card footer>div{justify-content:flex-start}.word-list article>button{width:100%}.sample-head .segment-control{grid-column:1/-1}.segment-control button{flex:1}.data-row{grid-template-columns:1fr auto}.data-copy{grid-column:1/-1}.data-usage{text-align:left}.data-row>button{min-width:96px}.modal-backdrop{padding:8px}.modal{max-height:calc(100vh - 16px);padding:17px}.modal-head{flex-direction:column}.console-actions{justify-content:flex-start}.console-log{min-height:55vh}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}} +/*$vite$:1*/ \ No newline at end of file diff --git a/static/ui/trainer-ui.js b/static/ui/trainer-ui.js new file mode 100644 index 0000000..8073ddc --- /dev/null +++ b/static/ui/trainer-ui.js @@ -0,0 +1,4748 @@ +//#region node_modules/@vue/shared/dist/shared.esm-bundler.js +// @__NO_SIDE_EFFECTS__ +function e(e) { + let t = /* @__PURE__ */ Object.create(null); + for (let n of e.split(",")) t[n] = 1; + return (e) => e in t; +} +var t = {}, n = [], r = () => {}, i = () => !1, a = (e) => e.charCodeAt(0) === 111 && e.charCodeAt(1) === 110 && (e.charCodeAt(2) > 122 || e.charCodeAt(2) < 97), o = (e) => e.startsWith("onUpdate:"), s = Object.assign, c = (e, t) => { + let n = e.indexOf(t); + n > -1 && e.splice(n, 1); +}, l = Object.prototype.hasOwnProperty, u = (e, t) => l.call(e, t), d = Array.isArray, f = (e) => x(e) === "[object Map]", p = (e) => x(e) === "[object Set]", m = (e) => x(e) === "[object Date]", h = (e) => typeof e == "function", g = (e) => typeof e == "string", _ = (e) => typeof e == "symbol", v = (e) => typeof e == "object" && !!e, y = (e) => (v(e) || h(e)) && h(e.then) && h(e.catch), b = Object.prototype.toString, x = (e) => b.call(e), S = (e) => x(e).slice(8, -1), C = (e) => x(e) === "[object Object]", w = (e) => g(e) && e !== "NaN" && e[0] !== "-" && "" + parseInt(e, 10) === e, ee = /* @__PURE__ */ e(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"), te = (e) => { + let t = /* @__PURE__ */ Object.create(null); + return ((n) => t[n] || (t[n] = e(n))); +}, ne = /-\w/g, T = te((e) => e.replace(ne, (e) => e.slice(1).toUpperCase())), re = /\B([A-Z])/g, E = te((e) => e.replace(re, "-$1").toLowerCase()), ie = te((e) => e.charAt(0).toUpperCase() + e.slice(1)), ae = te((e) => e ? `on${ie(e)}` : ""), oe = (e, t) => !Object.is(e, t), se = (e, ...t) => { + for (let n = 0; n < e.length; n++) e[n](...t); +}, D = (e, t, n, r = !1) => { + Object.defineProperty(e, t, { + configurable: !0, + enumerable: !1, + writable: r, + value: n + }); +}, ce = (e) => { + let t = parseFloat(e); + return isNaN(t) ? e : t; +}, le = (e) => { + let t = g(e) ? Number(e) : NaN; + return isNaN(t) ? e : t; +}, ue, de = () => ue ||= typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : typeof window < "u" ? window : typeof global < "u" ? global : {}; +function fe(e) { + if (d(e)) { + let t = {}; + for (let n = 0; n < e.length; n++) { + let r = e[n], i = g(r) ? ge(r) : fe(r); + if (i) for (let e in i) t[e] = i[e]; + } + return t; + } + if (g(e) || v(e)) return e; +} +var pe = /;(?![^(]*\))/g, me = /:([^]+)/, he = /\/\*[^]*?\*\//g; +function ge(e) { + let t = {}; + return e.replace(he, "").split(pe).forEach((e) => { + if (e) { + let n = e.split(me); + n.length > 1 && (t[n[0].trim()] = n[1].trim()); + } + }), t; +} +function O(e) { + let t = ""; + if (g(e)) t = e; + else if (d(e)) for (let n = 0; n < e.length; n++) { + let r = O(e[n]); + r && (t += r + " "); + } + else if (v(e)) for (let n in e) e[n] && (t += n + " "); + return t.trim(); +} +var _e = "itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly", ve = /* @__PURE__ */ e(_e); +_e + ""; +function ye(e) { + return !!e || e === ""; +} +function be(e, t) { + if (e.length !== t.length) return !1; + let n = !0; + for (let r = 0; n && r < e.length; r++) n = xe(e[r], t[r]); + return n; +} +function xe(e, t) { + if (e === t) return !0; + let n = m(e), r = m(t); + if (n || r) return n && r ? e.getTime() === t.getTime() : !1; + if (n = _(e), r = _(t), n || r) return e === t; + if (n = d(e), r = d(t), n || r) return n && r ? be(e, t) : !1; + if (n = v(e), r = v(t), n || r) { + if (!n || !r || Object.keys(e).length !== Object.keys(t).length) return !1; + for (let n in e) { + let r = e.hasOwnProperty(n), i = t.hasOwnProperty(n); + if (r && !i || !r && i || !xe(e[n], t[n])) return !1; + } + } + return String(e) === String(t); +} +function Se(e, t) { + return e.findIndex((e) => xe(e, t)); +} +var Ce = (e) => !!(e && e.__v_isRef === !0), k = (e) => g(e) ? e : e == null ? "" : d(e) || v(e) && (e.toString === b || !h(e.toString)) ? Ce(e) ? k(e.value) : JSON.stringify(e, we, 2) : String(e), we = (e, t) => Ce(t) ? we(e, t.value) : f(t) ? { [`Map(${t.size})`]: [...t.entries()].reduce((e, [t, n], r) => (e[Te(t, r) + " =>"] = n, e), {}) } : p(t) ? { [`Set(${t.size})`]: [...t.values()].map((e) => Te(e)) } : _(t) ? Te(t) : v(t) && !d(t) && !C(t) ? String(t) : t, Te = (e, t = "") => _(e) ? `Symbol(${e.description ?? t})` : e, A, Ee = class { + constructor(e = !1) { + this.detached = e, this._active = !0, this._on = 0, this.effects = [], this.cleanups = [], this._isPaused = !1, this._warnOnRun = !0, this.__v_skip = !0, !e && A && (A.active ? (this.parent = A, this.index = (A.scopes || (A.scopes = [])).push(this) - 1) : (this._active = !1, this._warnOnRun = !1)); + } + get active() { + return this._active; + } + pause() { + if (this._active) { + this._isPaused = !0; + let e, t; + if (this.scopes) { + let n = this.scopes.slice(); + for (e = 0, t = n.length; e < t; e++) n[e].pause(); + } + for (e = 0, t = this.effects.length; e < t; e++) this.effects[e].pause(); + } + } + resume() { + if (this._active && this._isPaused) { + this._isPaused = !1; + let e, t; + if (this.scopes) { + let n = this.scopes.slice(); + for (e = 0, t = n.length; e < t; e++) n[e].resume(); + } + let n = this.effects.slice(); + for (e = 0, t = n.length; e < t; e++) n[e].resume(); + } + } + run(e) { + if (this._active) { + let t = A; + try { + return A = this, e(); + } finally { + A = t; + } + } + } + on() { + ++this._on === 1 && (this.prevScope = A, A = this); + } + off() { + if (this._on > 0 && --this._on === 0) { + if (A === this) A = this.prevScope; + else { + let e = A; + for (; e;) { + if (e.prevScope === this) { + e.prevScope = this.prevScope; + break; + } + e = e.prevScope; + } + } + this.prevScope = void 0; + } + } + stop(e) { + if (this._active) { + this._active = !1; + let t, n; + for (t = 0, n = this.effects.length; t < n; t++) this.effects[t].stop(); + for (this.effects.length = 0, t = 0, n = this.cleanups.length; t < n; t++) this.cleanups[t](); + if (this.cleanups.length = 0, this.scopes) { + let e = this.scopes.slice(); + for (t = 0, n = e.length; t < n; t++) e[t].stop(!0); + this.scopes.length = 0; + } + if (!this.detached && this.parent && !e) { + let e = this.parent.scopes.pop(); + e && e !== this && (this.parent.scopes[this.index] = e, e.index = this.index); + } + this.parent = void 0; + } + } +}; +function De() { + return A; +} +var j, Oe = /* @__PURE__ */ new WeakSet(), ke = class { + constructor(e) { + this.fn = e, this.deps = void 0, this.depsTail = void 0, this.flags = 5, this.next = void 0, this.cleanup = void 0, this.scheduler = void 0, A && (A.active ? A.effects.push(this) : this.flags &= -2); + } + pause() { + this.flags |= 64; + } + resume() { + this.flags & 64 && (this.flags &= -65, Oe.has(this) && (Oe.delete(this), this.trigger())); + } + notify() { + this.flags & 2 && !(this.flags & 32) || this.flags & 8 || Ne(this); + } + run() { + if (!(this.flags & 1)) return this.fn(); + this.flags |= 2, Ke(this), Ie(this); + let e = j, t = He; + j = this, He = !0; + try { + return this.fn(); + } finally { + Le(this), j = e, He = t, this.flags &= -3; + } + } + stop() { + if (this.flags & 1) { + for (let e = this.deps; e; e = e.nextDep) Be(e); + this.deps = this.depsTail = void 0, Ke(this), this.onStop && this.onStop(), this.flags &= -2; + } + } + trigger() { + this.flags & 64 ? Oe.add(this) : this.scheduler ? this.scheduler() : this.runIfDirty(); + } + runIfDirty() { + Re(this) && this.run(); + } + get dirty() { + return Re(this); + } +}, Ae = 0, je, Me; +function Ne(e, t = !1) { + if (e.flags |= 8, t) { + e.next = Me, Me = e; + return; + } + e.next = je, je = e; +} +function Pe() { + Ae++; +} +function Fe() { + if (--Ae > 0) return; + if (Me) { + let e = Me; + for (Me = void 0; e;) { + let t = e.next; + e.next = void 0, e.flags &= -9, e = t; + } + } + let e; + for (; je;) { + let t = je; + for (je = void 0; t;) { + let n = t.next; + if (t.next = void 0, t.flags &= -9, t.flags & 1) try { + t.trigger(); + } catch (t) { + e ||= t; + } + t = n; + } + } + if (e) throw e; +} +function Ie(e) { + for (let t = e.deps; t; t = t.nextDep) t.version = -1, t.prevActiveLink = t.dep.activeLink, t.dep.activeLink = t; +} +function Le(e) { + let t, n = e.depsTail, r = n; + for (; r;) { + let e = r.prevDep; + r.version === -1 ? (r === n && (n = e), Be(r), Ve(r)) : t = r, r.dep.activeLink = r.prevActiveLink, r.prevActiveLink = void 0, r = e; + } + e.deps = t, e.depsTail = n; +} +function Re(e) { + for (let t = e.deps; t; t = t.nextDep) if (t.dep.version !== t.version || t.dep.computed && (ze(t.dep.computed) || t.dep.version !== t.version)) return !0; + return !!e._dirty; +} +function ze(e) { + if (e.flags & 4 && !(e.flags & 16) || (e.flags &= -17, e.globalVersion === qe) || (e.globalVersion = qe, !e.isSSR && e.flags & 128 && (!e.deps && !e._dirty || !Re(e)))) return; + e.flags |= 2; + let t = e.dep, n = j, r = He; + j = e, He = !0; + try { + Ie(e); + let n = e.fn(e._value); + (t.version === 0 || oe(n, e._value)) && (e.flags |= 128, e._value = n, t.version++); + } catch (e) { + throw t.version++, e; + } finally { + j = n, He = r, Le(e), e.flags &= -3; + } +} +function Be(e, t = !1) { + let { dep: n, prevSub: r, nextSub: i } = e; + if (r && (r.nextSub = i, e.prevSub = void 0), i && (i.prevSub = r, e.nextSub = void 0), n.subs === e && (n.subs = r, !r && n.computed)) { + n.computed.flags &= -5; + for (let e = n.computed.deps; e; e = e.nextDep) Be(e, !0); + } + !t && !--n.sc && n.map && n.map.delete(n.key); +} +function Ve(e) { + let { prevDep: t, nextDep: n } = e; + t && (t.nextDep = n, e.prevDep = void 0), n && (n.prevDep = t, e.nextDep = void 0); +} +var He = !0, Ue = []; +function We() { + Ue.push(He), He = !1; +} +function Ge() { + let e = Ue.pop(); + He = e === void 0 || e; +} +function Ke(e) { + let { cleanup: t } = e; + if (e.cleanup = void 0, t) { + let e = j; + j = void 0; + try { + t(); + } finally { + j = e; + } + } +} +var qe = 0, Je = class { + constructor(e, t) { + this.sub = e, this.dep = t, this.version = t.version, this.nextDep = this.prevDep = this.nextSub = this.prevSub = this.prevActiveLink = void 0; + } +}, Ye = class { + constructor(e) { + this.computed = e, this.version = 0, this.activeLink = void 0, this.subs = void 0, this.map = void 0, this.key = void 0, this.sc = 0, this.__v_skip = !0; + } + track(e) { + if (!j || !He || j === this.computed) return; + let t = this.activeLink; + if (t === void 0 || t.sub !== j) t = this.activeLink = new Je(j, this), j.deps ? (t.prevDep = j.depsTail, j.depsTail.nextDep = t, j.depsTail = t) : j.deps = j.depsTail = t, Xe(t); + else if (t.version === -1 && (t.version = this.version, t.nextDep)) { + let e = t.nextDep; + e.prevDep = t.prevDep, t.prevDep && (t.prevDep.nextDep = e), t.prevDep = j.depsTail, t.nextDep = void 0, j.depsTail.nextDep = t, j.depsTail = t, j.deps === t && (j.deps = e); + } + return t; + } + trigger(e) { + this.version++, qe++, this.notify(e); + } + notify(e) { + Pe(); + try { + for (let e = this.subs; e; e = e.prevSub) e.sub.notify() && e.sub.dep.notify(); + } finally { + Fe(); + } + } +}; +function Xe(e) { + if (e.dep.sc++, e.sub.flags & 4) { + let t = e.dep.computed; + if (t && !e.dep.subs) { + t.flags |= 20; + for (let e = t.deps; e; e = e.nextDep) Xe(e); + } + let n = e.dep.subs; + n !== e && (e.prevSub = n, n && (n.nextSub = e)), e.dep.subs = e; + } +} +var Ze = /* @__PURE__ */ new WeakMap(), Qe = /* @__PURE__ */ Symbol(""), $e = /* @__PURE__ */ Symbol(""), et = /* @__PURE__ */ Symbol(""); +function M(e, t, n) { + if (He && j) { + let t = Ze.get(e); + t || Ze.set(e, t = /* @__PURE__ */ new Map()); + let r = t.get(n); + r || (t.set(n, r = new Ye()), r.map = t, r.key = n), r.track(); + } +} +function tt(e, t, n, r, i, a) { + let o = Ze.get(e); + if (!o) { + qe++; + return; + } + let s = (e) => { + e && e.trigger(); + }; + if (Pe(), t === "clear") o.forEach(s); + else { + let i = d(e), a = i && w(n); + if (i && n === "length") { + let e = Number(r); + o.forEach((t, n) => { + (n === "length" || n === et || !_(n) && n >= e) && s(t); + }); + } else switch ((n !== void 0 || o.has(void 0)) && s(o.get(n)), a && s(o.get(et)), t) { + case "add": + i ? a && s(o.get("length")) : (s(o.get(Qe)), f(e) && s(o.get($e))); + break; + case "delete": + i || (s(o.get(Qe)), f(e) && s(o.get($e))); + break; + case "set": f(e) && s(o.get(Qe)); + } + } + Fe(); +} +function nt(e) { + let t = /* @__PURE__ */ N(e); + return t === e ? t : (M(t, "iterate", et), /* @__PURE__ */ Vt(e) ? t : t.map(Wt)); +} +function rt(e) { + return M(e = /* @__PURE__ */ N(e), "iterate", et), e; +} +function it(e, t) { + return /* @__PURE__ */ Bt(e) ? Gt(/* @__PURE__ */ zt(e) ? Wt(t) : t) : Wt(t); +} +var at = { + __proto__: null, + [Symbol.iterator]() { + return ot(this, Symbol.iterator, (e) => it(this, e)); + }, + concat(...e) { + return nt(this).concat(...e.map((e) => d(e) ? nt(e) : e)); + }, + entries() { + return ot(this, "entries", (e) => (e[1] = it(this, e[1]), e)); + }, + every(e, t) { + return ct(this, "every", e, t, void 0, arguments); + }, + filter(e, t) { + return ct(this, "filter", e, t, (e) => e.map((e) => it(this, e)), arguments); + }, + find(e, t) { + return ct(this, "find", e, t, (e) => it(this, e), arguments); + }, + findIndex(e, t) { + return ct(this, "findIndex", e, t, void 0, arguments); + }, + findLast(e, t) { + return ct(this, "findLast", e, t, (e) => it(this, e), arguments); + }, + findLastIndex(e, t) { + return ct(this, "findLastIndex", e, t, void 0, arguments); + }, + forEach(e, t) { + return ct(this, "forEach", e, t, void 0, arguments); + }, + includes(...e) { + return ut(this, "includes", e); + }, + indexOf(...e) { + return ut(this, "indexOf", e); + }, + join(e) { + return nt(this).join(e); + }, + lastIndexOf(...e) { + return ut(this, "lastIndexOf", e); + }, + map(e, t) { + return ct(this, "map", e, t, void 0, arguments); + }, + pop() { + return dt(this, "pop"); + }, + push(...e) { + return dt(this, "push", e); + }, + reduce(e, ...t) { + return lt(this, "reduce", e, t); + }, + reduceRight(e, ...t) { + return lt(this, "reduceRight", e, t); + }, + shift() { + return dt(this, "shift"); + }, + some(e, t) { + return ct(this, "some", e, t, void 0, arguments); + }, + splice(...e) { + return dt(this, "splice", e); + }, + toReversed() { + return nt(this).toReversed(); + }, + toSorted(e) { + return nt(this).toSorted(e); + }, + toSpliced(...e) { + return nt(this).toSpliced(...e); + }, + unshift(...e) { + return dt(this, "unshift", e); + }, + values() { + return ot(this, "values", (e) => it(this, e)); + } +}; +function ot(e, t, n) { + let r = rt(e), i = r[t](); + return r !== e && !/* @__PURE__ */ Vt(e) && (i._next = i.next, i.next = () => { + let e = i._next(); + return e.done || (e.value = n(e.value)), e; + }), i; +} +var st = Array.prototype; +function ct(e, t, n, r, i, a) { + let o = rt(e), s = o !== e && !/* @__PURE__ */ Vt(e), c = o[t]; + if (c !== st[t]) { + let t = c.apply(e, a); + return s ? Wt(t) : t; + } + let l = n; + o !== e && (s ? l = function(t, r) { + return n.call(this, it(e, t), r, e); + } : n.length > 2 && (l = function(t, r) { + return n.call(this, t, r, e); + })); + let u = c.call(o, l, r); + return s && i ? i(u) : u; +} +function lt(e, t, n, r) { + let i = rt(e), a = i !== e && !/* @__PURE__ */ Vt(e), o = n, s = !1; + i !== e && (a ? (s = r.length === 0, o = function(t, r, i) { + return s && (s = !1, t = it(e, t)), n.call(this, t, it(e, r), i, e); + }) : n.length > 3 && (o = function(t, r, i) { + return n.call(this, t, r, i, e); + })); + let c = i[t](o, ...r); + return s ? it(e, c) : c; +} +function ut(e, t, n) { + let r = /* @__PURE__ */ N(e); + M(r, "iterate", et); + let i = r[t](...n); + return (i === -1 || i === !1) && /* @__PURE__ */ Ht(n[0]) ? (n[0] = /* @__PURE__ */ N(n[0]), r[t](...n)) : i; +} +function dt(e, t, n = []) { + We(), Pe(); + let r = (/* @__PURE__ */ N(e))[t].apply(e, n); + return Fe(), Ge(), r; +} +var ft = /* @__PURE__ */ e("__proto__,__v_isRef,__isVue"), pt = new Set(/* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((e) => e !== "arguments" && e !== "caller").map((e) => Symbol[e]).filter(_)); +function mt(e) { + _(e) || (e = String(e)); + let t = /* @__PURE__ */ N(this); + return M(t, "has", e), t.hasOwnProperty(e); +} +var ht = class { + constructor(e = !1, t = !1) { + this._isReadonly = e, this._isShallow = t; + } + get(e, t, n) { + if (t === "__v_skip") return e.__v_skip; + let r = this._isReadonly, i = this._isShallow; + if (t === "__v_isReactive") return !r; + if (t === "__v_isReadonly") return r; + if (t === "__v_isShallow") return i; + if (t === "__v_raw") return n === (r ? i ? Nt : Mt : i ? jt : At).get(e) || Object.getPrototypeOf(e) === Object.getPrototypeOf(n) ? e : void 0; + let a = d(e); + if (!r) { + let e; + if (a && (e = at[t])) return e; + if (t === "hasOwnProperty") return mt; + } + let o = Reflect.get(e, t, /* @__PURE__ */ P(e) ? e : n); + if ((_(t) ? pt.has(t) : ft(t)) || (r || M(e, "get", t), i)) return o; + if (/* @__PURE__ */ P(o)) { + let e = a && w(t) ? o : o.value; + return r && v(e) ? /* @__PURE__ */ Lt(e) : e; + } + return v(o) ? r ? /* @__PURE__ */ Lt(o) : /* @__PURE__ */ Ft(o) : o; + } +}, gt = class extends ht { + constructor(e = !1) { + super(!1, e); + } + set(e, t, n, r) { + let i = e[t], a = d(e) && w(t); + if (!this._isShallow) { + let e = /* @__PURE__ */ Bt(i); + if (!/* @__PURE__ */ Vt(n) && !/* @__PURE__ */ Bt(n) && (i = /* @__PURE__ */ N(i), n = /* @__PURE__ */ N(n)), !a && /* @__PURE__ */ P(i) && !/* @__PURE__ */ P(n)) return e || (i.value = n), !0; + } + let o = a ? Number(t) < e.length : u(e, t), s = Reflect.set(e, t, n, /* @__PURE__ */ P(e) ? e : r); + return e === /* @__PURE__ */ N(r) && s && (o ? oe(n, i) && tt(e, "set", t, n, i) : tt(e, "add", t, n)), s; + } + deleteProperty(e, t) { + let n = u(e, t), r = e[t], i = Reflect.deleteProperty(e, t); + return i && n && tt(e, "delete", t, void 0, r), i; + } + has(e, t) { + let n = Reflect.has(e, t); + return (!_(t) || !pt.has(t)) && M(e, "has", t), n; + } + ownKeys(e) { + return M(e, "iterate", d(e) ? "length" : Qe), Reflect.ownKeys(e); + } +}, _t = class extends ht { + constructor(e = !1) { + super(!0, e); + } + set(e, t) { + return !0; + } + deleteProperty(e, t) { + return !0; + } +}, vt = /* @__PURE__ */ new gt(), yt = /* @__PURE__ */ new _t(), bt = /* @__PURE__ */ new gt(!0), xt = (e) => e, St = (e) => Reflect.getPrototypeOf(e); +function Ct(e, t, n) { + return function(...r) { + let i = this.__v_raw, a = /* @__PURE__ */ N(i), o = f(a), c = e === "entries" || e === Symbol.iterator && o, l = e === "keys" && o, u = i[e](...r), d = n ? xt : t ? Gt : Wt; + return !t && M(a, "iterate", l ? $e : Qe), s(Object.create(u), { next() { + let { value: e, done: t } = u.next(); + return t ? { + value: e, + done: t + } : { + value: c ? [d(e[0]), d(e[1])] : d(e), + done: t + }; + } }); + }; +} +function wt(e) { + return function(...t) { + return e === "delete" ? !1 : e === "clear" ? void 0 : this; + }; +} +function Tt(e, t) { + let n = { + get(n) { + let r = this.__v_raw, i = /* @__PURE__ */ N(r), a = /* @__PURE__ */ N(n); + e || (oe(n, a) && M(i, "get", n), M(i, "get", a)); + let { has: o } = St(i), s = t ? xt : e ? Gt : Wt; + if (o.call(i, n)) return s(r.get(n)); + if (o.call(i, a)) return s(r.get(a)); + r !== i && r.get(n); + }, + get size() { + let t = this.__v_raw; + return !e && M(/* @__PURE__ */ N(t), "iterate", Qe), t.size; + }, + has(t) { + let n = this.__v_raw, r = /* @__PURE__ */ N(n), i = /* @__PURE__ */ N(t); + return e || (oe(t, i) && M(r, "has", t), M(r, "has", i)), t === i ? n.has(t) : n.has(t) || n.has(i); + }, + forEach(n, r) { + let i = this, a = i.__v_raw, o = /* @__PURE__ */ N(a), s = t ? xt : e ? Gt : Wt; + return !e && M(o, "iterate", Qe), a.forEach((e, t) => n.call(r, s(e), s(t), i)); + } + }; + return s(n, e ? { + add: wt("add"), + set: wt("set"), + delete: wt("delete"), + clear: wt("clear") + } : { + add(e) { + let n = /* @__PURE__ */ N(this), r = St(n), i = /* @__PURE__ */ N(e), a = !t && !/* @__PURE__ */ Vt(e) && !/* @__PURE__ */ Bt(e) ? i : e; + return r.has.call(n, a) || oe(e, a) && r.has.call(n, e) || oe(i, a) && r.has.call(n, i) || (n.add(a), tt(n, "add", a, a)), this; + }, + set(e, n) { + !t && !/* @__PURE__ */ Vt(n) && !/* @__PURE__ */ Bt(n) && (n = /* @__PURE__ */ N(n)); + let r = /* @__PURE__ */ N(this), { has: i, get: a } = St(r), o = i.call(r, e); + o ||= (e = /* @__PURE__ */ N(e), i.call(r, e)); + let s = a.call(r, e); + return r.set(e, n), o ? oe(n, s) && tt(r, "set", e, n, s) : tt(r, "add", e, n), this; + }, + delete(e) { + let t = /* @__PURE__ */ N(this), { has: n, get: r } = St(t), i = n.call(t, e); + i ||= (e = /* @__PURE__ */ N(e), n.call(t, e)); + let a = r ? r.call(t, e) : void 0, o = t.delete(e); + return i && tt(t, "delete", e, void 0, a), o; + }, + clear() { + let e = /* @__PURE__ */ N(this), t = e.size !== 0, n = e.clear(); + return t && tt(e, "clear", void 0, void 0, void 0), n; + } + }), [ + "keys", + "values", + "entries", + Symbol.iterator + ].forEach((r) => { + n[r] = Ct(r, e, t); + }), n; +} +function Et(e, t) { + let n = Tt(e, t); + return (t, r, i) => r === "__v_isReactive" ? !e : r === "__v_isReadonly" ? e : r === "__v_raw" ? t : Reflect.get(u(n, r) && r in t ? n : t, r, i); +} +var Dt = { get: /* @__PURE__ */ Et(!1, !1) }, Ot = { get: /* @__PURE__ */ Et(!1, !0) }, kt = { get: /* @__PURE__ */ Et(!0, !1) }, At = /* @__PURE__ */ new WeakMap(), jt = /* @__PURE__ */ new WeakMap(), Mt = /* @__PURE__ */ new WeakMap(), Nt = /* @__PURE__ */ new WeakMap(); +function Pt(e) { + switch (e) { + case "Object": + case "Array": return 1; + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": return 2; + default: return 0; + } +} +// @__NO_SIDE_EFFECTS__ +function Ft(e) { + return /* @__PURE__ */ Bt(e) ? e : Rt(e, !1, vt, Dt, At); +} +// @__NO_SIDE_EFFECTS__ +function It(e) { + return Rt(e, !1, bt, Ot, jt); +} +// @__NO_SIDE_EFFECTS__ +function Lt(e) { + return Rt(e, !0, yt, kt, Mt); +} +function Rt(e, t, n, r, i) { + if (!v(e) || e.__v_raw && !(t && e.__v_isReactive) || e.__v_skip || !Object.isExtensible(e)) return e; + let a = i.get(e); + if (a) return a; + let o = Pt(S(e)); + if (o === 0) return e; + let s = new Proxy(e, o === 2 ? r : n); + return i.set(e, s), s; +} +// @__NO_SIDE_EFFECTS__ +function zt(e) { + return /* @__PURE__ */ Bt(e) ? /* @__PURE__ */ zt(e.__v_raw) : !!(e && e.__v_isReactive); +} +// @__NO_SIDE_EFFECTS__ +function Bt(e) { + return !!(e && e.__v_isReadonly); +} +// @__NO_SIDE_EFFECTS__ +function Vt(e) { + return !!(e && e.__v_isShallow); +} +// @__NO_SIDE_EFFECTS__ +function Ht(e) { + return e ? !!e.__v_raw : !1; +} +// @__NO_SIDE_EFFECTS__ +function N(e) { + let t = e && e.__v_raw; + return t ? /* @__PURE__ */ N(t) : e; +} +function Ut(e) { + return !u(e, "__v_skip") && Object.isExtensible(e) && D(e, "__v_skip", !0), e; +} +var Wt = (e) => v(e) ? /* @__PURE__ */ Ft(e) : e, Gt = (e) => v(e) ? /* @__PURE__ */ Lt(e) : e; +// @__NO_SIDE_EFFECTS__ +function P(e) { + return e ? e.__v_isRef === !0 : !1; +} +// @__NO_SIDE_EFFECTS__ +function F(e) { + return Kt(e, !1); +} +function Kt(e, t) { + return /* @__PURE__ */ P(e) ? e : new qt(e, t); +} +var qt = class { + constructor(e, t) { + this.dep = new Ye(), this.__v_isRef = !0, this.__v_isShallow = !1, this._rawValue = t ? e : /* @__PURE__ */ N(e), this._value = t ? e : Wt(e), this.__v_isShallow = t; + } + get value() { + return this.dep.track(), this._value; + } + set value(e) { + let t = this._rawValue, n = this.__v_isShallow || /* @__PURE__ */ Vt(e) || /* @__PURE__ */ Bt(e); + e = n ? e : /* @__PURE__ */ N(e), oe(e, t) && (this._rawValue = e, this._value = n ? e : Wt(e), this.dep.trigger()); + } +}; +function I(e) { + return /* @__PURE__ */ P(e) ? e.value : e; +} +var Jt = { + get: (e, t, n) => t === "__v_raw" ? e : I(Reflect.get(e, t, n)), + set: (e, t, n, r) => { + let i = e[t]; + return /* @__PURE__ */ P(i) && !/* @__PURE__ */ P(n) ? (i.value = n, !0) : Reflect.set(e, t, n, r); + } +}; +function Yt(e) { + return /* @__PURE__ */ zt(e) ? e : new Proxy(e, Jt); +} +var Xt = class { + constructor(e, t, n) { + this.fn = e, this.setter = t, this._value = void 0, this.dep = new Ye(this), this.__v_isRef = !0, this.deps = void 0, this.depsTail = void 0, this.flags = 16, this.globalVersion = qe - 1, this.next = void 0, this.effect = this, this.__v_isReadonly = !t, this.isSSR = n; + } + notify() { + if (this.flags |= 16, !(this.flags & 8) && j !== this) return Ne(this, !0), !0; + } + get value() { + let e = this.dep.track(); + return ze(this), e && (e.version = this.dep.version), this._value; + } + set value(e) { + this.setter && this.setter(e); + } +}; +// @__NO_SIDE_EFFECTS__ +function Zt(e, t, n = !1) { + let r, i; + return h(e) ? r = e : (r = e.get, i = e.set), new Xt(r, i, n); +} +var Qt = {}, $t = /* @__PURE__ */ new WeakMap(), en = void 0; +function tn(e, t = !1, n = en) { + if (n) { + let t = $t.get(n); + t || $t.set(n, t = []), t.push(e); + } +} +function nn(e, n, i = t) { + let { immediate: a, deep: o, once: s, scheduler: l, augmentJob: u, call: f } = i, p = (e) => o ? e : /* @__PURE__ */ Vt(e) || o === !1 || o === 0 ? rn(e, 1) : rn(e), m, g, _, v, y = !1, b = !1; + if (/* @__PURE__ */ P(e) ? (g = () => e.value, y = /* @__PURE__ */ Vt(e)) : /* @__PURE__ */ zt(e) ? (g = () => p(e), y = !0) : d(e) ? (b = !0, y = e.some((e) => /* @__PURE__ */ zt(e) || /* @__PURE__ */ Vt(e)), g = () => e.map((e) => { + if (/* @__PURE__ */ P(e)) return e.value; + if (/* @__PURE__ */ zt(e)) return p(e); + if (h(e)) return f ? f(e, 2) : e(); + })) : g = h(e) ? n ? f ? () => f(e, 2) : e : () => { + if (_) { + We(); + try { + _(); + } finally { + Ge(); + } + } + let t = en; + en = m; + try { + return f ? f(e, 3, [v]) : e(v); + } finally { + en = t; + } + } : r, n && o) { + let e = g, t = o === !0 ? Infinity : o; + g = () => rn(e(), t); + } + let x = De(), S = () => { + m.stop(), x && x.active && c(x.effects, m); + }; + if (s && n) { + let e = n; + n = (...t) => { + let n = e(...t); + return S(), n; + }; + } + let C = b ? Array(e.length).fill(Qt) : Qt, w = (e) => { + if (!(!(m.flags & 1) || !m.dirty && !e)) if (n) { + let t = m.run(); + if (e || o || y || (b ? t.some((e, t) => oe(e, C[t])) : oe(t, C))) { + _ && _(); + let e = en; + en = m; + try { + let e = [ + t, + C === Qt ? void 0 : b && C[0] === Qt ? [] : C, + v + ]; + C = t, f ? f(n, 3, e) : n(...e); + } finally { + en = e; + } + } + } else m.run(); + }; + return u && u(w), m = new ke(g), m.scheduler = l ? () => l(w, !1) : w, v = (e) => tn(e, !1, m), _ = m.onStop = () => { + let e = $t.get(m); + if (e) { + if (f) f(e, 4); + else for (let t of e) t(); + $t.delete(m); + } + }, n ? a ? w(!0) : C = m.run() : l ? l(w.bind(null, !0), !0) : m.run(), S.pause = m.pause.bind(m), S.resume = m.resume.bind(m), S.stop = S, S; +} +function rn(e, t = Infinity, n) { + if (t <= 0 || !v(e) || e.__v_skip || (n ||= /* @__PURE__ */ new Map(), (n.get(e) || 0) >= t)) return e; + if (n.set(e, t), t--, /* @__PURE__ */ P(e)) rn(e.value, t, n); + else if (d(e)) for (let r = 0; r < e.length; r++) rn(e[r], t, n); + else if (p(e) || f(e)) e.forEach((e) => { + rn(e, t, n); + }); + else if (C(e)) { + for (let r in e) rn(e[r], t, n); + for (let r of Object.getOwnPropertySymbols(e)) Object.prototype.propertyIsEnumerable.call(e, r) && rn(e[r], t, n); + } + return e; +} +//#endregion +//#region node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js +function an(e, t, n, r) { + try { + return r ? e(...r) : e(); + } catch (e) { + sn(e, t, n); + } +} +function on(e, t, n, r) { + if (h(e)) { + let i = an(e, t, n, r); + return i && y(i) && i.catch((e) => { + sn(e, t, n); + }), i; + } + if (d(e)) { + let i = []; + for (let a = 0; a < e.length; a++) i.push(on(e[a], t, n, r)); + return i; + } +} +function sn(e, n, r, i = !0) { + let a = n ? n.vnode : null, { errorHandler: o, throwUnhandledErrorInProduction: s } = n && n.appContext.config || t; + if (n) { + let t = n.parent, i = n.proxy, a = `https://vuejs.org/error-reference/#runtime-${r}`; + for (; t;) { + let n = t.ec; + if (n) { + for (let t = 0; t < n.length; t++) if (n[t](e, i, a) === !1) return; + } + t = t.parent; + } + if (o) { + We(), an(o, null, 10, [ + e, + i, + a + ]), Ge(); + return; + } + } + cn(e, r, a, i, s); +} +function cn(e, t, n, r = !0, i = !1) { + if (i) throw e; + console.error(e); +} +var L = [], ln = -1, un = [], dn = null, fn = 0, pn = /* @__PURE__ */ Promise.resolve(), mn = null; +function hn(e) { + let t = mn || pn; + return e ? t.then(this ? e.bind(this) : e) : t; +} +function gn(e) { + let t = ln + 1, n = L.length; + for (; t < n;) { + let r = t + n >>> 1, i = L[r], a = Sn(i); + a < e || a === e && i.flags & 2 ? t = r + 1 : n = r; + } + return t; +} +function _n(e) { + if (!(e.flags & 1)) { + let t = Sn(e), n = L[L.length - 1]; + !n || !(e.flags & 2) && t >= Sn(n) ? L.push(e) : L.splice(gn(t), 0, e), e.flags |= 1, vn(); + } +} +function vn() { + mn ||= pn.then(Cn); +} +function yn(e) { + d(e) ? un.push(...e) : dn && e.id === -1 ? dn.splice(fn + 1, 0, e) : e.flags & 1 || (un.push(e), e.flags |= 1), vn(); +} +function bn(e, t, n = ln + 1) { + for (; n < L.length; n++) { + let t = L[n]; + if (t && t.flags & 2) { + if (e && t.id !== e.uid) continue; + L.splice(n, 1), n--, t.flags & 4 && (t.flags &= -2), t(), t.flags & 4 || (t.flags &= -2); + } + } +} +function xn(e) { + if (un.length) { + let e = [...new Set(un)].sort((e, t) => Sn(e) - Sn(t)); + if (un.length = 0, dn) { + dn.push(...e); + return; + } + for (dn = e, fn = 0; fn < dn.length; fn++) { + let e = dn[fn]; + e.flags & 4 && (e.flags &= -2), e.flags & 8 || e(), e.flags &= -2; + } + dn = null, fn = 0; + } +} +var Sn = (e) => e.id == null ? e.flags & 2 ? -1 : Infinity : e.id; +function Cn(e) { + try { + for (ln = 0; ln < L.length; ln++) { + let e = L[ln]; + e && !(e.flags & 8) && (e.flags & 4 && (e.flags &= -2), an(e, e.i, e.i ? 15 : 14), e.flags & 4 || (e.flags &= -2)); + } + } finally { + for (; ln < L.length; ln++) { + let e = L[ln]; + e && (e.flags &= -2); + } + ln = -1, L.length = 0, xn(e), mn = null, (L.length || un.length) && Cn(e); + } +} +var wn = null, Tn = null; +function En(e) { + let t = wn; + return wn = e, Tn = e && e.type.__scopeId || null, t; +} +function Dn(e, t = wn, n) { + if (!t || e._n) return e; + let r = (...n) => { + r._d && ta(-1); + let i = En(t), a = Zi.length, o; + try { + o = e(...n); + } finally { + for (let e = Zi.length; e > a; e--) $i(); + En(i), r._d && ta(1); + } + return o; + }; + return r._n = !0, r._c = !0, r._d = !0, r; +} +function R(e, n) { + if (wn === null) return e; + let r = Ia(wn), i = e.dirs ||= []; + for (let e = 0; e < n.length; e++) { + let [a, o, s, c = t] = n[e]; + a && (h(a) && (a = { + mounted: a, + updated: a + }), a.deep && rn(o), i.push({ + dir: a, + instance: r, + value: o, + oldValue: void 0, + arg: s, + modifiers: c + })); + } + return e; +} +function On(e, t, n, r) { + let i = e.dirs, a = t && t.dirs; + for (let o = 0; o < i.length; o++) { + let s = i[o]; + a && (s.oldValue = a[o].value); + let c = s.dir[r]; + c && (We(), on(c, n, 8, [ + e.el, + s, + e, + t + ]), Ge()); + } +} +function kn(e, t) { + if (J) { + let n = J.provides, r = J.parent && J.parent.provides; + r === n && (n = J.provides = Object.create(r)), n[e] = t; + } +} +function An(e, t, n = !1) { + let r = xa(); + if (r || oi) { + let i = oi ? oi._context.provides : r ? r.parent == null || r.ce ? r.vnode.appContext && r.vnode.appContext.provides : r.parent.provides : void 0; + if (i && e in i) return i[e]; + if (arguments.length > 1) return n && h(t) ? t.call(r && r.proxy) : t; + } +} +var jn = /* @__PURE__ */ Symbol.for("v-scx"), Mn = () => An(jn); +function Nn(e, t, n) { + return Pn(e, t, n); +} +function Pn(e, n, i = t) { + let { immediate: a, deep: o, flush: c, once: l } = i, u = s({}, i), d = n && a || !n && c !== "post", f; + if (Da) { + if (c === "sync") { + let e = Mn(); + f = e.__watcherHandles ||= []; + } else if (!d) { + let e = () => {}; + return e.stop = r, e.resume = r, e.pause = r, e; + } + } + let p = J; + u.call = (e, t, n) => on(e, p, t, n); + let m = !1; + c === "post" ? u.scheduler = (e) => { + B(e, p && p.suspense); + } : c !== "sync" && (m = !0, u.scheduler = (e, t) => { + t ? e() : _n(e); + }), u.augmentJob = (e) => { + n && (e.flags |= 4), m && (e.flags |= 2, p && (e.id = p.uid, e.i = p)); + }; + let h = nn(e, n, u); + return Da && (f ? f.push(h) : d && h()), h; +} +function Fn(e, t, n) { + let r = this.proxy, i = g(e) ? e.includes(".") ? In(r, e) : () => r[e] : e.bind(r, r), a; + h(t) ? a = t : (a = t.handler, n = t); + let o = wa(this), s = Pn(i, a.bind(r), n); + return o(), s; +} +function In(e, t) { + let n = t.split("."); + return () => { + let t = e; + for (let e = 0; e < n.length && t; e++) t = t[n[e]]; + return t; + }; +} +var Ln = /* @__PURE__ */ new WeakMap(), Rn = /* @__PURE__ */ Symbol("_vte"), zn = (e) => e.__isTeleport, Bn = (e) => e && (e.disabled || e.disabled === ""), Vn = (e) => e && (e.defer || e.defer === ""), Hn = (e) => typeof SVGElement < "u" && e instanceof SVGElement, Un = (e) => typeof MathMLElement == "function" && e instanceof MathMLElement, Wn = (e, t) => { + let n = e && e.to; + return g(n) ? t ? t(n) : null : n; +}, Gn = { + name: "Teleport", + __isTeleport: !0, + process(e, t, n, r, i, a, o, s, c, l) { + let { mc: u, pc: d, pbc: f, o: { insert: p, querySelector: m, createText: h, createComment: g, parentNode: _ } } = l, v = Bn(t.props), { dynamicChildren: y } = t, b = (e, t, n) => { + e.shapeFlag & 16 && u(e.children, t, n, i, a, o, s, c); + }, x = (e = t) => { + let n = Bn(e.props), r = e.target = Wn(e.props, m), a = Xn(r, e, h, p); + r && (o !== "svg" && Hn(r) ? o = "svg" : o !== "mathml" && Un(r) && (o = "mathml"), i && i.isCE && (i.ce._teleportTargets || (i.ce._teleportTargets = /* @__PURE__ */ new Set())).add(r), n || (b(e, r, a), Yn(e, !1))); + }, S = (e) => { + let t = () => { + if (Ln.get(e) === t) { + if (Ln.delete(e), Bn(e.props)) { + let t = _(e.el) || n; + b(e, t, e.anchor), Yn(e, !0); + } + x(e); + } + }; + Ln.set(e, t), B(t, a); + }; + if (e == null) { + let e = t.el = h(""), i = t.anchor = h(""); + if (p(e, n, r), p(i, n, r), Vn(t.props) || a && a.pendingBranch) { + S(t); + return; + } + v && (b(t, n, i), Yn(t, !0)), x(); + } else { + t.el = e.el; + let r = t.anchor = e.anchor, u = Ln.get(e); + if (u) { + u.flags |= 8, Ln.delete(e), S(t); + return; + } + t.targetStart = e.targetStart; + let p = t.target = e.target, h = t.targetAnchor = e.targetAnchor, g = Bn(e.props), _ = g ? n : p, b = g ? r : h; + if (o === "svg" || Hn(p) ? o = "svg" : (o === "mathml" || Un(p)) && (o = "mathml"), y ? (f(e.dynamicChildren, y, _, i, a, o, s), Hi(e, t, !0)) : c || d(e, t, _, b, i, a, o, s, !1), v) g ? t.props && e.props && t.props.to !== e.props.to && (t.props.to = e.props.to) : Kn(t, n, r, l, 1); + else if ((t.props && t.props.to) !== (e.props && e.props.to)) { + let e = Wn(t.props, m); + e && (t.target = e, Kn(t, e, null, l, 0)); + } else g && Kn(t, p, h, l, 1); + Yn(t, v); + } + }, + remove(e, t, n, { um: r, o: { remove: i } }, a) { + let { shapeFlag: o, children: s, anchor: c, targetStart: l, targetAnchor: u, target: d, props: f } = e, p = Bn(f), m = a || !p, h = Ln.get(e); + if (h && (h.flags |= 8, Ln.delete(e)), d && (i(l), i(u)), a && i(c), !h && (p || d) && o & 16) for (let e = 0; e < s.length; e++) { + let i = s[e]; + r(i, t, n, m, !!i.dynamicChildren); + } + }, + move: Kn, + hydrate: qn +}; +function Kn(e, t, n, { o: { insert: r }, m: i }, a = 2) { + a === 0 && r(e.targetAnchor, t, n); + let { el: o, anchor: s, shapeFlag: c, children: l, props: u } = e, d = a === 2; + if (d && r(o, t, n), !Ln.has(e) && (!d || Bn(u)) && c & 16) for (let e = 0; e < l.length; e++) i(l[e], t, n, 2); + d && r(s, t, n); +} +function qn(e, t, n, r, i, a, { o: { nextSibling: o, parentNode: s, querySelector: c, insert: l, createText: u } }, d) { + function f(e, n) { + let r = n; + for (; r;) { + if (r && r.nodeType === 8) { + if (r.data === "teleport start anchor") t.targetStart = r; + else if (r.data === "teleport anchor") { + t.targetAnchor = r, e._lpa = t.targetAnchor && o(t.targetAnchor); + break; + } + } + r = o(r); + } + } + function p(e, t) { + t.anchor = d(o(e), t, s(e), n, r, i, a); + } + let m = t.target = Wn(t.props, c), h = Bn(t.props); + if (m) { + let c = m._lpa || m.firstChild; + t.shapeFlag & 16 && (h ? (p(e, t), f(m, c), t.targetAnchor || Xn(m, t, u, l, s(e) === m ? e : null)) : (t.anchor = o(e), f(m, c), t.targetAnchor || Xn(m, t, u, l), d(c && o(c), t, m, n, r, i, a))), Yn(t, h); + } else h && t.shapeFlag & 16 && (p(e, t), t.targetStart = e, t.targetAnchor = o(e)); + return t.anchor && o(t.anchor); +} +var Jn = Gn; +function Yn(e, t) { + let n = e.ctx; + if (n && n.ut) { + let r, i; + for (t ? (r = e.el, i = e.anchor) : (r = e.targetStart, i = e.targetAnchor); r && r !== i;) r.nodeType === 1 && r.setAttribute("data-v-owner", n.uid), r = r.nextSibling; + n.ut(); + } +} +function Xn(e, t, n, r, i = null) { + let a = t.targetStart = n(""), o = t.targetAnchor = n(""); + return a[Rn] = o, e && (r(a, e, i), r(o, e, i)), o; +} +var Zn = /* @__PURE__ */ Symbol("_leaveCb"), Qn = /* @__PURE__ */ Symbol("_enterCb"); +function $n() { + let e = { + isMounted: !1, + isLeaving: !1, + isUnmounting: !1, + leavingVNodes: /* @__PURE__ */ new Map() + }; + return Dr(() => { + e.isMounted = !0; + }), Ar(() => { + e.isUnmounting = !0; + }), e; +} +var er = [Function, Array], tr = { + mode: String, + appear: Boolean, + persisted: Boolean, + onBeforeEnter: er, + onEnter: er, + onAfterEnter: er, + onEnterCancelled: er, + onBeforeLeave: er, + onLeave: er, + onAfterLeave: er, + onLeaveCancelled: er, + onBeforeAppear: er, + onAppear: er, + onAfterAppear: er, + onAppearCancelled: er +}, nr = (e) => { + let t = e.subTree; + return t.component ? nr(t.component) : t; +}, rr = { + name: "BaseTransition", + props: tr, + setup(e, { slots: t }) { + let n = xa(), r = $n(); + return () => { + let i = t.default && dr(t.default(), !0), a = i && i.length ? ir(i) : n.subTree ? q() : void 0; + if (!a) return; + let o = /* @__PURE__ */ N(e), { mode: s } = o; + if (r.isLeaving) return cr(a); + let c = lr(a); + if (!c) return cr(a); + let l = sr(c, o, r, n, (e) => l = e); + c.type !== H && ur(c, l); + let u = n.subTree && lr(n.subTree); + if (u && u.type !== H && !aa(u, c) && nr(n).type !== H) { + let e = sr(u, o, r, n); + if (ur(u, e), s === "out-in" && c.type !== H) return r.isLeaving = !0, e.afterLeave = () => { + r.isLeaving = !1, n.job.flags & 8 || n.update(), delete e.afterLeave, u = void 0; + }, cr(a); + s === "in-out" && c.type !== H ? e.delayLeave = (e, t, n) => { + let i = or(r, u); + i[String(u.key)] = u, e[Zn] = () => { + t(), e[Zn] = void 0, delete l.delayedLeave, u = void 0; + }, l.delayedLeave = () => { + n(), delete l.delayedLeave, u = void 0; + }; + } : u = void 0; + } else u &&= void 0; + return a; + }; + } +}; +function ir(e) { + let t = e[0]; + if (e.length > 1) { + for (let n of e) if (n.type !== H) { + t = n; + break; + } + } + return t; +} +var ar = rr; +function or(e, t) { + let { leavingVNodes: n } = e, r = n.get(t.type); + return r || (r = /* @__PURE__ */ Object.create(null), n.set(t.type, r)), r; +} +function sr(e, t, n, r, i) { + let { appear: a, mode: o, persisted: s = !1, onBeforeEnter: c, onEnter: l, onAfterEnter: u, onEnterCancelled: f, onBeforeLeave: p, onLeave: m, onAfterLeave: h, onLeaveCancelled: g, onBeforeAppear: _, onAppear: v, onAfterAppear: y, onAppearCancelled: b } = t, x = String(e.key), S = or(n, e), C = (e, t) => { + e && on(e, r, 9, t); + }, w = (e, t) => { + let n = t[1]; + C(e, t), d(e) ? e.every((e) => e.length <= 1) && n() : e.length <= 1 && n(); + }, ee = { + mode: o, + persisted: s, + beforeEnter(t) { + let r = c; + if (!n.isMounted) if (a) r = _ || c; + else return; + t[Zn] && t[Zn](!0); + let i = S[x]; + i && aa(e, i) && i.el[Zn] && i.el[Zn](), C(r, [t]); + }, + enter(t) { + if (S[x] === e) return; + let r = l, i = u, o = f; + if (!n.isMounted) if (a) r = v || l, i = y || u, o = b || f; + else return; + let s = !1; + t[Qn] = (e) => { + s || (s = !0, C(e ? o : i, [t]), ee.delayedLeave && ee.delayedLeave(), t[Qn] = void 0); + }; + let c = t[Qn].bind(null, !1); + r ? w(r, [t, c]) : c(); + }, + leave(t, r) { + let i = String(e.key); + if (t[Qn] && t[Qn](!0), n.isUnmounting) return r(); + C(p, [t]); + let a = !1; + t[Zn] = (n) => { + a || (a = !0, r(), C(n ? g : h, [t]), t[Zn] = void 0, S[i] === e && delete S[i]); + }; + let o = t[Zn].bind(null, !1); + S[i] = e, m ? w(m, [t, o]) : o(); + }, + clone(e) { + let a = sr(e, t, n, r, i); + return i && i(a), a; + } + }; + return ee; +} +function cr(e) { + if (yr(e)) return e = ua(e), e.children = null, e; +} +function lr(e) { + if (!yr(e)) return zn(e.type) && e.children ? ir(e.children) : e; + if (e.component) return e.component.subTree; + let { shapeFlag: t, children: n } = e; + if (n) { + if (t & 16) return n[0]; + if (t & 32 && h(n.default)) return n.default(); + } +} +function ur(e, t) { + e.shapeFlag & 6 && e.component ? (e.transition = t, ur(e.component.subTree, t)) : e.shapeFlag & 128 ? (e.ssContent.transition = t.clone(e.ssContent), e.ssFallback.transition = t.clone(e.ssFallback)) : e.transition = t; +} +function dr(e, t = !1, n) { + let r = [], i = 0; + for (let a = 0; a < e.length; a++) { + let o = e[a], s = n == null ? o.key : String(n) + String(o.key == null ? a : o.key); + o.type === V ? (o.patchFlag & 128 && i++, r = r.concat(dr(o.children, t, s))) : (t || o.type !== H) && r.push(s == null ? o : ua(o, { key: s })); + } + if (i > 1) for (let e = 0; e < r.length; e++) r[e].patchFlag = -2; + return r; +} +// @__NO_SIDE_EFFECTS__ +function fr(e, t) { + return h(e) ? /* @__PURE__ */ s({ name: e.name }, t, { setup: e }) : e; +} +function pr(e) { + e.ids = [ + e.ids[0] + e.ids[2]++ + "-", + 0, + 0 + ]; +} +function mr(e, t) { + let n; + return !!((n = Object.getOwnPropertyDescriptor(e, t)) && !n.configurable); +} +var hr = /* @__PURE__ */ new WeakMap(); +function gr(e, n, r, a, o = !1) { + if (d(e)) { + e.forEach((e, t) => gr(e, n && (d(n) ? n[t] : n), r, a, o)); + return; + } + if (vr(a) && !o) { + a.shapeFlag & 512 && a.type.__asyncResolved && a.component.subTree.component && gr(e, n, r, a.component.subTree); + return; + } + let s = a.shapeFlag & 4 ? Ia(a.component) : a.el, l = o ? null : s, { i: f, r: p } = e, m = n && n.r, _ = f.refs === t ? f.refs = {} : f.refs, v = f.setupState, y = /* @__PURE__ */ N(v), b = v === t ? i : (e) => !mr(_, e) && u(y, e), x = (e, t) => !(t && mr(_, t)); + if (m != null && m !== p) { + if (_r(n), g(m)) _[m] = null, b(m) && (v[m] = null); + else if (/* @__PURE__ */ P(m)) { + let e = n; + x(m, e.k) && (m.value = null), e.k && (_[e.k] = null); + } + } + if (h(p)) an(p, f, 12, [l, _]); + else { + let t = g(p), n = /* @__PURE__ */ P(p); + if (t || n) { + let i = () => { + if (e.f) { + let n = t ? b(p) ? v[p] : _[p] : x(p) || !e.k ? p.value : _[e.k]; + if (o) d(n) && c(n, s); + else if (d(n)) n.includes(s) || n.push(s); + else if (t) _[p] = [s], b(p) && (v[p] = _[p]); + else { + let t = [s]; + x(p, e.k) && (p.value = t), e.k && (_[e.k] = t); + } + } else t ? (_[p] = l, b(p) && (v[p] = l)) : n && (x(p, e.k) && (p.value = l), e.k && (_[e.k] = l)); + }; + if (l) { + let t = () => { + i(), hr.delete(e); + }; + t.id = -1, hr.set(e, t), B(t, r); + } else _r(e), i(); + } + } +} +function _r(e) { + let t = hr.get(e); + t && (t.flags |= 8, hr.delete(e)); +} +de().requestIdleCallback, de().cancelIdleCallback; +var vr = (e) => !!e.type.__asyncLoader, yr = (e) => e.type.__isKeepAlive; +function br(e, t) { + Sr(e, "a", t); +} +function xr(e, t) { + Sr(e, "da", t); +} +function Sr(e, t, n = J) { + let r = e.__wdc ||= () => { + let t = n; + for (; t;) { + if (t.isDeactivated) return; + t = t.parent; + } + return e(); + }; + if (wr(t, r, n), n) { + let e = n.parent; + for (; e && e.parent;) yr(e.parent.vnode) && Cr(r, t, n, e), e = e.parent; + } +} +function Cr(e, t, n, r) { + let i = wr(t, e, r, !0); + jr(() => { + c(r[t], i); + }, n); +} +function wr(e, t, n = J, r = !1) { + if (n) { + let i = n[e] || (n[e] = []), a = t.__weh ||= (...r) => { + We(); + let i = wa(n), a = on(t, n, e, r); + return i(), Ge(), a; + }; + return r ? i.unshift(a) : i.push(a), a; + } +} +var Tr = (e) => (t, n = J) => { + (!Da || e === "sp") && wr(e, (...e) => t(...e), n); +}, Er = Tr("bm"), Dr = Tr("m"), Or = Tr("bu"), kr = Tr("u"), Ar = Tr("bum"), jr = Tr("um"), Mr = Tr("sp"), Nr = Tr("rtg"), Pr = Tr("rtc"); +function Fr(e, t = J) { + wr("ec", e, t); +} +var Ir = /* @__PURE__ */ Symbol.for("v-ndc"); +function Lr(e, t, n, r) { + let i, a = n && n[r], o = d(e); + if (o || g(e)) { + let n = o && /* @__PURE__ */ zt(e), r = !1, s = !1; + n && (r = !/* @__PURE__ */ Vt(e), s = /* @__PURE__ */ Bt(e), e = rt(e)), i = Array(e.length); + for (let n = 0, o = e.length; n < o; n++) i[n] = t(r ? s ? Gt(Wt(e[n])) : Wt(e[n]) : e[n], n, void 0, a && a[n]); + } else if (typeof e == "number") { + i = Array(e); + for (let n = 0; n < e; n++) i[n] = t(n + 1, n, void 0, a && a[n]); + } else if (v(e)) if (e[Symbol.iterator]) i = Array.from(e, (e, n) => t(e, n, void 0, a && a[n])); + else { + let n = Object.keys(e); + i = Array(n.length); + for (let r = 0, o = n.length; r < o; r++) { + let o = n[r]; + i[r] = t(e[o], o, r, a && a[r]); + } + } + else i = []; + return n && (n[r] = i), i; +} +var Rr = (e) => e ? Ea(e) ? Ia(e) : Rr(e.parent) : null, zr = /* @__PURE__ */ s(/* @__PURE__ */ Object.create(null), { + $: (e) => e, + $el: (e) => e.vnode.el, + $data: (e) => e.data, + $props: (e) => e.props, + $attrs: (e) => e.attrs, + $slots: (e) => e.slots, + $refs: (e) => e.refs, + $parent: (e) => Rr(e.parent), + $root: (e) => Rr(e.root), + $host: (e) => e.ce, + $emit: (e) => e.emit, + $options: (e) => Jr(e), + $forceUpdate: (e) => e.f ||= () => { + _n(e.update); + }, + $nextTick: (e) => e.n ||= hn.bind(e.proxy), + $watch: (e) => Fn.bind(e) +}), Br = (e, n) => e !== t && !e.__isScriptSetup && u(e, n), Vr = { + get({ _: e }, n) { + if (n === "__v_skip") return !0; + let { ctx: r, setupState: i, data: a, props: o, accessCache: s, type: c, appContext: l } = e; + if (n[0] !== "$") { + let e = s[n]; + if (e !== void 0) switch (e) { + case 1: return i[n]; + case 2: return a[n]; + case 4: return r[n]; + case 3: return o[n]; + } + else if (Br(i, n)) return s[n] = 1, i[n]; + else if (a !== t && u(a, n)) return s[n] = 2, a[n]; + else if (u(o, n)) return s[n] = 3, o[n]; + else if (r !== t && u(r, n)) return s[n] = 4, r[n]; + else Ur && (s[n] = 0); + } + let d = zr[n], f, p; + if (d) return n === "$attrs" && M(e.attrs, "get", ""), d(e); + if ((f = c.__cssModules) && (f = f[n])) return f; + if (r !== t && u(r, n)) return s[n] = 4, r[n]; + if (p = l.config.globalProperties, u(p, n)) return p[n]; + }, + set({ _: e }, n, r) { + let { data: i, setupState: a, ctx: o } = e; + return Br(a, n) ? (a[n] = r, !0) : i !== t && u(i, n) ? (i[n] = r, !0) : u(e.props, n) || n[0] === "$" && n.slice(1) in e ? !1 : (o[n] = r, !0); + }, + has({ _: { data: e, setupState: n, accessCache: r, ctx: i, appContext: a, props: o, type: s } }, c) { + let l; + return !!(r[c] || e !== t && c[0] !== "$" && u(e, c) || Br(n, c) || u(o, c) || u(i, c) || u(zr, c) || u(a.config.globalProperties, c) || (l = s.__cssModules) && l[c]); + }, + defineProperty(e, t, n) { + return n.get == null ? u(n, "value") && this.set(e, t, n.value, null) : e._.accessCache[t] = 0, Reflect.defineProperty(e, t, n); + } +}; +function Hr(e) { + return d(e) ? e.reduce((e, t) => (e[t] = null, e), {}) : e; +} +var Ur = !0; +function Wr(e) { + let t = Jr(e), n = e.proxy, i = e.ctx; + Ur = !1, t.beforeCreate && Kr(t.beforeCreate, e, "bc"); + let { data: a, computed: o, methods: s, watch: c, provide: l, inject: u, created: f, beforeMount: p, mounted: m, beforeUpdate: g, updated: _, activated: y, deactivated: b, beforeDestroy: x, beforeUnmount: S, destroyed: C, unmounted: w, render: ee, renderTracked: te, renderTriggered: ne, errorCaptured: T, serverPrefetch: re, expose: E, inheritAttrs: ie, components: ae, directives: oe, filters: se } = t; + if (u && Gr(u, i, null), s) for (let e in s) { + let t = s[e]; + h(t) && (i[e] = t.bind(n)); + } + if (a) { + let t = a.call(n, n); + v(t) && (e.data = /* @__PURE__ */ Ft(t)); + } + if (Ur = !0, o) for (let e in o) { + let t = o[e], a = Y({ + get: h(t) ? t.bind(n, n) : h(t.get) ? t.get.bind(n, n) : r, + set: !h(t) && h(t.set) ? t.set.bind(n) : r + }); + Object.defineProperty(i, e, { + enumerable: !0, + configurable: !0, + get: () => a.value, + set: (e) => a.value = e + }); + } + if (c) for (let e in c) qr(c[e], i, n, e); + if (l) { + let e = h(l) ? l.call(n) : l; + Reflect.ownKeys(e).forEach((t) => { + kn(t, e[t]); + }); + } + f && Kr(f, e, "c"); + function D(e, t) { + d(t) ? t.forEach((t) => e(t.bind(n))) : t && e(t.bind(n)); + } + if (D(Er, p), D(Dr, m), D(Or, g), D(kr, _), D(br, y), D(xr, b), D(Fr, T), D(Pr, te), D(Nr, ne), D(Ar, S), D(jr, w), D(Mr, re), d(E)) if (E.length) { + let t = e.exposed ||= {}; + E.forEach((e) => { + Object.defineProperty(t, e, { + get: () => n[e], + set: (t) => n[e] = t, + enumerable: !0 + }); + }); + } else e.exposed ||= {}; + ee && e.render === r && (e.render = ee), ie != null && (e.inheritAttrs = ie), ae && (e.components = ae), oe && (e.directives = oe), re && pr(e); +} +function Gr(e, t, n = r) { + d(e) && (e = $r(e)); + for (let n in e) { + let r = e[n], i; + i = v(r) ? "default" in r ? An(r.from || n, r.default, !0) : An(r.from || n) : An(r), /* @__PURE__ */ P(i) ? Object.defineProperty(t, n, { + enumerable: !0, + configurable: !0, + get: () => i.value, + set: (e) => i.value = e + }) : t[n] = i; + } +} +function Kr(e, t, n) { + on(d(e) ? e.map((e) => e.bind(t.proxy)) : e.bind(t.proxy), t, n); +} +function qr(e, t, n, r) { + let i = r.includes(".") ? In(n, r) : () => n[r]; + if (g(e)) { + let n = t[e]; + h(n) && Nn(i, n); + } else if (h(e)) Nn(i, e.bind(n)); + else if (v(e)) if (d(e)) e.forEach((e) => qr(e, t, n, r)); + else { + let r = h(e.handler) ? e.handler.bind(n) : t[e.handler]; + h(r) && Nn(i, r, e); + } +} +function Jr(e) { + let t = e.type, { mixins: n, extends: r } = t, { mixins: i, optionsCache: a, config: { optionMergeStrategies: o } } = e.appContext, s = a.get(t), c; + return s ? c = s : !i.length && !n && !r ? c = t : (c = {}, i.length && i.forEach((e) => Yr(c, e, o, !0)), Yr(c, t, o)), v(t) && a.set(t, c), c; +} +function Yr(e, t, n, r = !1) { + let { mixins: i, extends: a } = t; + a && Yr(e, a, n, !0), i && i.forEach((t) => Yr(e, t, n, !0)); + for (let i in t) if (!(r && i === "expose")) { + let r = Xr[i] || n && n[i]; + e[i] = r ? r(e[i], t[i]) : t[i]; + } + return e; +} +var Xr = { + data: Zr, + props: ti, + emits: ti, + methods: ei, + computed: ei, + beforeCreate: z, + created: z, + beforeMount: z, + mounted: z, + beforeUpdate: z, + updated: z, + beforeDestroy: z, + beforeUnmount: z, + destroyed: z, + unmounted: z, + activated: z, + deactivated: z, + errorCaptured: z, + serverPrefetch: z, + components: ei, + directives: ei, + watch: ni, + provide: Zr, + inject: Qr +}; +function Zr(e, t) { + return t ? e ? function() { + return s(h(e) ? e.call(this, this) : e, h(t) ? t.call(this, this) : t); + } : t : e; +} +function Qr(e, t) { + return ei($r(e), $r(t)); +} +function $r(e) { + if (d(e)) { + let t = {}; + for (let n = 0; n < e.length; n++) t[e[n]] = e[n]; + return t; + } + return e; +} +function z(e, t) { + return e ? [...new Set([].concat(e, t))] : t; +} +function ei(e, t) { + return e ? s(/* @__PURE__ */ Object.create(null), e, t) : t; +} +function ti(e, t) { + return e ? d(e) && d(t) ? [.../* @__PURE__ */ new Set([...e, ...t])] : s(/* @__PURE__ */ Object.create(null), Hr(e), Hr(t ?? {})) : t; +} +function ni(e, t) { + if (!e) return t; + if (!t) return e; + let n = s(/* @__PURE__ */ Object.create(null), e); + for (let r in t) n[r] = z(e[r], t[r]); + return n; +} +function ri() { + return { + app: null, + config: { + isNativeTag: i, + performance: !1, + globalProperties: {}, + optionMergeStrategies: {}, + errorHandler: void 0, + warnHandler: void 0, + compilerOptions: {} + }, + mixins: [], + components: {}, + directives: {}, + provides: /* @__PURE__ */ Object.create(null), + optionsCache: /* @__PURE__ */ new WeakMap(), + propsCache: /* @__PURE__ */ new WeakMap(), + emitsCache: /* @__PURE__ */ new WeakMap() + }; +} +var ii = 0; +function ai(e, t) { + return function(n, r = null) { + h(n) || (n = s({}, n)), r != null && !v(r) && (r = null); + let i = ri(), a = /* @__PURE__ */ new WeakSet(), o = [], c = !1, l = i.app = { + _uid: ii++, + _component: n, + _props: r, + _container: null, + _context: i, + _instance: null, + version: za, + get config() { + return i.config; + }, + set config(e) {}, + use(e, ...t) { + return a.has(e) || (e && h(e.install) ? (a.add(e), e.install(l, ...t)) : h(e) && (a.add(e), e(l, ...t))), l; + }, + mixin(e) { + return i.mixins.includes(e) || i.mixins.push(e), l; + }, + component(e, t) { + return t ? (i.components[e] = t, l) : i.components[e]; + }, + directive(e, t) { + return t ? (i.directives[e] = t, l) : i.directives[e]; + }, + mount(a, o, s) { + if (!c) { + let u = l._ceVNode || K(n, r); + return u.appContext = i, s === !0 ? s = "svg" : s === !1 && (s = void 0), o && t ? t(u, a) : e(u, a, s), c = !0, l._container = a, a.__vue_app__ = l, Ia(u.component); + } + }, + onUnmount(e) { + o.push(e); + }, + unmount() { + c && (on(o, l._instance, 16), e(null, l._container), delete l._container.__vue_app__); + }, + provide(e, t) { + return i.provides[e] = t, l; + }, + runWithContext(e) { + let t = oi; + oi = l; + try { + return e(); + } finally { + oi = t; + } + } + }; + return l; + }; +} +var oi = null, si = (e, t) => t === "modelValue" || t === "model-value" ? e.modelModifiers : e[`${t}Modifiers`] || e[`${T(t)}Modifiers`] || e[`${E(t)}Modifiers`]; +function ci(e, n, ...r) { + if (e.isUnmounted) return; + let i = e.vnode.props || t, a = r, o = n.startsWith("update:"), s = o && si(i, n.slice(7)); + s && (s.trim && (a = r.map((e) => g(e) ? e.trim() : e)), s.number && (a = r.map(ce))); + let c, l = i[c = ae(n)] || i[c = ae(T(n))]; + !l && o && (l = i[c = ae(E(n))]), l && on(l, e, 6, a); + let u = i[c + "Once"]; + if (u) { + if (!e.emitted) e.emitted = {}; + else if (e.emitted[c]) return; + e.emitted[c] = !0, on(u, e, 6, a); + } +} +var li = /* @__PURE__ */ new WeakMap(); +function ui(e, t, n = !1) { + let r = n ? li : t.emitsCache, i = r.get(e); + if (i !== void 0) return i; + let a = e.emits, o = {}, c = !1; + if (!h(e)) { + let r = (e) => { + let n = ui(e, t, !0); + n && (c = !0, s(o, n)); + }; + !n && t.mixins.length && t.mixins.forEach(r), e.extends && r(e.extends), e.mixins && e.mixins.forEach(r); + } + return !a && !c ? (v(e) && r.set(e, null), null) : (d(a) ? a.forEach((e) => o[e] = null) : s(o, a), v(e) && r.set(e, o), o); +} +function di(e, t) { + return !e || !a(t) ? !1 : (t = t.slice(2), t = t === "Once" ? t : t.replace(/Once$/, ""), u(e, t[0].toLowerCase() + t.slice(1)) || u(e, E(t)) || u(e, t)); +} +function fi(e) { + let { type: t, vnode: n, proxy: r, withProxy: i, propsOptions: [a], slots: s, attrs: c, emit: l, render: u, renderCache: d, props: f, data: p, setupState: m, ctx: h, inheritAttrs: g } = e, _ = En(e), v, y; + try { + if (n.shapeFlag & 4) { + let e = i || r, t = e; + v = pa(u.call(t, e, d, f, m, p, h)), y = c; + } else { + let e = t; + v = pa(e.length > 1 ? e(f, { + attrs: c, + slots: s, + emit: l + }) : e(f, null)), y = t.props ? c : pi(c); + } + } catch (t) { + Zi.length = 0, sn(t, e, 1), v = K(H); + } + let b = v; + if (y && g !== !1) { + let e = Object.keys(y), { shapeFlag: t } = b; + e.length && t & 7 && (a && e.some(o) && (y = mi(y, a)), b = ua(b, y, !1, !0)); + } + return n.dirs && (b = ua(b, null, !1, !0), b.dirs = b.dirs ? b.dirs.concat(n.dirs) : n.dirs), n.transition && ur(b, n.transition), v = b, En(_), v; +} +var pi = (e) => { + let t; + for (let n in e) (n === "class" || n === "style" || a(n)) && ((t ||= {})[n] = e[n]); + return t; +}, mi = (e, t) => { + let n = {}; + for (let r in e) (!o(r) || !(r.slice(9) in t)) && (n[r] = e[r]); + return n; +}; +function hi(e, t, n) { + let { props: r, children: i, component: a } = e, { props: o, children: s, patchFlag: c } = t, l = a.emitsOptions; + if (t.dirs || t.transition) return !0; + if (n && c >= 0) { + if (c & 1024) return !0; + if (c & 16) return r ? gi(r, o, l) : !!o; + if (c & 8) { + let e = t.dynamicProps; + for (let t = 0; t < e.length; t++) { + let n = e[t]; + if (_i(o, r, n) && !di(l, n)) return !0; + } + } + } else return (i || s) && (!s || !s.$stable) ? !0 : r === o ? !1 : r ? !o || gi(r, o, l) : !!o; + return !1; +} +function gi(e, t, n) { + let r = Object.keys(t); + if (r.length !== Object.keys(e).length) return !0; + for (let i = 0; i < r.length; i++) { + let a = r[i]; + if (_i(t, e, a) && !di(n, a)) return !0; + } + return !1; +} +function _i(e, t, n) { + let r = e[n], i = t[n]; + return n === "style" && v(r) && v(i) ? !xe(r, i) : r !== i; +} +function vi({ vnode: e, parent: t, suspense: n }, r) { + for (; t;) { + let n = t.subTree; + if (n.suspense && n.suspense.activeBranch === e && (n.suspense.vnode.el = n.el = r, e = n), n === e) (e = t.vnode).el = r, t = t.parent; + else break; + } + n && n.activeBranch === e && (n.vnode.el = r); +} +var yi = {}, bi = () => Object.create(yi), xi = (e) => Object.getPrototypeOf(e) === yi; +function Si(e, t, n, r = !1) { + let i = {}, a = bi(); + e.propsDefaults = /* @__PURE__ */ Object.create(null), wi(e, t, i, a); + for (let t in e.propsOptions[0]) t in i || (i[t] = void 0); + e.props = n ? r ? i : /* @__PURE__ */ It(i) : e.type.props ? i : a, e.attrs = a; +} +function Ci(e, t, n, r) { + let { props: i, attrs: a, vnode: { patchFlag: o } } = e, s = /* @__PURE__ */ N(i), [c] = e.propsOptions, l = !1; + if ((r || o > 0) && !(o & 16)) { + if (o & 8) { + let n = e.vnode.dynamicProps; + for (let r = 0; r < n.length; r++) { + let o = n[r]; + if (di(e.emitsOptions, o)) continue; + let d = t[o]; + if (c) if (u(a, o)) d !== a[o] && (a[o] = d, l = !0); + else { + let t = T(o); + i[t] = Ti(c, s, t, d, e, !1); + } + else d !== a[o] && (a[o] = d, l = !0); + } + } + } else { + wi(e, t, i, a) && (l = !0); + let r; + for (let a in s) (!t || !u(t, a) && ((r = E(a)) === a || !u(t, r))) && (c ? n && (n[a] !== void 0 || n[r] !== void 0) && (i[a] = Ti(c, s, a, void 0, e, !0)) : delete i[a]); + if (a !== s) for (let e in a) (!t || !u(t, e)) && (delete a[e], l = !0); + } + l && tt(e.attrs, "set", ""); +} +function wi(e, n, r, i) { + let [a, o] = e.propsOptions, s = !1, c; + if (n) for (let t in n) { + if (ee(t)) continue; + let l = n[t], d; + a && u(a, d = T(t)) ? !o || !o.includes(d) ? r[d] = l : (c ||= {})[d] = l : di(e.emitsOptions, t) || (!(t in i) || l !== i[t]) && (i[t] = l, s = !0); + } + if (o) { + let n = /* @__PURE__ */ N(r), i = c || t; + for (let t = 0; t < o.length; t++) { + let s = o[t]; + r[s] = Ti(a, n, s, i[s], e, !u(i, s)); + } + } + return s; +} +function Ti(e, t, n, r, i, a) { + let o = e[n]; + if (o != null) { + let e = u(o, "default"); + if (e && r === void 0) { + let e = o.default; + if (o.type !== Function && !o.skipFactory && h(e)) { + let { propsDefaults: a } = i; + if (n in a) r = a[n]; + else { + let o = wa(i); + r = a[n] = e.call(null, t), o(); + } + } else r = e; + i.ce && i.ce._setProp(n, r); + } + o[0] && (a && !e ? r = !1 : o[1] && (r === "" || r === E(n)) && (r = !0)); + } + return r; +} +var Ei = /* @__PURE__ */ new WeakMap(); +function Di(e, r, i = !1) { + let a = i ? Ei : r.propsCache, o = a.get(e); + if (o) return o; + let c = e.props, l = {}, f = [], p = !1; + if (!h(e)) { + let t = (e) => { + p = !0; + let [t, n] = Di(e, r, !0); + s(l, t), n && f.push(...n); + }; + !i && r.mixins.length && r.mixins.forEach(t), e.extends && t(e.extends), e.mixins && e.mixins.forEach(t); + } + if (!c && !p) return v(e) && a.set(e, n), n; + if (d(c)) for (let e = 0; e < c.length; e++) { + let n = T(c[e]); + Oi(n) && (l[n] = t); + } + else if (c) for (let e in c) { + let t = T(e); + if (Oi(t)) { + let n = c[e], r = l[t] = d(n) || h(n) ? { type: n } : s({}, n), i = r.type, a = !1, o = !0; + if (d(i)) for (let e = 0; e < i.length; ++e) { + let t = i[e], n = h(t) && t.name; + if (n === "Boolean") { + a = !0; + break; + } + n === "String" && (o = !1); + } + else a = h(i) && i.name === "Boolean"; + r[0] = a, r[1] = o, (a || u(r, "default")) && f.push(t); + } + } + let m = [l, f]; + return v(e) && a.set(e, m), m; +} +function Oi(e) { + return e[0] !== "$" && !ee(e); +} +var ki = (e) => e === "_" || e === "_ctx" || e === "$stable", Ai = (e) => d(e) ? e.map(pa) : [pa(e)], ji = (e, t, n) => { + if (t._n) return t; + let r = Dn((...e) => Ai(t(...e)), n); + return r._c = !1, r; +}, Mi = (e, t, n) => { + let r = e._ctx; + for (let n in e) { + if (ki(n)) continue; + let i = e[n]; + if (h(i)) t[n] = ji(n, i, r); + else if (i != null) { + let e = Ai(i); + t[n] = () => e; + } + } +}, Ni = (e, t) => { + let n = Ai(t); + e.slots.default = () => n; +}, Pi = (e, t, n) => { + for (let r in t) (n || !ki(r)) && (e[r] = t[r]); +}, Fi = (e, t, n) => { + let r = e.slots = bi(); + if (e.vnode.shapeFlag & 32) { + let e = t._; + e ? (Pi(r, t, n), n && D(r, "_", e, !0)) : Mi(t, r); + } else t && Ni(e, t); +}, Ii = (e, n, r) => { + let { vnode: i, slots: a } = e, o = !0, s = t; + if (i.shapeFlag & 32) { + let e = n._; + e ? r && e === 1 ? o = !1 : Pi(a, n, r) : (o = !n.$stable, Mi(n, a)), s = n; + } else n && (Ni(e, n), s = { default: 1 }); + if (o) for (let e in a) !ki(e) && s[e] == null && delete a[e]; +}, B = Ji; +function Li(e) { + return Ri(e); +} +function Ri(e, i) { + let a = de(); + a.__VUE__ = !0; + let { insert: o, remove: s, patchProp: c, createElement: l, createText: u, createComment: d, setText: f, setElementText: p, parentNode: m, nextSibling: h, setScopeId: g = r, insertStaticContent: _ } = e, v = (e, t, n, r = null, i = null, a = null, o = void 0, s = null, c = !!t.dynamicChildren) => { + if (e === t) return; + e && !aa(e, t) && (r = be(e), ge(e, i, a, !0), e = null), t.patchFlag === -2 && (c = !1, t.dynamicChildren = null); + let { type: l, ref: u, shapeFlag: d } = t; + switch (l) { + case Yi: + y(e, t, n, r); + break; + case H: + b(e, t, n, r); + break; + case Xi: + e ?? x(t, n, r, o); + break; + case V: + ae(e, t, n, r, i, a, o, s, c); + break; + default: d & 1 ? w(e, t, n, r, i, a, o, s, c) : d & 6 ? oe(e, t, n, r, i, a, o, s, c) : (d & 64 || d & 128) && l.process(e, t, n, r, i, a, o, s, c, Ce); + } + u != null && i ? gr(u, e && e.ref, a, t || e, !t) : u == null && e && e.ref != null && gr(e.ref, null, a, e, !0); + }, y = (e, t, n, r) => { + if (e == null) o(t.el = u(t.children), n, r); + else { + let n = t.el = e.el; + t.children !== e.children && f(n, t.children); + } + }, b = (e, t, n, r) => { + e == null ? o(t.el = d(t.children || ""), n, r) : t.el = e.el; + }, x = (e, t, n, r) => { + [e.el, e.anchor] = _(e.children, t, n, r, e.el, e.anchor); + }, S = ({ el: e, anchor: t }, n, r) => { + let i; + for (; e && e !== t;) i = h(e), o(e, n, r), e = i; + o(t, n, r); + }, C = ({ el: e, anchor: t }) => { + let n; + for (; e && e !== t;) n = h(e), s(e), e = n; + s(t); + }, w = (e, t, n, r, i, a, o, s, c) => { + if (t.type === "svg" ? o = "svg" : t.type === "math" && (o = "mathml"), e == null) te(t, n, r, i, a, o, s, c); + else { + let n = e.el && e.el._isVueCE ? e.el : null; + try { + n && n._beginPatch(), re(e, t, i, a, o, s, c); + } finally { + n && n._endPatch(); + } + } + }, te = (e, t, n, r, i, a, s, u) => { + let d, f, { props: m, shapeFlag: h, transition: g, dirs: _ } = e; + if (d = e.el = l(e.type, a, m && m.is, m), h & 8 ? p(d, e.children) : h & 16 && T(e.children, d, null, r, i, zi(e, a), s, u), _ && On(e, null, r, "created"), ne(d, e, e.scopeId, s, r), m) { + for (let e in m) e !== "value" && !ee(e) && c(d, e, null, m[e], a, r); + "value" in m && c(d, "value", null, m.value, a), (f = m.onVnodeBeforeMount) && _a(f, r, e); + } + _ && On(e, null, r, "beforeMount"); + let v = Vi(i, g); + v && g.beforeEnter(d), o(d, t, n), ((f = m && m.onVnodeMounted) || v || _) && B(() => { + try { + f && _a(f, r, e), v && g.enter(d), _ && On(e, null, r, "mounted"); + } finally {} + }, i); + }, ne = (e, t, n, r, i) => { + if (n && g(e, n), r) for (let t = 0; t < r.length; t++) g(e, r[t]); + if (i) { + let n = i.subTree; + if (t === n || qi(n.type) && (n.ssContent === t || n.ssFallback === t)) { + let t = i.vnode; + ne(e, t, t.scopeId, t.slotScopeIds, i.parent); + } + } + }, T = (e, t, n, r, i, a, o, s, c = 0) => { + for (let l = c; l < e.length; l++) { + let c = e[l] = s ? ma(e[l]) : pa(e[l]); + v(null, c, t, n, r, i, a, o, s); + } + }, re = (e, n, r, i, a, o, s) => { + let l = n.el = e.el, { patchFlag: u, dynamicChildren: d, dirs: f } = n; + u |= e.patchFlag & 16; + let m = e.props || t, h = n.props || t, g; + if (r && Bi(r, !1), (g = h.onVnodeBeforeUpdate) && _a(g, r, n, e), f && On(n, e, r, "beforeUpdate"), r && Bi(r, !0), d && (!e.dynamicChildren || e.dynamicChildren.length !== d.length) && (u = 0, s = !1, d = null), (m.innerHTML && h.innerHTML == null || m.textContent && h.textContent == null) && p(l, ""), d ? E(e.dynamicChildren, d, l, r, i, zi(n, a), o) : s || fe(e, n, l, null, r, i, zi(n, a), o, !1), u > 0) { + if (u & 16) ie(l, m, h, r, a); + else if (u & 2 && m.class !== h.class && c(l, "class", null, h.class, a), u & 4 && c(l, "style", m.style, h.style, a), u & 8) { + let e = n.dynamicProps; + for (let t = 0; t < e.length; t++) { + let n = e[t], i = m[n], o = h[n]; + (o !== i || n === "value") && c(l, n, i, o, a, r); + } + } + u & 1 && e.children !== n.children && p(l, n.children); + } else !s && d == null && ie(l, m, h, r, a); + ((g = h.onVnodeUpdated) || f) && B(() => { + g && _a(g, r, n, e), f && On(n, e, r, "updated"); + }, i); + }, E = (e, t, n, r, i, a, o) => { + for (let s = 0; s < t.length; s++) { + let c = e[s], l = t[s], u = c.el && (c.type === V || !aa(c, l) || c.shapeFlag & 198) ? m(c.el) : n; + v(c, l, u, null, r, i, a, o, !0); + } + }, ie = (e, n, r, i, a) => { + if (n !== r) { + if (n !== t) for (let t in n) !ee(t) && !(t in r) && c(e, t, n[t], null, a, i); + for (let t in r) { + if (ee(t)) continue; + let o = r[t], s = n[t]; + o !== s && t !== "value" && c(e, t, s, o, a, i); + } + "value" in r && c(e, "value", n.value, r.value, a); + } + }, ae = (e, t, n, r, i, a, s, c, l) => { + let d = t.el = e ? e.el : u(""), f = t.anchor = e ? e.anchor : u(""), { patchFlag: p, dynamicChildren: m, slotScopeIds: h } = t; + h && (c = c ? c.concat(h) : h), e == null ? (o(d, n, r), o(f, n, r), T(t.children || [], n, f, i, a, s, c, l)) : p > 0 && p & 64 && m && e.dynamicChildren && e.dynamicChildren.length === m.length ? (E(e.dynamicChildren, m, n, i, a, s, c), (t.key != null || i && t === i.subTree) && Hi(e, t, !0)) : fe(e, t, n, f, i, a, s, c, l); + }, oe = (e, t, n, r, i, a, o, s, c) => { + t.slotScopeIds = s, e == null ? t.shapeFlag & 512 ? i.ctx.activate(t, n, r, o, c) : D(t, n, r, i, a, o, c) : ce(e, t, c); + }, D = (e, t, n, r, i, a, o) => { + let s = e.component = ba(e, r, i); + if (yr(e) && (s.ctx.renderer = Ce), Oa(s, !1, o), s.asyncDep) { + if (i && i.registerDep(s, le, o), !e.el) { + let r = s.subTree = K(H); + b(null, r, t, n), e.placeholder = r.el; + } + } else le(s, e, t, n, i, a, o); + }, ce = (e, t, n) => { + let r = t.component = e.component; + if (hi(e, t, n)) if (r.asyncDep && !r.asyncResolved) { + ue(r, t, n); + return; + } else r.next = t, r.update(); + else t.el = e.el, r.vnode = t; + }, le = (e, t, n, r, i, a, o) => { + let s = () => { + if (e.isMounted) { + let { next: t, bu: n, u: r, parent: s, vnode: c } = e; + { + let n = Wi(e); + if (n) { + t && (t.el = c.el, ue(e, t, o)), n.asyncDep.then(() => { + B(() => { + e.isUnmounted || l(); + }, i); + }); + return; + } + } + let u = t, d; + Bi(e, !1), t ? (t.el = c.el, ue(e, t, o)) : t = c, n && se(n), (d = t.props && t.props.onVnodeBeforeUpdate) && _a(d, s, t, c), Bi(e, !0); + let f = fi(e), p = e.subTree; + e.subTree = f, v(p, f, m(p.el), be(p), e, i, a), t.el = f.el, u === null && vi(e, f.el), r && B(r, i), (d = t.props && t.props.onVnodeUpdated) && B(() => _a(d, s, t, c), i); + } else { + let o, { el: s, props: c } = t, { bm: l, m: u, parent: d, root: f, type: p } = e, m = vr(t); + if (Bi(e, !1), l && se(l), !m && (o = c && c.onVnodeBeforeMount) && _a(o, d, t), Bi(e, !0), s && we) { + let t = () => { + e.subTree = fi(e), we(s, e.subTree, e, i, null); + }; + m && p.__asyncHydrate ? p.__asyncHydrate(s, e, t) : t(); + } else { + f.ce && f.ce._hasShadowRoot() && f.ce._injectChildStyle(p, e.parent ? e.parent.type : void 0); + let o = e.subTree = fi(e); + v(null, o, n, r, e, i, a), t.el = o.el; + } + if (u && B(u, i), !m && (o = c && c.onVnodeMounted)) { + let e = t; + B(() => _a(o, d, e), i); + } + (t.shapeFlag & 256 || d && vr(d.vnode) && d.vnode.shapeFlag & 256) && e.a && B(e.a, i), e.isMounted = !0, t = n = r = null; + } + }; + e.scope.on(); + let c = e.effect = new ke(s); + e.scope.off(); + let l = e.update = c.run.bind(c), u = e.job = c.runIfDirty.bind(c); + u.i = e, u.id = e.uid, c.scheduler = () => _n(u), Bi(e, !0), l(); + }, ue = (e, t, n) => { + t.component = e; + let r = e.vnode.props; + e.vnode = t, e.next = null, Ci(e, t.props, r, n), Ii(e, t.children, n), We(), bn(e), Ge(); + }, fe = (e, t, n, r, i, a, o, s, c = !1) => { + let l = e && e.children, u = e ? e.shapeFlag : 0, d = t.children, { patchFlag: f, shapeFlag: m } = t; + if (f > 0) { + if (f & 128) { + me(l, d, n, r, i, a, o, s, c); + return; + } + if (f & 256) { + pe(l, d, n, r, i, a, o, s, c); + return; + } + } + m & 8 ? (u & 16 && ye(l, i, a), d !== l && p(n, d)) : u & 16 ? m & 16 ? me(l, d, n, r, i, a, o, s, c) : ye(l, i, a, !0) : (u & 8 && p(n, ""), m & 16 && T(d, n, r, i, a, o, s, c)); + }, pe = (e, t, r, i, a, o, s, c, l) => { + e ||= n, t ||= n; + let u = e.length, d = t.length, f = Math.min(u, d), p; + for (p = 0; p < f; p++) { + let n = t[p] = l ? ma(t[p]) : pa(t[p]); + v(e[p], n, r, null, a, o, s, c, l); + } + u > d ? ye(e, a, o, !0, !1, f) : T(t, r, i, a, o, s, c, l, f); + }, me = (e, t, r, i, a, o, s, c, l) => { + let u = 0, d = t.length, f = e.length - 1, p = d - 1; + for (; u <= f && u <= p;) { + let n = e[u], i = t[u] = l ? ma(t[u]) : pa(t[u]); + if (aa(n, i)) v(n, i, r, null, a, o, s, c, l); + else break; + u++; + } + for (; u <= f && u <= p;) { + let n = e[f], i = t[p] = l ? ma(t[p]) : pa(t[p]); + if (aa(n, i)) v(n, i, r, null, a, o, s, c, l); + else break; + f--, p--; + } + if (u > f) { + if (u <= p) { + let e = p + 1, n = e < d ? t[e].el : i; + for (; u <= p;) v(null, t[u] = l ? ma(t[u]) : pa(t[u]), r, n, a, o, s, c, l), u++; + } + } else if (u > p) for (; u <= f;) ge(e[u], a, o, !0), u++; + else { + let m = u, h = u, g = /* @__PURE__ */ new Map(); + for (u = h; u <= p; u++) { + let e = t[u] = l ? ma(t[u]) : pa(t[u]); + e.key != null && g.set(e.key, u); + } + let _, y = 0, b = p - h + 1, x = !1, S = 0, C = Array(b); + for (u = 0; u < b; u++) C[u] = 0; + for (u = m; u <= f; u++) { + let n = e[u]; + if (y >= b) { + ge(n, a, o, !0); + continue; + } + let i; + if (n.key != null) i = g.get(n.key); + else for (_ = h; _ <= p; _++) if (C[_ - h] === 0 && aa(n, t[_])) { + i = _; + break; + } + i === void 0 ? ge(n, a, o, !0) : (C[i - h] = u + 1, i >= S ? S = i : x = !0, v(n, t[i], r, null, a, o, s, c, l), y++); + } + let w = x ? Ui(C) : n; + for (_ = w.length - 1, u = b - 1; u >= 0; u--) { + let e = h + u, n = t[e], f = t[e + 1], p = e + 1 < d ? f.el || Ki(f) : i; + C[u] === 0 ? v(null, n, r, p, a, o, s, c, l) : x && (_ < 0 || u !== w[_] ? he(n, r, p, 2) : _--); + } + } + }, he = (e, t, n, r, i = null) => { + let { el: a, type: c, transition: l, children: u, shapeFlag: d } = e; + if (d & 6) { + he(e.component.subTree, t, n, r); + return; + } + if (d & 128) { + e.suspense.move(t, n, r); + return; + } + if (d & 64) { + c.move(e, t, n, Ce); + return; + } + if (c === V) { + o(a, t, n); + for (let e = 0; e < u.length; e++) he(u[e], t, n, r); + o(e.anchor, t, n); + return; + } + if (c === Xi) { + S(e, t, n); + return; + } + if (r !== 2 && d & 1 && l) if (r === 0) l.persisted && !a[Zn] ? o(a, t, n) : (l.beforeEnter(a), o(a, t, n), B(() => l.enter(a), i)); + else { + let { leave: r, delayLeave: i, afterLeave: c } = l, u = () => { + e.ctx.isUnmounted ? s(a) : o(a, t, n); + }, d = () => { + let e = a._isLeaving || !!a[Zn]; + a._isLeaving && a[Zn](!0), l.persisted && !e ? u() : r(a, () => { + u(), c && c(); + }); + }; + i ? i(a, u, d) : d(); + } + else o(a, t, n); + }, ge = (e, t, n, r = !1, i = !1) => { + let { type: a, props: o, ref: s, children: c, dynamicChildren: l, shapeFlag: u, patchFlag: d, dirs: f, cacheIndex: p, memo: m } = e; + if (d === -2 && (i = !1), s != null && (We(), gr(s, null, n, e, !0), Ge()), p != null && (t.renderCache[p] = void 0), u & 256) { + t.ctx.deactivate(e); + return; + } + let h = u & 1 && f, g = !vr(e), _; + if (g && (_ = o && o.onVnodeBeforeUnmount) && _a(_, t, e), u & 6) ve(e.component, n, r); + else { + if (u & 128) { + e.suspense.unmount(n, r); + return; + } + h && On(e, null, t, "beforeUnmount"), u & 64 ? e.type.remove(e, t, n, Ce, r) : l && !l.hasOnce && (a !== V || d > 0 && d & 64) ? ye(l, t, n, !1, !0) : (a === V && d & 384 || !i && u & 16) && ye(c, t, n), r && O(e); + } + let v = m != null && p == null; + (g && (_ = o && o.onVnodeUnmounted) || h || v) && B(() => { + _ && _a(_, t, e), h && On(e, null, t, "unmounted"), v && (e.el = null); + }, n); + }, O = (e) => { + let { type: t, el: n, anchor: r, transition: i } = e; + if (t === V) { + _e(n, r); + return; + } + if (t === Xi) { + C(e); + return; + } + let a = () => { + s(n), i && !i.persisted && i.afterLeave && i.afterLeave(); + }; + if (e.shapeFlag & 1 && i && !i.persisted) { + let { leave: t, delayLeave: r } = i, o = () => t(n, a); + r ? r(e.el, a, o) : o(); + } else a(); + }, _e = (e, t) => { + let n; + for (; e !== t;) n = h(e), s(e), e = n; + s(t); + }, ve = (e, t, n) => { + let { bum: r, scope: i, job: a, subTree: o, um: s, m: c, a: l } = e; + Gi(c), Gi(l), r && se(r), i.stop(), a && (a.flags |= 8, ge(o, e, t, n)), s && B(s, t), B(() => { + e.isUnmounted = !0; + }, t); + }, ye = (e, t, n, r = !1, i = !1, a = 0) => { + for (let o = a; o < e.length; o++) ge(e[o], t, n, r, i); + }, be = (e) => { + if (e.shapeFlag & 6) return be(e.component.subTree); + if (e.shapeFlag & 128) return e.suspense.next(); + let t = h(e.anchor || e.el), n = t && t[Rn]; + return n ? h(n) : t; + }, xe = !1, Se = (e, t, n) => { + let r; + e == null ? t._vnode && (ge(t._vnode, null, null, !0), r = t._vnode.component) : v(t._vnode || null, e, t, null, null, null, n), t._vnode = e, xe ||= (xe = !0, bn(r), xn(), !1); + }, Ce = { + p: v, + um: ge, + m: he, + r: O, + mt: D, + mc: T, + pc: fe, + pbc: E, + n: be, + o: e + }, k, we; + return i && ([k, we] = i(Ce)), { + render: Se, + hydrate: k, + createApp: ai(Se, k) + }; +} +function zi({ type: e, props: t }, n) { + return n === "svg" && e === "foreignObject" || n === "mathml" && e === "annotation-xml" && t && t.encoding && t.encoding.includes("html") ? void 0 : n; +} +function Bi({ effect: e, job: t }, n) { + n ? (e.flags |= 32, t.flags |= 4) : (e.flags &= -33, t.flags &= -5); +} +function Vi(e, t) { + return (!e || e && !e.pendingBranch) && t && !t.persisted; +} +function Hi(e, t, n = !1) { + let r = e.children, i = t.children; + if (d(r) && d(i)) for (let e = 0; e < r.length; e++) { + let t = r[e], a = i[e]; + a.shapeFlag & 1 && !a.dynamicChildren && ((a.patchFlag <= 0 || a.patchFlag === 32) && (a = i[e] = ma(i[e]), a.el = t.el), !n && a.patchFlag !== -2 && Hi(t, a)), a.type === Yi && (a.patchFlag === -1 && (a = i[e] = ma(a)), a.el = t.el), a.type === H && !a.el && (a.el = t.el); + } +} +function Ui(e) { + let t = e.slice(), n = [0], r, i, a, o, s, c = e.length; + for (r = 0; r < c; r++) { + let c = e[r]; + if (c !== 0) { + if (i = n[n.length - 1], e[i] < c) { + t[r] = i, n.push(r); + continue; + } + for (a = 0, o = n.length - 1; a < o;) s = a + o >> 1, e[n[s]] < c ? a = s + 1 : o = s; + c < e[n[a]] && (a > 0 && (t[r] = n[a - 1]), n[a] = r); + } + } + for (a = n.length, o = n[a - 1]; a-- > 0;) n[a] = o, o = t[o]; + return n; +} +function Wi(e) { + let t = e.subTree.component; + if (t) return t.asyncDep && !t.asyncResolved ? t : Wi(t); +} +function Gi(e) { + if (e) for (let t = 0; t < e.length; t++) e[t].flags |= 8; +} +function Ki(e) { + if (e.placeholder) return e.placeholder; + let t = e.component; + return t ? Ki(t.subTree) : null; +} +var qi = (e) => e.__isSuspense; +function Ji(e, t) { + t && t.pendingBranch ? d(e) ? t.effects.push(...e) : t.effects.push(e) : yn(e); +} +var V = /* @__PURE__ */ Symbol.for("v-fgt"), Yi = /* @__PURE__ */ Symbol.for("v-txt"), H = /* @__PURE__ */ Symbol.for("v-cmt"), Xi = /* @__PURE__ */ Symbol.for("v-stc"), Zi = [], Qi = null; +function U(e = !1) { + Zi.push(Qi = e ? null : []); +} +function $i() { + Zi.pop(), Qi = Zi[Zi.length - 1] || null; +} +var ea = 1; +function ta(e, t = !1) { + ea += e, e < 0 && Qi && t && (Qi.hasOnce = !0); +} +function na(e) { + return e.dynamicChildren = ea > 0 ? Qi || n : null, $i(), ea > 0 && Qi && Qi.push(e), e; +} +function W(e, t, n, r, i, a) { + return na(G(e, t, n, r, i, a, !0)); +} +function ra(e, t, n, r, i) { + return na(K(e, t, n, r, i, !0)); +} +function ia(e) { + return e ? e.__v_isVNode === !0 : !1; +} +function aa(e, t) { + return e.type === t.type && e.key === t.key; +} +var oa = ({ key: e }) => e ?? null, sa = ({ ref: e, ref_key: t, ref_for: n }) => (typeof e == "number" && (e = "" + e), e == null ? null : g(e) || /* @__PURE__ */ P(e) || h(e) ? { + i: wn, + r: e, + k: t, + f: !!n +} : e); +function G(e, t = null, n = null, r = 0, i = null, a = e === V ? 0 : 1, o = !1, s = !1) { + let c = { + __v_isVNode: !0, + __v_skip: !0, + type: e, + props: t, + key: t && oa(t), + ref: t && sa(t), + scopeId: Tn, + slotScopeIds: null, + children: n, + component: null, + suspense: null, + ssContent: null, + ssFallback: null, + dirs: null, + transition: null, + el: null, + anchor: null, + target: null, + targetStart: null, + targetAnchor: null, + staticCount: 0, + shapeFlag: a, + patchFlag: r, + dynamicProps: i, + dynamicChildren: null, + appContext: null, + ctx: wn + }; + return s ? (ha(c, n), a & 128 && e.normalize(c)) : n && (c.shapeFlag |= g(n) ? 8 : 16), ea > 0 && !o && Qi && (c.patchFlag > 0 || a & 6) && c.patchFlag !== 32 && Qi.push(c), c; +} +var K = ca; +function ca(e, t = null, n = null, r = 0, i = null, a = !1) { + if ((!e || e === Ir) && (e = H), ia(e)) { + let r = ua(e, t, !0); + return n && ha(r, n), ea > 0 && !a && Qi && (r.shapeFlag & 6 ? Qi[Qi.indexOf(e)] = r : Qi.push(r)), r.patchFlag = -2, r; + } + if (La(e) && (e = e.__vccOpts), t) { + t = la(t); + let { class: e, style: n } = t; + e && !g(e) && (t.class = O(e)), v(n) && (/* @__PURE__ */ Ht(n) && !d(n) && (n = s({}, n)), t.style = fe(n)); + } + let o = g(e) ? 1 : qi(e) ? 128 : zn(e) ? 64 : v(e) ? 4 : h(e) ? 2 : 0; + return G(e, t, n, r, i, o, a, !0); +} +function la(e) { + return e ? /* @__PURE__ */ Ht(e) || xi(e) ? s({}, e) : e : null; +} +function ua(e, t, n = !1, r = !1) { + let { props: i, ref: a, patchFlag: o, children: s, transition: c } = e, l = t ? ga(i || {}, t) : i, u = { + __v_isVNode: !0, + __v_skip: !0, + type: e.type, + props: l, + key: l && oa(l), + ref: t && t.ref ? n && a ? d(a) ? a.concat(sa(t)) : [a, sa(t)] : sa(t) : a, + scopeId: e.scopeId, + slotScopeIds: e.slotScopeIds, + children: s, + target: e.target, + targetStart: e.targetStart, + targetAnchor: e.targetAnchor, + staticCount: e.staticCount, + shapeFlag: e.shapeFlag, + patchFlag: t && e.type !== V ? o === -1 ? 16 : o | 16 : o, + dynamicProps: e.dynamicProps, + dynamicChildren: e.dynamicChildren, + appContext: e.appContext, + dirs: e.dirs, + transition: c, + component: e.component, + suspense: e.suspense, + ssContent: e.ssContent && ua(e.ssContent), + ssFallback: e.ssFallback && ua(e.ssFallback), + placeholder: e.placeholder, + el: e.el, + anchor: e.anchor, + ctx: e.ctx, + ce: e.ce + }; + return c && r && ur(u, c.clone(u)), u; +} +function da(e = " ", t = 0) { + return K(Yi, null, e, t); +} +function fa(e, t) { + let n = K(Xi, null, e); + return n.staticCount = t, n; +} +function q(e = "", t = !1) { + return t ? (U(), ra(H, null, e)) : K(H, null, e); +} +function pa(e) { + return e == null || typeof e == "boolean" ? K(H) : d(e) ? K(V, null, e.slice()) : ia(e) ? ma(e) : K(Yi, null, String(e)); +} +function ma(e) { + return e.el === null && e.patchFlag !== -1 || e.memo ? e : ua(e); +} +function ha(e, t) { + let n = 0, { shapeFlag: r } = e; + if (t == null) t = null; + else if (d(t)) n = 16; + else if (typeof t == "object") if (r & 65) { + let n = t.default; + n && (n._c && (n._d = !1), ha(e, n()), n._c && (n._d = !0)); + return; + } else { + n = 32; + let r = t._; + !r && !xi(t) ? t._ctx = wn : r === 3 && wn && (wn.slots._ === 1 ? t._ = 1 : (t._ = 2, e.patchFlag |= 1024)); + } + else if (h(t)) { + if (r & 65) { + ha(e, { default: t }); + return; + } + t = { + default: t, + _ctx: wn + }, n = 32; + } else t = String(t), r & 64 ? (n = 16, t = [da(t)]) : n = 8; + e.children = t, e.shapeFlag |= n; +} +function ga(...e) { + let t = {}; + for (let n = 0; n < e.length; n++) { + let r = e[n]; + for (let e in r) if (e === "class") t.class !== r.class && (t.class = O([t.class, r.class])); + else if (e === "style") t.style = fe([t.style, r.style]); + else if (a(e)) { + let n = t[e], i = r[e]; + i && n !== i && !(d(n) && n.includes(i)) ? t[e] = n ? [].concat(n, i) : i : i == null && n == null && !o(e) && (t[e] = i); + } else e !== "" && (t[e] = r[e]); + } + return t; +} +function _a(e, t, n, r = null) { + on(e, t, 7, [n, r]); +} +var va = ri(), ya = 0; +function ba(e, n, r) { + let i = e.type, a = (n ? n.appContext : e.appContext) || va, o = { + uid: ya++, + vnode: e, + type: i, + parent: n, + appContext: a, + root: null, + next: null, + subTree: null, + effect: null, + update: null, + job: null, + scope: new Ee(!0), + render: null, + proxy: null, + exposed: null, + exposeProxy: null, + withProxy: null, + provides: n ? n.provides : Object.create(a.provides), + ids: n ? n.ids : [ + "", + 0, + 0 + ], + accessCache: null, + renderCache: [], + components: null, + directives: null, + propsOptions: Di(i, a), + emitsOptions: ui(i, a), + emit: null, + emitted: null, + propsDefaults: t, + inheritAttrs: i.inheritAttrs, + ctx: t, + data: t, + props: t, + attrs: t, + slots: t, + refs: t, + setupState: t, + setupContext: null, + suspense: r, + suspenseId: r ? r.pendingId : 0, + asyncDep: null, + asyncResolved: !1, + isMounted: !1, + isUnmounted: !1, + isDeactivated: !1, + bc: null, + c: null, + bm: null, + m: null, + bu: null, + u: null, + um: null, + bum: null, + da: null, + a: null, + rtg: null, + rtc: null, + ec: null, + sp: null + }; + return o.ctx = { _: o }, o.root = n ? n.root : o, o.emit = ci.bind(null, o), e.ce && e.ce(o), o; +} +var J = null, xa = () => J || wn, Sa, Ca; +{ + let e = de(), t = (t, n) => { + let r; + return (r = e[t]) || (r = e[t] = []), r.push(n), (e) => { + r.length > 1 ? r.forEach((t) => t(e)) : r[0](e); + }; + }; + Sa = t("__VUE_INSTANCE_SETTERS__", (e) => J = e), Ca = t("__VUE_SSR_SETTERS__", (e) => Da = e); +} +var wa = (e) => { + let t = J; + return Sa(e), e.scope.on(), () => { + e.scope.off(), Sa(t); + }; +}, Ta = () => { + J && J.scope.off(), Sa(null); +}; +function Ea(e) { + return e.vnode.shapeFlag & 4; +} +var Da = !1; +function Oa(e, t = !1, n = !1) { + t && Ca(t); + let { props: r, children: i } = e.vnode, a = Ea(e); + Si(e, r, a, t), Fi(e, i, n || t); + let o = a ? ka(e, t) : void 0; + return t && Ca(!1), o; +} +function ka(e, t) { + let n = e.type; + e.accessCache = /* @__PURE__ */ Object.create(null), e.proxy = new Proxy(e.ctx, Vr); + let { setup: r } = n; + if (r) { + We(); + let n = e.setupContext = r.length > 1 ? Fa(e) : null, i = wa(e), a = an(r, e, 0, [e.props, n]), o = y(a); + if (Ge(), i(), (o || e.sp) && !vr(e) && pr(e), o) { + if (a.then(Ta, Ta), t) return a.then((n) => { + Aa(e, n, t); + }).catch((t) => { + sn(t, e, 0); + }); + e.asyncDep = a; + } else Aa(e, a, t); + } else Na(e, t); +} +function Aa(e, t, n) { + h(t) ? e.type.__ssrInlineRender ? e.ssrRender = t : e.render = t : v(t) && (e.setupState = Yt(t)), Na(e, n); +} +var ja, Ma; +function Na(e, t, n) { + let i = e.type; + if (!e.render) { + if (!t && ja && !i.render) { + let t = i.template || Jr(e).template; + if (t) { + let { isCustomElement: n, compilerOptions: r } = e.appContext.config, { delimiters: a, compilerOptions: o } = i; + i.render = ja(t, s(s({ + isCustomElement: n, + delimiters: a + }, r), o)); + } + } + e.render = i.render || r, Ma && Ma(e); + } + { + let t = wa(e); + We(); + try { + Wr(e); + } finally { + Ge(), t(); + } + } +} +var Pa = { get(e, t) { + return M(e, "get", ""), e[t]; +} }; +function Fa(e) { + return { + attrs: new Proxy(e.attrs, Pa), + slots: e.slots, + emit: e.emit, + expose: (t) => { + e.exposed = t || {}; + } + }; +} +function Ia(e) { + return e.exposed ? e.exposeProxy ||= new Proxy(Yt(Ut(e.exposed)), { + get(t, n) { + if (n in t) return t[n]; + if (n in zr) return zr[n](e); + }, + has(e, t) { + return t in e || t in zr; + } + }) : e.proxy; +} +function La(e) { + return h(e) && "__vccOpts" in e; +} +var Y = (e, t) => /* @__PURE__ */ Zt(e, t, Da); +function Ra(e, t, n) { + try { + ta(-1); + let r = arguments.length; + return r === 2 ? v(t) && !d(t) ? ia(t) ? K(e, null, [t]) : K(e, t) : K(e, null, t) : (r > 3 ? n = Array.prototype.slice.call(arguments, 2) : r === 3 && ia(n) && (n = [n]), K(e, t, n)); + } finally { + ta(1); + } +} +var za = "3.5.40", Ba = void 0, Va = typeof window < "u" && window.trustedTypes; +if (Va) try { + Ba = /* @__PURE__ */ Va.createPolicy("vue", { createHTML: (e) => e }); +} catch {} +var Ha = Ba ? (e) => Ba.createHTML(e) : (e) => e, Ua = "http://www.w3.org/2000/svg", Wa = "http://www.w3.org/1998/Math/MathML", Ga = typeof document < "u" ? document : null, Ka = Ga && /* @__PURE__ */ Ga.createElement("template"), qa = { + insert: (e, t, n) => { + t.insertBefore(e, n || null); + }, + remove: (e) => { + let t = e.parentNode; + t && t.removeChild(e); + }, + createElement: (e, t, n, r) => { + let i = t === "svg" ? Ga.createElementNS(Ua, e) : t === "mathml" ? Ga.createElementNS(Wa, e) : n ? Ga.createElement(e, { is: n }) : Ga.createElement(e); + return e === "select" && r && r.multiple != null && i.setAttribute("multiple", r.multiple), i; + }, + createText: (e) => Ga.createTextNode(e), + createComment: (e) => Ga.createComment(e), + setText: (e, t) => { + e.nodeValue = t; + }, + setElementText: (e, t) => { + e.textContent = t; + }, + parentNode: (e) => e.parentNode, + nextSibling: (e) => e.nextSibling, + querySelector: (e) => Ga.querySelector(e), + setScopeId(e, t) { + e.setAttribute(t, ""); + }, + insertStaticContent(e, t, n, r, i, a) { + let o = n ? n.previousSibling : t.lastChild; + if (i && (i === a || i.nextSibling)) for (; t.insertBefore(i.cloneNode(!0), n), !(i === a || !(i = i.nextSibling));); + else { + Ka.innerHTML = Ha(r === "svg" ? `${e}` : r === "mathml" ? `${e}` : e); + let i = Ka.content; + if (r === "svg" || r === "mathml") { + let e = i.firstChild; + for (; e.firstChild;) i.appendChild(e.firstChild); + i.removeChild(e); + } + t.insertBefore(i, n); + } + return [o ? o.nextSibling : t.firstChild, n ? n.previousSibling : t.lastChild]; + } +}, Ja = "transition", Ya = "animation", Xa = /* @__PURE__ */ Symbol("_vtc"), Za = { + name: String, + type: String, + css: { + type: Boolean, + default: !0 + }, + duration: [ + String, + Number, + Object + ], + enterFromClass: String, + enterActiveClass: String, + enterToClass: String, + appearFromClass: String, + appearActiveClass: String, + appearToClass: String, + leaveFromClass: String, + leaveActiveClass: String, + leaveToClass: String +}, Qa = /* @__PURE__ */ s({}, tr, Za), $a = /* @__PURE__ */ ((e) => (e.displayName = "Transition", e.props = Qa, e))((e, { slots: t }) => Ra(ar, no(e), t)), eo = (e, t = []) => { + d(e) ? e.forEach((e) => e(...t)) : e && e(...t); +}, to = (e) => e ? d(e) ? e.some((e) => e.length > 1) : e.length > 1 : !1; +function no(e) { + let t = {}; + for (let n in e) n in Za || (t[n] = e[n]); + if (e.css === !1) return t; + let { name: n = "v", type: r, duration: i, enterFromClass: a = `${n}-enter-from`, enterActiveClass: o = `${n}-enter-active`, enterToClass: c = `${n}-enter-to`, appearFromClass: l = a, appearActiveClass: u = o, appearToClass: d = c, leaveFromClass: f = `${n}-leave-from`, leaveActiveClass: p = `${n}-leave-active`, leaveToClass: m = `${n}-leave-to` } = e, h = ro(i), g = h && h[0], _ = h && h[1], { onBeforeEnter: v, onEnter: y, onEnterCancelled: b, onLeave: x, onLeaveCancelled: S, onBeforeAppear: C = v, onAppear: w = y, onAppearCancelled: ee = b } = t, te = (e, t, n, r) => { + e._enterCancelled = r, oo(e, t ? d : c), oo(e, t ? u : o), n && n(); + }, ne = (e, t) => { + e._isLeaving = !1, oo(e, f), oo(e, m), oo(e, p), t && t(); + }, T = (e) => (t, n) => { + let i = e ? w : y, o = () => te(t, e, n); + eo(i, [t, o]), so(() => { + oo(t, e ? l : a), ao(t, e ? d : c), to(i) || lo(t, r, g, o); + }); + }; + return s(t, { + onBeforeEnter(e) { + eo(v, [e]), ao(e, a), ao(e, o); + }, + onBeforeAppear(e) { + eo(C, [e]), ao(e, l), ao(e, u); + }, + onEnter: T(!1), + onAppear: T(!0), + onLeave(e, t) { + e._isLeaving = !0; + let n = () => ne(e, t); + ao(e, f), e._enterCancelled ? (ao(e, p), mo(e)) : (mo(e), ao(e, p)), so(() => { + e._isLeaving && (oo(e, f), ao(e, m), to(x) || lo(e, r, _, n)); + }), eo(x, [e, n]); + }, + onEnterCancelled(e) { + te(e, !1, void 0, !0), eo(b, [e]); + }, + onAppearCancelled(e) { + te(e, !0, void 0, !0), eo(ee, [e]); + }, + onLeaveCancelled(e) { + ne(e), eo(S, [e]); + } + }); +} +function ro(e) { + if (e == null) return null; + if (v(e)) return [io(e.enter), io(e.leave)]; + { + let t = io(e); + return [t, t]; + } +} +function io(e) { + return le(e); +} +function ao(e, t) { + t.split(/\s+/).forEach((t) => t && e.classList.add(t)), (e[Xa] || (e[Xa] = /* @__PURE__ */ new Set())).add(t); +} +function oo(e, t) { + t.split(/\s+/).forEach((t) => t && e.classList.remove(t)); + let n = e[Xa]; + n && (n.delete(t), n.size || (e[Xa] = void 0)); +} +function so(e) { + requestAnimationFrame(() => { + requestAnimationFrame(e); + }); +} +var co = 0; +function lo(e, t, n, r) { + let i = e._endId = ++co, a = () => { + i === e._endId && r(); + }; + if (n != null) return setTimeout(a, n); + let { type: o, timeout: s, propCount: c } = uo(e, t); + if (!o) return r(); + let l = o + "end", u = 0, d = () => { + e.removeEventListener(l, f), a(); + }, f = (t) => { + t.target === e && ++u >= c && d(); + }; + setTimeout(() => { + u < c && d(); + }, s + 1), e.addEventListener(l, f); +} +function uo(e, t) { + let n = window.getComputedStyle(e), r = (e) => (n[e] || "").split(", "), i = r(`${Ja}Delay`), a = r(`${Ja}Duration`), o = fo(i, a), s = r(`${Ya}Delay`), c = r(`${Ya}Duration`), l = fo(s, c), u = null, d = 0, f = 0; + t === Ja ? o > 0 && (u = Ja, d = o, f = a.length) : t === Ya ? l > 0 && (u = Ya, d = l, f = c.length) : (d = Math.max(o, l), u = d > 0 ? o > l ? Ja : Ya : null, f = u ? u === Ja ? a.length : c.length : 0); + let p = u === Ja && /\b(?:transform|all)(?:,|$)/.test(r(`${Ja}Property`).toString()); + return { + type: u, + timeout: d, + propCount: f, + hasTransform: p + }; +} +function fo(e, t) { + for (; e.length < t.length;) e = e.concat(e); + return Math.max(...t.map((t, n) => po(t) + po(e[n]))); +} +function po(e) { + return e === "auto" ? 0 : Number(e.slice(0, -1).replace(",", ".")) * 1e3; +} +function mo(e) { + return (e ? e.ownerDocument : document).body.offsetHeight; +} +function ho(e, t, n) { + let r = e[Xa]; + r && (t = (t ? [t, ...r] : [...r]).join(" ")), t == null ? e.removeAttribute("class") : n ? e.setAttribute("class", t) : e.className = t; +} +var go = /* @__PURE__ */ Symbol("_vod"), _o = /* @__PURE__ */ Symbol("_vsh"), vo = /* @__PURE__ */ Symbol(""), yo = /(?:^|;)\s*display\s*:/; +function bo(e, t, n) { + let r = e.style, i = g(n), a = !1; + if (n && !i) { + if (t) if (g(t)) for (let e of t.split(";")) { + let t = e.slice(0, e.indexOf(":")).trim(); + n[t] ?? So(r, t, ""); + } + else for (let e in t) n[e] ?? So(r, e, ""); + for (let i in n) { + i === "display" && (a = !0); + let o = n[i]; + o == null ? So(r, i, "") : Eo(e, i, !g(t) && t ? t[i] : void 0, o) || So(r, i, o); + } + } else if (i) { + if (t !== n) { + let e = r[vo]; + e && (n += ";" + e), r.cssText = n, a = yo.test(n); + } + } else t && e.removeAttribute("style"); + go in e && (e[go] = a ? r.display : "", e[_o] && (r.display = "none")); +} +var xo = /\s*!important$/; +function So(e, t, n) { + if (d(n)) n.forEach((n) => So(e, t, n)); + else if (n ??= "", t.startsWith("--")) e.setProperty(t, n); + else { + let r = To(e, t); + xo.test(n) ? e.setProperty(E(r), n.replace(xo, ""), "important") : e[r] = n; + } +} +var Co = [ + "Webkit", + "Moz", + "ms" +], wo = {}; +function To(e, t) { + let n = wo[t]; + if (n) return n; + let r = T(t); + if (r !== "filter" && r in e) return wo[t] = r; + r = ie(r); + for (let n = 0; n < Co.length; n++) { + let i = Co[n] + r; + if (i in e) return wo[t] = i; + } + return t; +} +function Eo(e, t, n, r) { + return e.tagName === "TEXTAREA" && (t === "width" || t === "height") && g(r) && n === r; +} +var Do = "http://www.w3.org/1999/xlink"; +function Oo(e, t, n, r, i, a = ve(t)) { + r && t.startsWith("xlink:") ? n == null ? e.removeAttributeNS(Do, t.slice(6, t.length)) : e.setAttributeNS(Do, t, n) : n == null || a && !ye(n) ? e.removeAttribute(t) : e.setAttribute(t, a ? "" : _(n) ? String(n) : n); +} +function ko(e, t, n, r, i) { + if (t === "innerHTML" || t === "textContent") { + n != null && (e[t] = t === "innerHTML" ? Ha(n) : n); + return; + } + let a = e.tagName; + if (t === "value" && a !== "PROGRESS" && !a.includes("-")) { + let r = a === "OPTION" ? e.getAttribute("value") || "" : e.value, i = n == null ? e.type === "checkbox" ? "on" : "" : String(n); + (r !== i || !("_value" in e)) && (e.value = i), n ?? e.removeAttribute(t), e._value = n; + return; + } + let o = !1; + if (n === "" || n == null) { + let r = typeof e[t]; + r === "boolean" ? n = ye(n) : n == null && r === "string" ? (n = "", o = !0) : r === "number" && (n = 0, o = !0); + } + try { + e[t] = n; + } catch {} + o && e.removeAttribute(i || t); +} +function Ao(e, t, n, r) { + e.addEventListener(t, n, r); +} +function jo(e, t, n, r) { + e.removeEventListener(t, n, r); +} +var Mo = /* @__PURE__ */ Symbol("_vei"); +function No(e, t, n, r, i = null) { + let a = e[Mo] || (e[Mo] = {}), o = a[t]; + if (r && o) o.value = r; + else { + let [n, s] = Io(t); + r ? Ao(e, n, a[t] = Bo(r, i), s) : o && (jo(e, n, o, s), a[t] = void 0); + } +} +var Po = /(Once|Passive|Capture)$/, Fo = /^on:?(?:Once|Passive|Capture)$/; +function Io(e) { + let t, n; + for (; (n = e.match(Po)) && !Fo.test(e);) t ||= {}, e = e.slice(0, e.length - n[1].length), t[n[1].toLowerCase()] = !0; + return [e[2] === ":" ? e.slice(3) : E(e.slice(2)), t]; +} +var Lo = 0, Ro = /* @__PURE__ */ Promise.resolve(), zo = () => Lo ||= (Ro.then(() => Lo = 0), Date.now()); +function Bo(e, t) { + let n = (e) => { + if (!e._vts) e._vts = Date.now(); + else if (e._vts <= n.attached) return; + let r = n.value; + if (d(r)) { + let n = e.stopImmediatePropagation; + e.stopImmediatePropagation = () => { + n.call(e), e._stopped = !0; + }; + let i = r.slice(), a = [e]; + for (let n = 0; n < i.length && !e._stopped; n++) { + let e = i[n]; + e && on(e, t, 5, a); + } + } else on(r, t, 5, [e]); + }; + return n.value = e, n.attached = zo(), n; +} +var Vo = (e) => e.charCodeAt(0) === 111 && e.charCodeAt(1) === 110 && e.charCodeAt(2) > 96 && e.charCodeAt(2) < 123, Ho = (e, t, n, r, i, s) => { + let c = i === "svg"; + t === "class" ? ho(e, r, c) : t === "style" ? bo(e, n, r) : a(t) ? o(t) || No(e, t, n, r, s) : (t[0] === "." ? (t = t.slice(1), !0) : t[0] === "^" ? (t = t.slice(1), !1) : Uo(e, t, r, c)) ? (ko(e, t, r), !e.tagName.includes("-") && (t === "value" || t === "checked" || t === "selected") && Oo(e, t, r, c, s, t !== "value")) : e._isVueCE && (Wo(e, t) || e._def.__asyncLoader && (/[A-Z]/.test(t) || !g(r))) ? ko(e, T(t), r, s, t) : (t === "true-value" ? e._trueValue = r : t === "false-value" && (e._falseValue = r), Oo(e, t, r, c)); +}; +function Uo(e, t, n, r) { + if (r) return !!(t === "innerHTML" || t === "textContent" || t in e && Vo(t) && h(n)); + if (t === "spellcheck" || t === "draggable" || t === "translate" || t === "autocorrect" || t === "sandbox" && e.tagName === "IFRAME" || t === "form" || t === "list" && e.tagName === "INPUT" || t === "type" && e.tagName === "TEXTAREA") return !1; + if (t === "width" || t === "height") { + let t = e.tagName; + if (t === "IMG" || t === "VIDEO" || t === "CANVAS" || t === "SOURCE") return !1; + } + return Vo(t) && g(n) ? !1 : t in e; +} +function Wo(e, t) { + let n = e._def.props; + if (!n) return !1; + let r = T(t); + return Array.isArray(n) ? n.some((e) => T(e) === r) : Object.keys(n).some((e) => T(e) === r); +} +var Go = (e) => { + let t = e.props["onUpdate:modelValue"] || !1; + return d(t) ? (e) => se(t, e) : t; +}; +function Ko(e) { + e.target.composing = !0; +} +function qo(e) { + let t = e.target; + t.composing && (t.composing = !1, t.dispatchEvent(new Event("input"))); +} +var Jo = /* @__PURE__ */ Symbol("_assign"); +function Yo(e, t, n) { + return t && (e = e.trim()), n && (e = ce(e)), e; +} +var Xo = { + created(e, { modifiers: { lazy: t, trim: n, number: r } }, i) { + e[Jo] = Go(i); + let a = r || i.props && i.props.type === "number"; + Ao(e, t ? "change" : "input", (t) => { + t.target.composing || e[Jo](Yo(e.value, n, a)); + }), (n || a) && Ao(e, "change", () => { + e.value = Yo(e.value, n, a); + }), t || (Ao(e, "compositionstart", Ko), Ao(e, "compositionend", qo), Ao(e, "change", qo)); + }, + mounted(e, { value: t }) { + e.value = t ?? ""; + }, + beforeUpdate(e, { value: t, oldValue: n, modifiers: { lazy: r, trim: i, number: a } }, o) { + if (e[Jo] = Go(o), e.composing) return; + let s = (a || e.type === "number") && !/^0\d/.test(e.value) ? ce(e.value) : e.value, c = t ?? ""; + if (s === c) return; + let l = e.getRootNode(); + (l instanceof Document || l instanceof ShadowRoot) && l.activeElement === e && e.type !== "range" && (r && t === n || i && e.value.trim() === c) || (e.value = c); + } +}, Zo = { + deep: !0, + created(e, t, n) { + e[Jo] = Go(n), Ao(e, "change", () => { + let t = e._modelValue, n = ts(e), r = e.checked, i = e[Jo]; + if (d(t)) { + let e = Se(t, n), a = e !== -1; + if (r && !a) i(t.concat(n)); + else if (!r && a) { + let n = [...t]; + n.splice(e, 1), i(n); + } + } else if (p(t)) { + let e = new Set(t); + r ? e.add(n) : e.delete(n), i(e); + } else i(ns(e, r)); + }); + }, + mounted: Qo, + beforeUpdate(e, t, n) { + e[Jo] = Go(n), Qo(e, t, n); + } +}; +function Qo(e, { value: t, oldValue: n }, r) { + e._modelValue = t; + let i; + if (d(t)) i = Se(t, r.props.value) > -1; + else if (p(t)) i = t.has(r.props.value); + else { + if (t === n) return; + i = xe(t, ns(e, !0)); + } + e.checked !== i && (e.checked = i); +} +var $o = { + deep: !0, + created(e, { value: t, modifiers: { number: n } }, r) { + e._modelValue = t, Ao(e, "change", () => { + let t = Array.prototype.filter.call(e.options, (e) => e.selected).map((e) => n ? ce(ts(e)) : ts(e)); + e[Jo](e.multiple ? p(e._modelValue) ? new Set(t) : t : t[0]), e._assigning = !0, hn(() => { + e._assigning = !1; + }); + }), e[Jo] = Go(r); + }, + mounted(e, { value: t }) { + es(e, t); + }, + beforeUpdate(e, { value: t }, n) { + e._modelValue = t, e[Jo] = Go(n); + }, + updated(e, { value: t }) { + e._assigning || es(e, t); + } +}; +function es(e, t) { + let n = e.multiple, r = d(t); + if (!(n && !r && !p(t))) { + for (let i = 0, a = e.options.length; i < a; i++) { + let a = e.options[i], o = ts(a); + if (n) if (r) { + let e = typeof o; + a.selected = e === "string" || e === "number" ? t.some((e) => String(e) === String(o)) : Se(t, o) > -1; + } else a.selected = t.has(o); + else if (xe(ts(a), t)) { + e.selectedIndex !== i && (e.selectedIndex = i); + return; + } + } + !n && e.selectedIndex !== -1 && (e.selectedIndex = -1); + } +} +function ts(e) { + return "_value" in e ? e._value : e.value; +} +function ns(e, t) { + let n = t ? "_trueValue" : "_falseValue"; + return n in e ? e[n] : t; +} +var rs = [ + "ctrl", + "shift", + "alt", + "meta" +], is = { + stop: (e) => e.stopPropagation(), + prevent: (e) => e.preventDefault(), + self: (e) => e.target !== e.currentTarget, + ctrl: (e) => !e.ctrlKey, + shift: (e) => !e.shiftKey, + alt: (e) => !e.altKey, + meta: (e) => !e.metaKey, + left: (e) => "button" in e && e.button !== 0, + middle: (e) => "button" in e && e.button !== 1, + right: (e) => "button" in e && e.button !== 2, + exact: (e, t) => rs.some((n) => e[`${n}Key`] && !t.includes(n)) +}, as = (e, t) => { + if (!e) return e; + let n = e._withMods ||= {}, r = t.join("."); + return n[r] || (n[r] = ((n, ...r) => { + for (let e = 0; e < t.length; e++) { + let r = is[t[e]]; + if (r && r(n, t)) return; + } + return e(n, ...r); + })); +}, os = { + esc: "escape", + space: " ", + up: "arrow-up", + left: "arrow-left", + right: "arrow-right", + down: "arrow-down", + delete: "backspace" +}, ss = (e, t) => { + let n = e._withKeys ||= {}, r = t.join("."); + return n[r] || (n[r] = ((n) => { + if (!("key" in n)) return; + let r = E(n.key); + if (t.some((e) => e === r || os[e] === r)) return e(n); + })); +}, cs = /* @__PURE__ */ s({ patchProp: Ho }, qa), ls; +function us() { + return ls ||= Li(cs); +} +var ds = ((...e) => { + let t = us().createApp(...e), { mount: n } = t; + return t.mount = (e) => { + let r = ps(e); + if (!r) return; + let i = t._component; + !h(i) && !i.render && !i.template && (i.template = r.innerHTML), r.nodeType === 1 && (r.textContent = ""); + let a = n(r, !1, fs(r)); + return r instanceof Element && (r.removeAttribute("v-cloak"), r.setAttribute("data-v-app", "")), a; + }, t; +}); +function fs(e) { + if (e instanceof SVGElement) return "svg"; + if (typeof MathMLElement == "function" && e instanceof MathMLElement) return "mathml"; +} +function ps(e) { + return g(e) ? document.querySelector(e) : e; +} +//#endregion +//#region src/api.ts +async function ms(e, t = {}) { + let n = await fetch(e, { + credentials: "same-origin", + ...t, + headers: { + Accept: "application/json", + ...t.headers || {} + } + }), r = (n.headers.get("content-type") || "").includes("application/json") ? await n.json() : await n.text(); + if (!n.ok) { + let e = typeof r == "object" && r ? r.error || r.detail || r.message : r; + throw Error(String(e || `Request failed (${n.status})`)); + } + return r; +} +function hs(e) { + return ms(e); +} +function gs(e, t = {}) { + return ms(e, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(t) + }); +} +function _s(e, t) { + return ms(e, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(t) + }); +} +//#endregion +//#region src/trainerStore.ts +var vs = () => ({ + running: !1, + exit_code: null, + log_lines: [] +}), ys = () => ({ + personal: [], + negative: [], + personal_count: 0, + negative_count: 0 +}), bs = () => ({ + items: [], + captured_count: 0, + personal_count: 0, + negative_count: 0 +}), xs = () => ({ + items: [], + total_size_bytes: 0, + total_file_count: 0 +}), Ss = () => ({ + enabled: !1, + wake_phrase: "", + language: "en", + stt_engine: "faster_whisper", + minimum_transcript_chars: 2, + delete_confirmed_wakes: !1, + promote_close_misses: !1, + schedule_hours: 24, + minimum_new_negatives: 3, + advertised_base_url: "", + tater_url: "http://127.0.0.1:8501", + notify_satellites: !0 +}), X = /* @__PURE__ */ Ft({ + activeView: "trainer", + initialized: !1, + busy: /* @__PURE__ */ new Set(), + phrase: "", + language: "en", + ttsMode: "hybrid", + languages: [{ + code: "en", + label: "English (en)", + engines: ["omnivoice"] + }], + session: {}, + samples: ys(), + captured: bs(), + training: vs(), + auto: {}, + autoForm: Ss(), + wakeWords: [], + managedData: xs(), + selectedFiles: [], + sampleBucket: "personal", + samplePage: { + personal: 0, + negative: 0 + }, + uploadProgress: 0, + uploadLabel: "No upload in progress", + uploadDetail: "Choose files and upload when you are ready.", + consoleOpen: !1, + taterLinkOpen: !1, + trimItem: null, + trimBucket: "personal", + toast: { + message: "", + tone: "success", + serial: 0 + } +}), Cs = 0, ws = 0, Ts = Y(() => Number(X.samples.personal_count ?? X.session.takes_received ?? 0)), Es = Y(() => Number(X.samples.negative_count ?? X.captured.negative_count ?? 0)), Ds = Y(() => X.languages.find((e) => e.code === X.language) || X.languages[0]), Os = Y(() => { + let e = Ds.value?.engines?.length ? Ds.value.engines : ["omnivoice"], t = X.ttsMode === "piper" ? e.filter((e) => e === "piper") : X.ttsMode === "hybrid" ? e : e.filter((e) => e !== "piper"), n = { + omnivoice: "OmniVoice", + qwen3: "Qwen3", + moss: "MOSS", + piper: "Piper" + }, r = X.ttsMode === "piper" ? "Legacy" : Ns(Ds.value?.quality || "experimental"); + return `${t.map((e) => n[e] || e).join(" + ") || "Unavailable"} · ${r}`; +}), ks = Y(() => !!(X.training.running || X.training.exit_code !== null || X.training.log_lines?.length)), As = Y(() => X.samples[X.sampleBucket] || []), js = Y(() => !!X.auto.trainer_link?.linked), Ms = Y(() => { + let e = X.auto.stt_engines; + return Array.isArray(e) && e.length ? e : [{ + id: "faster_whisper", + label: "Faster Whisper" + }, { + id: "parakeet_onnx", + label: "Parakeet ONNX" + }]; +}); +function Ns(e) { + return String(e || "").replaceAll("_", " ").replace(/\b\w/g, (e) => e.toUpperCase()); +} +function Z(e) { + return e ? X.busy.has(e) : X.busy.size > 0; +} +function Q(e, t) { + t ? X.busy.add(e) : X.busy.delete(e); +} +function $(e, t = "success") { + X.toast = { + message: String(e || ""), + tone: t, + serial: X.toast.serial + 1 + }; +} +function Ps(e, t) { + $(e instanceof Error ? e.message : t, "error"); +} +function Fs(e) { + X.session = e || {}, Array.isArray(e.available_languages) && e.available_languages.length && (X.languages = e.available_languages), e.raw_phrase && (X.phrase = e.raw_phrase), e.language && (X.language = e.language), e.tts_mode && (X.ttsMode = e.tts_mode), e.training && (X.training = e.training); +} +async function Is() { + let e = await hs("/api/session"); + return Fs(e), e; +} +async function Ls() { + if (!X.phrase.trim()) { + $("Enter a wake phrase first.", "warning"); + return; + } + Q("session", !0); + try { + let e = await gs("/api/start_session", { + phrase: X.phrase.trim(), + language: X.language, + tts_mode: X.ttsMode + }); + Fs(e), $(`Session ${e.safe_word || "started"} is ready.`); + } catch (e) { + Ps(e, "Session failed to start."); + } finally { + Q("session", !1); + } +} +async function Rs() { + let e = !!X.training.running; + if (!(e && !window.confirm("Training is running. Stop training cleanly and end this session?"))) { + Q("session", !0), ws &&= (window.clearInterval(ws), 0); + try { + Fs(await gs("/api/stop_session")), $(e ? "Training stopped cleanly and the session ended." : "Session ended. You can edit the wake phrase now."); + } catch (t) { + e && sc(), Ps(t, "Session could not be stopped."); + } finally { + Q("session", !1); + } + } +} +function zs() { + if (!X.phrase.trim() || !("speechSynthesis" in window)) return; + let e = new SpeechSynthesisUtterance(X.phrase.trim()); + e.lang = X.language, window.speechSynthesis.cancel(), window.speechSynthesis.speak(e); +} +function Bs() { + let e = Ds.value?.engines || [], t = e.some((e) => e !== "piper"), n = e.includes("piper"); + X.ttsMode === "modern" && !t && (X.ttsMode = "piper"), X.ttsMode === "hybrid" && !(t && n) && (X.ttsMode = t ? "modern" : "piper"), X.ttsMode === "piper" && !n && (X.ttsMode = "modern"); +} +async function Vs(e = !1) { + e || Q("samples", !0); + try { + let e = await hs("/api/samples"); + X.samples = { + ...ys(), + ...e + }; + for (let e of ["personal", "negative"]) { + let t = Math.max(0, Math.ceil((X.samples[e]?.length || 0) / 50) - 1); + X.samplePage[e] = Math.min(X.samplePage[e], t); + } + return e; + } finally { + e || Q("samples", !1); + } +} +async function Hs(e = !1) { + e || Q("captured", !0); + try { + let e = await hs("/api/captured_audio"); + return X.captured = { + ...bs(), + ...e + }, e; + } finally { + e || Q("captured", !1); + } +} +function Us(e) { + let t = e.target; + X.selectedFiles = Array.from(t.files || []); +} +function Ws(e, t, n) { + return new Promise((r, i) => { + let a = new XMLHttpRequest(), o = new FormData(); + o.append("file", e, e.name), a.open("POST", "/api/upload_personal_sample"), a.responseType = "json", a.upload.onprogress = (r) => { + r.lengthComputable && (X.uploadProgress = Math.round((t + r.loaded / r.total) / n * 100), X.uploadLabel = `Uploading ${e.name} (${t + 1}/${n})`, X.uploadDetail = "Sending and normalizing the recording."); + }, a.onload = () => { + let t = a.response || {}; + a.status >= 200 && a.status < 300 ? r(t) : i(Error(t.error || `Upload failed for ${e.name}`)); + }, a.onerror = () => i(/* @__PURE__ */ Error(`Upload failed for ${e.name}`)), a.send(o); + }); +} +async function Gs(e) { + if (!X.session.safe_word) { + $("Start a trainer session before uploading samples.", "warning"); + return; + } + if (X.selectedFiles.length) { + Q("upload", !0); + try { + let t = [...X.selectedFiles]; + for (let e = 0; e < t.length; e += 1) await Ws(t[e], e, t.length); + X.uploadProgress = 100, X.uploadLabel = "Upload complete", X.uploadDetail = `${t.length} sample${t.length === 1 ? "" : "s"} saved in the required training format.`, X.selectedFiles = [], e && (e.value = ""), await Promise.all([Is(), Vs(!0)]), $("Personal samples uploaded."); + } catch (e) { + X.uploadProgress = 0, Ps(e, "Sample upload failed."); + } finally { + Q("upload", !1); + } + } +} +async function Ks(e, t) { + if (!(t === "discard" && !window.confirm(`Discard ${e.saved_as} from the captured-audio inbox?`))) { + Q("review", !0); + try { + await gs(`/api/captured_audio/${encodeURIComponent(e.saved_as)}/${t}`), await Promise.all([ + Is(), + Hs(!0), + Vs(!0) + ]), $(t === "approve_personal" ? "Clip added to personal samples." : t === "mark_negative" ? "Clip marked negative." : "Clip discarded."); + } catch (e) { + Ps(e, "Review action failed."); + } finally { + Q("review", !1); + } + } +} +async function qs(e, t) { + if (window.confirm(`Remove ${e.saved_as} from ${t} samples?`)) { + Q("review", !0); + try { + await ms(`/api/samples/${t}/${encodeURIComponent(e.saved_as)}`, { method: "DELETE" }), await Vs(!0), $("Sample removed."); + } catch (e) { + Ps(e, "Sample removal failed."); + } finally { + Q("review", !1); + } + } +} +async function Js(e, t) { + if (!window.confirm(`Revert ${e.saved_as} to its pre-trim version?`)) return; + let n = new FormData(); + n.append("bucket", t), n.append("file_name", e.saved_as), Q("review", !0); + try { + await ms("/api/samples/revert", { + method: "POST", + body: n + }), await Vs(!0), $("Original sample restored."); + } catch (e) { + Ps(e, "Sample revert failed."); + } finally { + Q("review", !1); + } +} +async function Ys(e) { + let t = e === "personal" ? Ts.value : Es.value; + if (!(!t || !window.confirm(`Clear ${t} ${e} sample${t === 1 ? "" : "s"}?`))) { + Q("review", !0); + try { + await gs(e === "personal" ? "/api/reset_recordings" : "/api/reset_negative_samples"), await Promise.all([ + Is(), + Vs(!0), + Hs(!0) + ]), $(`${Ns(e)} samples cleared.`); + } catch (e) { + Ps(e, "Samples could not be cleared."); + } finally { + Q("review", !1); + } + } +} +function Xs(e, t) { + X.auto = e || {}, t && (X.autoForm = { + ...Ss(), + ...e.config || {} + }, X.autoForm.wake_phrase || (X.autoForm.wake_phrase = X.session.raw_phrase || ""), X.autoForm.language || (X.autoForm.language = X.session.language || "en")); +} +async function Zs(e = !1) { + let t = await hs("/api/auto_train"); + return Xs(t, e), t; +} +async function Qs() { + Q("auto", !0); + try { + let e = await _s("/api/auto_train", X.autoForm); + Xs(e, !0), $(e.config?.enabled ? "Auto Training saved and enabled." : "Auto Training saved."); + } catch (e) { + Ps(e, "Auto Training settings failed to save."); + } finally { + Q("auto", !1); + } +} +async function $s(e) { + Q("auto", !0); + try { + let t = await gs("/api/auto_train/action", { action: e }); + Xs(t, !1), e === "train_now" && (X.consoleOpen = !0, sc()), $(e === "review_now" ? `${Number(t.queued || 0)} clips queued for review.` : e === "train_now" ? "Training started." : "Wake word published."); + } catch (e) { + Ps(e, "Auto Training action failed."); + } finally { + Q("auto", !1); + } +} +async function ec(e, t) { + Q("link", !0); + try { + return await gs("/api/tater_link/claim", { + tater_url: e.trim(), + pairing_code: t.trim() + }), X.autoForm.tater_url = e.trim(), await Zs(!1), $("Trainer linked securely to Tater."), !0; + } catch (e) { + return Ps(e, "Tater link failed."), !1; + } finally { + Q("link", !1); + } +} +async function tc() { + if (window.confirm("Unlink this trainer from Tater?")) { + Q("auto", !0); + try { + await gs("/api/tater_link/unlink"), await Zs(!1), $("Trainer unlinked from Tater.", "warning"); + } catch (e) { + Ps(e, "Tater unlink failed."); + } finally { + Q("auto", !1); + } + } +} +async function nc(e = !1) { + e || Q("firmware", !0); + try { + let e = await hs("/api/trained_wake_words/catalog"); + X.wakeWords = Array.isArray(e.wake_words) ? e.wake_words : []; + } finally { + e || Q("firmware", !1); + } +} +async function rc() { + Q("data", !0); + try { + let e = await hs("/api/data"); + return X.managedData = { + ...xs(), + ...e + }, e; + } finally { + Q("data", !1); + } +} +async function ic(e) { + if (!e.file_count) return; + let t = `${dc(e.size_bytes)} · ${Number(e.file_count).toLocaleString()} file${e.file_count === 1 ? "" : "s"}`, n = e.rebuild_note ? `\n\n${e.rebuild_note}` : ""; + if (window.confirm(`Permanently delete ${e.label} (${t})?${n}\n\nThis cannot be undone.`)) { + Q("data-delete", !0); + try { + let t = await ms(`/api/data/${encodeURIComponent(e.id)}`, { method: "DELETE" }); + X.managedData = { + ...xs(), + ...t + }, await Promise.allSettled([ + Is(), + Vs(!0), + Hs(!0), + nc(!0) + ]), $(`${e.label} deleted. ${dc(e.size_bytes)} released.`); + } catch (t) { + Ps(t, `${e.label} could not be deleted.`); + } finally { + Q("data-delete", !1); + } + } +} +async function ac(e) { + try { + await navigator.clipboard.writeText(e), $("Wake-word JSON URL copied."); + } catch (e) { + Ps(e, "Clipboard unavailable."); + } +} +async function oc() { + await Promise.all([Is(), Vs(!0)]); + let e = !1; + if (!(!Ts.value && (e = window.confirm("No positive samples are saved. Train anyway without personal voices?"), !e))) { + Q("training-start", !0), X.training = { + running: !0, + exit_code: null, + log_lines: ["Waiting for training output…"] + }, X.consoleOpen = !0; + try { + await gs("/api/train", { allow_no_personal: e }), sc(); + } catch (e) { + X.training = { + running: !1, + exit_code: 1, + log_lines: [e instanceof Error ? e.message : String(e)] + }, Ps(e, "Training could not start."); + } finally { + Q("training-start", !1); + } + } +} +function sc() { + if (ws) return; + let e = async () => { + try { + let e = await hs("/api/train_status"); + X.training = { + ...vs(), + ...e.training || {} + }, X.training.running || (window.clearInterval(ws), ws = 0, await Promise.all([Vs(!0), nc(!0)]), $(X.training.exit_code === 0 ? "Training finished successfully." : `Training ended with exit ${X.training.exit_code}.`, X.training.exit_code === 0 ? "success" : "error")); + } catch {} + }; + e(), ws = window.setInterval(() => void e(), 1500); +} +async function cc() { + Q("bootstrap", !0); + try { + await Promise.allSettled([ + Is(), + Vs(!0), + Hs(!0), + Zs(!0), + nc(!0) + ]), Bs(); + try { + let e = await hs("/api/train_status"); + X.training = { + ...vs(), + ...e.training || {} + }, X.training.running && (X.consoleOpen = !0, sc()); + } catch {} + Cs = window.setInterval(() => { + X.activeView === "auto" && !Z("auto") && Zs(!1).catch(() => void 0); + }, 2500), X.initialized = !0; + } finally { + Q("bootstrap", !1); + } +} +function lc() { + window.clearInterval(Cs), window.clearInterval(ws), Cs = 0, ws = 0; +} +function uc(e) { + if (!e) return ""; + let t = new Date(String(e)); + return Number.isNaN(t.getTime()) ? String(e) : t.toLocaleString(); +} +function dc(e) { + let t = Math.max(0, Number(e) || 0); + if (t < 1024) return `${Math.round(t)} B`; + let n = [ + "KB", + "MB", + "GB", + "TB" + ], r = t / 1024, i = n[0]; + for (let e = 1; e < n.length && r >= 1024; e += 1) r /= 1024, i = n[e]; + return `${r >= 10 ? r.toFixed(1) : r.toFixed(2)} ${i}`; +} +function fc(e) { + if (!e) return "16 kHz · mono · 16-bit WAV"; + let t = Number(e.sample_rate || e.sample_rate_hz || 16e3), n = Number(e.channels || 1) === 1 ? "mono" : `${e.channels} channels`, r = Number(e.bits_per_sample || e.sample_width_bits || 16); + return `${Math.round(t / 1e3)} kHz · ${n} · ${r}-bit`; +} +function pc(e) { + if (e.blocked_by_vad) return { + label: "Blocked by VAD", + tone: "warning" + }; + let t = String(e.event_type || "").toLowerCase(); + return t.includes("close") ? { + label: e.capture_label || "Close miss", + tone: "warning" + } : t.includes("false") ? { + label: e.capture_label || "False trigger", + tone: "error" + } : t.includes("wake") || t.includes("detect") ? { + label: e.capture_label || "Wake trigger", + tone: "success" + } : { + label: e.capture_label || "Captured", + tone: "neutral" + }; +} +function mc(e, t) { + return e.audio_url || `/api/audio/${t}/${encodeURIComponent(e.saved_as)}`; +} +//#endregion +//#region src/components/AudioTrimModal.vue?vue&type=script&setup=true&lang.ts +var hc = { + class: "modal trim-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Trim audio" +}, gc = { class: "modal-head" }, _c = { + key: 0, + class: "empty-state" +}, vc = { class: "range-grid" }, yc = ["max"], bc = ["min", "max"], xc = { class: "row space" }, Sc = { class: "pill" }, Cc = { + key: 0, + class: "pill success" +}, wc = { class: "row modal-actions" }, Tc = ["disabled"], Ec = /* @__PURE__ */ fr({ + __name: "AudioTrimModal", + setup(e) { + let t = /* @__PURE__ */ F(null), n = /* @__PURE__ */ F(null), r = /* @__PURE__ */ F(0), i = /* @__PURE__ */ F(0), a = /* @__PURE__ */ F(0), o = /* @__PURE__ */ F([]), s = /* @__PURE__ */ F(!1), c = /* @__PURE__ */ F(!1); + Nn(() => X.trimItem, async (e) => { + if (!e) { + n.value = null; + return; + } + s.value = !0; + try { + let t = `/api/audio/${encodeURIComponent(X.trimBucket)}/${encodeURIComponent(e.saved_as)}`, s = await fetch(t); + if (!s.ok) throw Error("Audio could not be loaded."); + let c = new (window.AudioContext || window.webkitAudioContext)(); + n.value = await c.decodeAudioData(await s.arrayBuffer()), r.value = n.value.duration, i.value = 0, a.value = r.value, await c.close(); + try { + let t = await ms(`/api/samples/${encodeURIComponent(X.trimBucket)}/${encodeURIComponent(e.saved_as)}/vad`, { method: "POST" }); + o.value = Array.isArray(t.segments) ? t.segments : [], o.value.length && (i.value = Math.max(0, Number(o.value[0].start || 0)), a.value = Math.min(r.value, Number(o.value[0].end || r.value))); + } catch { + o.value = []; + } + await hn(), d(); + } catch (e) { + $(e instanceof Error ? e.message : "Audio could not be loaded.", "error"), l(); + } finally { + s.value = !1; + } + }, { immediate: !0 }), Nn([i, a], () => d()); + function l() { + X.trimItem = null, n.value = null, o.value = []; + } + function u() { + let e = o.value[0]; + e && (i.value = Number(e.start), a.value = Number(e.end)); + } + function d() { + let e = t.value, s = n.value; + if (!e || !s || !r.value) return; + let c = e.getBoundingClientRect(); + if (!c.width || !c.height) return; + let l = window.devicePixelRatio || 1; + e.width = Math.round(c.width * l), e.height = Math.round(c.height * l); + let u = e.getContext("2d"); + if (!u) return; + u.scale(l, l); + let d = c.width, f = c.height, p = f / 2, m = s.getChannelData(0), h = Math.max(1, Math.floor(m.length / d)); + u.clearRect(0, 0, d, f), u.strokeStyle = "rgba(222, 218, 212, .24)", u.lineWidth = 1, u.beginPath(); + for (let e = 0; e < d; e += 1) { + let t = 1, n = -1; + for (let r = 0; r < h; r += 1) { + let i = m[Math.floor(e) * h + r] || 0; + t = Math.min(t, i), n = Math.max(n, i); + } + u.moveTo(e, p + t * p * .84), u.lineTo(e, p + n * p * .84); + } + u.stroke(); + let g = i.value / r.value * d, _ = a.value / r.value * d; + u.fillStyle = "rgba(8, 8, 9, .66)", u.fillRect(0, 0, g, f), u.fillRect(_, 0, d - _, f), u.fillStyle = "rgba(255, 145, 52, .12)", u.fillRect(g, 0, _ - g, f), u.strokeStyle = "#ff9134", u.lineWidth = 2; + for (let e of [g, _]) u.beginPath(), u.moveTo(e, 0), u.lineTo(e, f), u.stroke(); + u.strokeStyle = "rgba(68, 225, 165, .55)"; + for (let e of o.value) { + let t = e.start / r.value * d; + u.beginPath(), u.moveTo(t, 0), u.lineTo(t, f), u.stroke(); + } + } + function f() { + let e = n.value; + if (!e) return; + let t = new (window.AudioContext || window.webkitAudioContext)(), r = t.createBufferSource(); + r.buffer = e, r.connect(t.destination), r.start(0, i.value, Math.max(.01, a.value - i.value)), r.onended = () => void t.close(); + } + async function p() { + let e = n.value; + if (!e) throw Error("Audio is not loaded."); + let t = Math.floor(i.value * e.sampleRate), r = Math.min(Math.floor(a.value * e.sampleRate), e.length), o = 16e3, s; + if (e.sampleRate === o) s = e.getChannelData(0).slice(t, r); + else { + let n = Math.max(1, Math.floor((r - t) * o / e.sampleRate)), c = new OfflineAudioContext(1, n, o), l = c.createBufferSource(); + l.buffer = e, l.connect(c.destination), l.start(0, i.value, a.value - i.value), s = (await c.startRendering()).getChannelData(0); + } + let c = /* @__PURE__ */ new ArrayBuffer(44 + s.length * 2), l = new DataView(c); + l.setUint32(0, 1380533830, !1), l.setUint32(4, 36 + s.length * 2, !0), l.setUint32(8, 1463899717, !1), l.setUint32(12, 1718449184, !1), l.setUint32(16, 16, !0), l.setUint16(20, 1, !0), l.setUint16(22, 1, !0), l.setUint32(24, o, !0), l.setUint32(28, o * 2, !0), l.setUint16(32, 2, !0), l.setUint16(34, 16, !0), l.setUint32(36, 1684108385, !1), l.setUint32(40, s.length * 2, !0); + for (let e = 0; e < s.length; e += 1) l.setInt16(44 + e * 2, Math.max(-32768, Math.min(32767, Math.round(s[e] * 32767))), !0); + return new Blob([c], { type: "audio/wav" }); + } + async function m() { + let e = X.trimItem; + if (e) { + c.value = !0; + try { + let t = new FormData(); + t.append("file", await p(), "trimmed.wav"), t.append("bucket", X.trimBucket), t.append("source_file", e.saved_as), t.append("start_time", i.value.toFixed(3)), t.append("end_time", a.value.toFixed(3)); + let n = await ms("/api/samples/trim", { + method: "POST", + body: t + }); + l(), await Vs(!0), $(n.message || "Trimmed sample saved."); + } catch (e) { + $(e instanceof Error ? e.message : "Trim failed.", "error"); + } finally { + c.value = !1; + } + } + } + function h() { + X.trimItem && d(); + } + return window.addEventListener("resize", h), Ar(() => window.removeEventListener("resize", h)), (e, n) => (U(), ra(Jn, { to: "body" }, [I(X).trimItem ? (U(), W("div", { + key: 0, + class: "modal-backdrop", + onClick: as(l, ["self"]) + }, [G("section", hc, [ + G("header", gc, [G("div", null, [n[2] ||= G("span", { class: "eyebrow" }, "Audio editor", -1), G("h2", null, "Trim " + k(I(X).trimItem.saved_as), 1)]), G("button", { + type: "button", + class: "button ghost", + onClick: l + }, "Close")]), + n[3] ||= G("p", { class: "muted" }, "Keep the spoken wake phrase and remove excess silence or noise. VAD markers appear in green.", -1), + s.value ? (U(), W("div", _c, "Loading waveform…")) : (U(), W(V, { key: 1 }, [ + G("canvas", { + ref_key: "canvas", + ref: t, + class: "waveform" + }, null, 512), + G("div", vc, [G("label", null, [G("span", null, "Start · " + k(i.value.toFixed(2)) + "s", 1), R(G("input", { + "onUpdate:modelValue": n[0] ||= (e) => i.value = e, + type: "range", + min: "0", + max: Math.max(0, a.value - .01), + step: ".01" + }, null, 8, yc), [[ + Xo, + i.value, + void 0, + { number: !0 } + ]])]), G("label", null, [G("span", null, "End · " + k(a.value.toFixed(2)) + "s", 1), R(G("input", { + "onUpdate:modelValue": n[1] ||= (e) => a.value = e, + type: "range", + min: Math.min(r.value, i.value + .01), + max: r.value, + step: ".01" + }, null, 8, bc), [[ + Xo, + a.value, + void 0, + { number: !0 } + ]])])]), + G("div", xc, [G("span", Sc, "Selection " + k(Math.max(0, a.value - i.value).toFixed(2)) + "s", 1), o.value.length ? (U(), W("span", Cc, k(o.value.length) + " speech segment" + k(o.value.length === 1 ? "" : "s"), 1)) : q("", !0)]), + G("div", wc, [ + G("button", { + type: "button", + onClick: f + }, "Play selection"), + o.value.length ? (U(), W("button", { + key: 0, + type: "button", + onClick: u + }, "Select first VAD")) : q("", !0), + G("button", { + type: "button", + class: "button primary", + disabled: c.value, + onClick: m + }, k(c.value ? "Saving…" : "Save trim"), 9, Tc) + ]) + ], 64)) + ])])) : q("", !0)])); + } +}), Dc = { class: "app-shell" }, Oc = { class: "app-header" }, kc = { class: "header-status" }, Ac = { + key: 0, + class: "session-chip" +}, jc = { + class: "tabs", + "aria-label": "Trainer areas" +}, Mc = ["onClick"], Nc = { class: "tab-full" }, Pc = { class: "tab-short" }, Fc = { key: 0 }, Ic = { class: "main-content" }, Lc = { + key: 0, + class: "loading-panel" +}, Rc = { class: "panel" }, zc = { class: "panel-head" }, Bc = { class: "form-grid phrase-form" }, Vc = { class: "field wide" }, Hc = ["disabled"], Uc = { class: "field" }, Wc = ["disabled"], Gc = ["value"], Kc = { class: "field" }, qc = ["disabled"], Jc = ["disabled"], Yc = ["disabled"], Xc = ["disabled"], Zc = { class: "row form-actions" }, Qc = ["disabled"], $c = ["disabled"], el = ["disabled"], tl = { class: "panel" }, nl = { class: "panel-head" }, rl = { class: "stats" }, il = { class: "train-action" }, al = ["disabled"], ol = { class: "panel-footer" }, sl = ["disabled"], cl = { class: "hero auto-hero" }, ll = { class: "panel" }, ul = { class: "toggle-list" }, dl = { class: "form-grid" }, fl = { class: "field" }, pl = { class: "field" }, ml = { class: "field wide" }, hl = ["value"], gl = { class: "field" }, _l = { class: "panel" }, vl = { class: "form-grid" }, yl = { class: "field" }, bl = { class: "field" }, xl = { class: "stats" }, Sl = { class: "format-value" }, Cl = { class: "format-value" }, wl = { class: "panel" }, Tl = { class: "form-grid" }, El = { class: "field wide" }, Dl = { class: "field wide" }, Ol = { class: "link-row" }, kl = ["disabled"], Al = ["disabled"], jl = { class: "toggle-list compact" }, Ml = { class: "panel action-panel" }, Nl = { class: "action-grid" }, Pl = ["disabled"], Fl = ["disabled"], Il = ["disabled"], Ll = ["disabled"], Rl = { class: "audit" }, zl = { class: "hero capture-hero" }, Bl = { class: "panel" }, Vl = { class: "panel-head" }, Hl = ["disabled"], Ul = { class: "stats" }, Wl = { class: "panel" }, Gl = { + key: 0, + class: "empty-state" +}, Kl = { + key: 1, + class: "audio-list" +}, ql = { + key: 0, + class: "meta-row" +}, Jl = { + key: 1, + class: "transcript" +}, Yl = { + key: 2, + class: "transcript" +}, Xl = ["src"], Zl = ["disabled", "onClick"], Ql = ["disabled", "onClick"], $l = ["disabled", "onClick"], eu = { class: "hero samples-hero" }, tu = { class: "pill hero-pill" }, nu = { class: "panel" }, ru = { class: "panel-head sample-head" }, iu = { class: "segment-control" }, au = { class: "row toolbar" }, ou = ["disabled"], su = ["disabled"], cu = ["disabled"], lu = { + key: 0, + class: "empty-state" +}, uu = { + key: 1, + class: "audio-list compact-list" +}, du = { class: "row" }, fu = { + key: 0, + class: "pill warning" +}, pu = ["src"], mu = ["onClick"], hu = ["onClick"], gu = ["disabled", "onClick"], _u = { + key: 2, + class: "pagination" +}, vu = ["disabled"], yu = ["disabled"], bu = { class: "panel" }, xu = { class: "dropzone" }, Su = ["disabled"], Cu = { class: "progress-card" }, wu = { class: "progress-track" }, Tu = { class: "hero data-hero" }, Eu = { class: "pill hero-pill" }, Du = { class: "panel" }, Ou = { class: "panel-head" }, ku = ["disabled"], Au = { class: "stats" }, ju = { class: "format-value" }, Mu = { + key: 0, + class: "data-warning" +}, Nu = { class: "panel-head" }, Pu = { class: "number" }, Fu = { class: "data-list" }, Iu = { class: "data-copy" }, Lu = { class: "data-title" }, Ru = { + key: 0, + class: "data-note" +}, zu = { class: "data-usage" }, Bu = ["disabled", "onClick"], Vu = { + key: 0, + class: "panel empty-state" +}, Hu = { class: "hero firmware-hero" }, Uu = { class: "panel" }, Wu = { class: "panel-head" }, Gu = ["disabled"], Ku = { + key: 0, + class: "empty-state" +}, qu = { + key: 1, + class: "word-list" +}, Ju = ["href"], Yu = { + key: 1, + class: "muted" +}, Xu = ["href"], Zu = { class: "meta-row" }, Qu = { key: 0 }, $u = { key: 1 }, ed = { key: 2 }, td = ["disabled", "onClick"], nd = { + class: "modal console-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Training console" +}, rd = { class: "modal-head" }, id = { class: "row console-actions" }, ad = { + class: "modal link-modal", + role: "dialog", + "aria-modal": "true", + "aria-label": "Link Tater" +}, od = { class: "modal-head" }, sd = { + key: 0, + class: "link-success" +}, cd = { + key: 1, + class: "stack" +}, ld = { class: "field" }, ud = { class: "field" }, dd = ["disabled"], fd = "/static/images/tater-wake-word-trainer.png", pd = 50, md = /* @__PURE__ */ fr({ + __name: "TrainerApp", + setup(e) { + let t = /* @__PURE__ */ F(null), n = /* @__PURE__ */ F(null), r = /* @__PURE__ */ F(!0), i = /* @__PURE__ */ F(""), a = /* @__PURE__ */ F(""), o = /* @__PURE__ */ F(!1), s = [ + { + id: "trainer", + label: "Trainer", + short: "Train" + }, + { + id: "auto", + label: "Auto Training", + short: "Auto" + }, + { + id: "firmware", + label: "Wake Words", + short: "Words" + }, + { + id: "captured", + label: "Captured Audio", + short: "Inbox" + }, + { + id: "samples", + label: "Samples", + short: "Samples" + }, + { + id: "data", + label: "Data", + short: "Data" + } + ], c = Y(() => { + let e = X.samplePage[X.sampleBucket]; + return As.value.slice(e * pd, (e + 1) * pd); + }), l = Y(() => Math.max(1, Math.ceil(As.value.length / pd))), u = Y(() => X.auto.state || {}), d = Y(() => X.auto.runtime || {}), f = Y(() => { + let e = u.value, t = []; + return e.last_review_result && t.push(`Last review: ${String(e.last_review_result).replaceAll("_", " ")}`), e.last_review_file && t.push(String(e.last_review_file)), e.last_review_transcript && t.push(`STT: “${e.last_review_transcript}”`), e.last_review_error && t.push(`Error: ${e.last_review_error}`), e.last_stt_engine && t.push(`STT engine: ${String(e.last_stt_engine).replaceAll("_", " ")}`), e.last_notify_at && t.push(e.last_notify_error ? `Publish failed: ${e.last_notify_error}` : `Wake word published ${uc(e.last_notify_at)}`), t.join(" · ") || "No automatic review has run yet."; + }), p = Y(() => X.training.running ? { + text: "Training running", + tone: "warning" + } : X.training.exit_code === 0 ? { + text: "Training finished", + tone: "success" + } : X.training.exit_code === null ? { + text: "Not started", + tone: "neutral" + } : { + text: `Exit ${X.training.exit_code}`, + tone: "error" + }), m = Y(() => d.value.review_running ? { + text: `Transcribing ${d.value.review_file || "wake"}`, + tone: "warning" + } : X.training.running && X.auto.config?.enabled ? { + text: "Training running", + tone: "warning" + } : X.auto.config?.enabled ? { + text: "Enabled", + tone: "success" + } : { + text: "Disabled", + tone: "neutral" + }), h = Y(() => X.training.log_lines?.length ? X.training.log_lines : ["No training output yet."]), g = Y(() => { + let e = /* @__PURE__ */ new Map(); + for (let t of X.managedData.items || []) { + let n = e.get(t.category) || []; + n.push(t), e.set(t.category, n); + } + return Array.from(e, ([e, t]) => ({ + name: e, + items: t + })); + }); + Nn(() => X.language, Bs), Nn(() => X.toast.serial, () => window.setTimeout(() => { + X.toast.message = ""; + }, 4500)), Nn(h, async () => { + r.value && (await hn(), r.value && n.value && (n.value.scrollTop = n.value.scrollHeight)); + }), Nn(() => X.consoleOpen, async (e) => { + e && (r.value = !0, await hn(), y()); + }), Dr(() => { + cc(), document.addEventListener("keydown", _); + }), Ar(() => { + lc(), document.removeEventListener("keydown", _); + }); + function _(e) { + e.key === "Escape" && (X.consoleOpen = !1, X.taterLinkOpen = !1, X.trimItem = null); + } + function v() { + let e = n.value; + if (!e) return; + let t = e.scrollHeight - e.clientHeight - e.scrollTop; + r.value = t <= 32; + } + function y() { + let e = n.value; + e && (r.value = !0, e.scrollTop = e.scrollHeight); + } + function b(e) { + X.activeView = e, (e === "auto" ? Zs(!1) : e === "captured" ? Hs() : e === "samples" ? Vs() : e === "firmware" ? nc() : e === "data" ? rc() : Promise.resolve()).catch((e) => $(e instanceof Error ? e.message : "Refresh failed.", "error")); + } + function x(e) { + X.sampleBucket = e; + } + function S(e, t) { + X.trimBucket = t, X.trimItem = e; + } + function C() { + i.value = X.autoForm.tater_url || "http://127.0.0.1:8501", a.value = "", o.value = !1, X.taterLinkOpen = !0, hn(() => document.querySelector("#pairing-code")?.focus()); + } + function w() { + let e = a.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8); + a.value = e.length > 4 ? `${e.slice(0, 4)}-${e.slice(4)}` : e; + } + async function ee() { + if (!i.value.trim() || !a.value.trim()) { + $("Tater address and pairing code are required.", "warning"); + return; + } + o.value = await ec(i.value, a.value); + } + function te(e) { + let t = []; + return e.source_device && t.push(String(e.source_device)), e.wake_word && t.push(String(e.wake_word)), e.max_probability !== null && e.max_probability !== void 0 && t.push(`max ${e.max_probability}`), e.average_probability !== null && e.average_probability !== void 0 && t.push(`avg ${e.average_probability}`), e.detection_profile && t.push(`profile ${String(e.detection_profile).replaceAll("_", " ")}`), e.auto_review_status && t.push(`auto ${String(e.auto_review_status).replaceAll("_", " ")}`), e.vad_max_probability !== null && e.vad_max_probability !== void 0 && t.push(`VAD ${e.vad_max_probability}`), t; + } + function ne(e) { + let t = []; + e.original_name && e.original_name !== e.saved_as && t.push(`From ${e.original_name}`); + let n = uc(e.reviewed_at || e.received_at || e.created_at); + return n && t.push(`Saved ${n}`), e.message && t.push(String(e.message)), e.auto_negative && t.push("Auto-reviewed false positive"), e.auto_positive && t.push("Auto-promoted close miss"), t.join(" · ") || "Training sample"; + } + function T(e) { + return String(e.json_url || e.url || e.jsonUrl || ""); + } + function re(e) { + return String(e.model_url || e.modelUrl || ""); + } + function E(e) { + let t = e.trim().toLowerCase(); + return /^(✓|✅)|success|finished/.test(t) ? "success" : /^(✗|❌)|error|failed|traceback/.test(t) ? "error" : /^(⚠|warning)/.test(t) ? "warning" : /^={4,}|^-----|^=====/.test(t) ? "heading" : ""; + } + return (e, d) => (U(), W("div", Dc, [ + d[113] ||= G("div", { + class: "ambient ambient-one", + "aria-hidden": "true" + }, null, -1), + d[114] ||= G("div", { + class: "ambient ambient-two", + "aria-hidden": "true" + }, null, -1), + G("header", Oc, [G("div", { class: "brand" }, [G("div", { + class: "brand-mark", + "aria-hidden": "true" + }, [G("img", { + src: fd, + alt: "" + })]), d[44] ||= G("div", null, [ + G("span", { class: "eyebrow" }, "Tater tools"), + G("h1", null, "Wake Word Studio"), + G("p", null, "Generate voices, curate real recordings, train, and publish.") + ], -1)]), G("div", kc, [d[45] ||= G("span", { class: "live-dot" }, [G("i"), da("Local trainer")], -1), I(X).session.safe_word ? (U(), W("span", Ac, k(I(X).session.safe_word) + " · " + k(I(X).language), 1)) : q("", !0)])]), + G("nav", jc, [(U(), W(V, null, Lr(s, (e) => G("button", { + key: e.id, + type: "button", + class: O({ active: I(X).activeView === e.id }), + onClick: (t) => b(e.id) + }, [ + G("span", Nc, k(e.label), 1), + G("span", Pc, k(e.short), 1), + e.id === "captured" && I(X).captured.captured_count ? (U(), W("b", Fc, k(I(X).captured.captured_count), 1)) : q("", !0) + ], 10, Mc)), 64))]), + G("main", Ic, [I(X).initialized ? (U(), W(V, { key: 1 }, [I(X).activeView === "trainer" ? (U(), W(V, { key: 0 }, [ + d[59] ||= fa("
Training studio

Build a personal wake word

Choose a multilingual voice route, check your real samples, then follow the model pipeline live.

1 Phrase2 Samples3 Train
", 1), + G("section", Rc, [ + G("header", zc, [ + d[47] ||= G("div", { class: "number" }, "1", -1), + d[48] ||= G("div", null, [G("h3", null, "Phrase + voice"), G("p", null, "The phrase and voice route lock while a session is active.")], -1), + G("span", { class: O(["pill", I(X).session.safe_word ? "success" : ""]) }, k(I(X).session.safe_word ? `Session · ${I(X).session.safe_word}` : "No session"), 3) + ]), + G("div", Bc, [ + G("label", Vc, [d[49] ||= G("span", null, "Wake phrase", -1), R(G("input", { + "onUpdate:modelValue": d[0] ||= (e) => I(X).phrase = e, + type: "text", + placeholder: "e.g. \"hey tater\"", + disabled: !!I(X).session.safe_word || I(Z)("session"), + onKeyup: d[1] ||= ss((...e) => I(Ls) && I(Ls)(...e), ["enter"]) + }, null, 40, Hc), [[Xo, I(X).phrase]])]), + G("label", Uc, [ + d[50] ||= G("span", null, "Language", -1), + R(G("select", { + "onUpdate:modelValue": d[2] ||= (e) => I(X).language = e, + disabled: !!I(X).session.safe_word || I(Z)("session") + }, [(U(!0), W(V, null, Lr(I(X).languages, (e) => (U(), W("option", { + key: e.code, + value: e.code + }, k(e.label), 9, Gc))), 128))], 8, Wc), [[$o, I(X).language]]), + G("small", null, k(I(Os)), 1) + ]), + G("label", Kc, [ + d[51] ||= G("span", null, "TTS source", -1), + R(G("select", { + "onUpdate:modelValue": d[3] ||= (e) => I(X).ttsMode = e, + disabled: !!I(X).session.safe_word || I(Z)("session") + }, [ + G("option", { + value: "hybrid", + disabled: !I(X).languages.find((e) => e.code === I(X).language)?.engines?.includes("piper") + }, "Four-provider ensemble · recommended", 8, Jc), + G("option", { + value: "modern", + disabled: !I(X).languages.find((e) => e.code === I(X).language)?.engines?.some((e) => e !== "piper") + }, "Modern only · no Piper", 8, Yc), + G("option", { + value: "piper", + disabled: !I(X).languages.find((e) => e.code === I(X).language)?.engines?.includes("piper") + }, "Piper only · legacy", 8, Xc) + ], 8, qc), [[$o, I(X).ttsMode]]), + d[52] ||= G("small", null, "Models download once and stay cached.", -1) + ]) + ]), + G("div", Zc, [I(X).session.safe_word ? (U(), W("button", { + key: 1, + type: "button", + class: "button danger", + disabled: I(Z)("session"), + onClick: d[5] ||= (...e) => I(Rs) && I(Rs)(...e) + }, k(I(Z)("session") ? "Stopping…" : I(X).training.running ? "Stop session + training" : "Stop session"), 9, $c)) : (U(), W("button", { + key: 0, + type: "button", + class: "button primary", + disabled: I(Z)("session") || !I(X).phrase.trim(), + onClick: d[4] ||= (...e) => I(Ls) && I(Ls)(...e) + }, k(I(Z)("session") ? "Starting…" : "Start session"), 9, Qc)), G("button", { + type: "button", + disabled: !I(X).phrase.trim(), + onClick: d[6] ||= (...e) => I(zs) && I(zs)(...e) + }, "System preview", 8, el)]) + ]), + G("section", tl, [ + G("header", nl, [ + d[53] ||= G("div", { class: "number" }, "2", -1), + d[54] ||= G("div", null, [G("h3", null, "Train wake word"), G("p", null, "Personal positives and reviewed false-wake negatives are automatically included.")], -1), + G("span", { class: O(["pill", p.value.tone]) }, k(p.value.text), 3) + ]), + G("div", rl, [ + G("article", null, [d[55] ||= G("span", null, "Positive samples", -1), G("strong", null, k(I(Ts)), 1)]), + G("article", null, [d[56] ||= G("span", null, "Negative samples", -1), G("strong", null, k(I(Es)), 1)]), + d[57] ||= G("article", null, [G("span", null, "Training format"), G("strong", { class: "format-value" }, "16 kHz · mono · WAV")], -1) + ]), + G("div", il, [G("button", { + type: "button", + class: "button primary large", + disabled: !I(X).session.safe_word || I(X).training.running || I(Z)("training-start"), + onClick: d[7] ||= (...e) => I(oc) && I(oc)(...e) + }, k(I(X).training.running ? "Training in progress" : "Start training"), 9, al)]), + G("footer", ol, [d[58] ||= G("span", null, "Training opens the console automatically and continues if the window is closed.", -1), G("button", { + type: "button", + disabled: !I(ks), + onClick: d[8] ||= (e) => I(X).consoleOpen = !0 + }, "Open console", 8, sl)]) + ]) + ], 64)) : I(X).activeView === "auto" ? (U(), W(V, { key: 1 }, [ + G("section", cl, [d[60] ||= G("div", null, [ + G("span", { class: "eyebrow" }, "False-positive loop"), + G("h2", null, "Auto Training"), + G("p", null, "Transcribe captures, sort negatives, recover close misses, retrain on schedule, and publish through Tater.") + ], -1), G("span", { class: O(["pill hero-pill", m.value.tone]) }, k(m.value.text), 3)]), + G("section", ll, [ + d[68] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "1"), G("div", null, [G("h3", null, "Review rules"), G("p", null, "Conservative local STT keeps uncertain clips in the manual inbox.")])], -1), + G("div", ul, [ + G("label", null, [R(G("input", { + "onUpdate:modelValue": d[9] ||= (e) => I(X).autoForm.enabled = e, + type: "checkbox" + }, null, 512), [[Zo, I(X).autoForm.enabled]]), d[61] ||= G("span", null, [G("strong", null, "Enable Auto Training"), G("small", null, "Queue eligible wake triggers for local transcription.")], -1)]), + G("label", null, [R(G("input", { + "onUpdate:modelValue": d[10] ||= (e) => I(X).autoForm.delete_confirmed_wakes = e, + type: "checkbox" + }, null, 512), [[Zo, I(X).autoForm.delete_confirmed_wakes]]), d[62] ||= G("span", null, [G("strong", null, "Delete confirmed good wakes"), G("small", null, "Remove normal triggers when STT confirms the phrase.")], -1)]), + G("label", null, [R(G("input", { + "onUpdate:modelValue": d[11] ||= (e) => I(X).autoForm.promote_close_misses = e, + type: "checkbox" + }, null, 512), [[Zo, I(X).autoForm.promote_close_misses]]), d[63] ||= G("span", null, [G("strong", null, "Promote confirmed close misses"), G("small", null, "Move verified close misses into positive samples.")], -1)]) + ]), + G("div", dl, [ + G("label", fl, [d[64] ||= G("span", null, "Wake phrase", -1), R(G("input", { + "onUpdate:modelValue": d[12] ||= (e) => I(X).autoForm.wake_phrase = e, + type: "text" + }, null, 512), [[Xo, I(X).autoForm.wake_phrase]])]), + G("label", pl, [d[65] ||= G("span", null, "STT language", -1), R(G("input", { + "onUpdate:modelValue": d[13] ||= (e) => I(X).autoForm.language = e, + type: "text" + }, null, 512), [[Xo, I(X).autoForm.language]])]), + G("label", ml, [ + d[66] ||= G("span", null, "STT engine", -1), + R(G("select", { "onUpdate:modelValue": d[14] ||= (e) => I(X).autoForm.stt_engine = e }, [(U(!0), W(V, null, Lr(I(Ms), (e) => (U(), W("option", { + key: e.id || e.value, + value: e.id || e.value + }, k(e.label || e.name || e.id), 9, hl))), 128))], 512), [[$o, I(X).autoForm.stt_engine]]), + G("small", null, k(I(Ms).find((e) => (e.id || e.value) === I(X).autoForm.stt_engine)?.description || "Runs locally on this trainer."), 1) + ]), + G("label", gl, [d[67] ||= G("span", null, "Minimum transcript characters", -1), R(G("input", { + "onUpdate:modelValue": d[15] ||= (e) => I(X).autoForm.minimum_transcript_chars = e, + min: "1", + max: "100", + type: "number" + }, null, 512), [[ + Xo, + I(X).autoForm.minimum_transcript_chars, + void 0, + { number: !0 } + ]])]) + ]) + ]), + G("section", _l, [ + d[75] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "2"), G("div", null, [G("h3", null, "Training schedule"), G("p", null, "A run starts only after enough newly reviewed negatives accumulate.")])], -1), + G("div", vl, [G("label", yl, [d[70] ||= G("span", null, "Run training", -1), R(G("select", { "onUpdate:modelValue": d[16] ||= (e) => I(X).autoForm.schedule_hours = e }, [...d[69] ||= [ + G("option", { value: 0 }, "Manually only", -1), + G("option", { value: 6 }, "Every 6 hours", -1), + G("option", { value: 12 }, "Every 12 hours", -1), + G("option", { value: 24 }, "Every day", -1), + G("option", { value: 48 }, "Every 2 days", -1), + G("option", { value: 168 }, "Every week", -1) + ]], 512), [[ + $o, + I(X).autoForm.schedule_hours, + void 0, + { number: !0 } + ]])]), G("label", bl, [d[71] ||= G("span", null, "Minimum new negatives", -1), R(G("input", { + "onUpdate:modelValue": d[17] ||= (e) => I(X).autoForm.minimum_new_negatives = e, + min: "1", + max: "10000", + type: "number" + }, null, 512), [[ + Xo, + I(X).autoForm.minimum_new_negatives, + void 0, + { number: !0 } + ]])])]), + G("div", xl, [ + G("article", null, [d[72] ||= G("span", null, "Pending negatives", -1), G("strong", null, k(Number(u.value.pending_negative_count || 0)), 1)]), + G("article", null, [d[73] ||= G("span", null, "Next check", -1), G("strong", Sl, k(u.value.next_run_at ? I(uc)(u.value.next_run_at) : "Manual"), 1)]), + G("article", null, [d[74] ||= G("span", null, "Last training", -1), G("strong", Cl, k(u.value.last_train_finished_at ? I(uc)(u.value.last_train_finished_at) : "Never"), 1)]) + ]) + ]), + G("section", wl, [ + d[79] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "3"), G("div", null, [G("h3", null, "Publish to Tater"), G("p", null, "Securely activate successful models across every connected satellite.")])], -1), + G("div", Tl, [G("label", El, [ + d[76] ||= G("span", null, "Trainer public URL", -1), + R(G("input", { + "onUpdate:modelValue": d[18] ||= (e) => I(X).autoForm.advertised_base_url = e, + type: "text", + placeholder: "Auto-detect LAN address" + }, null, 512), [[Xo, I(X).autoForm.advertised_base_url]]), + G("small", null, k(I(X).autoForm.advertised_base_url ? `Configured: ${I(X).autoForm.advertised_base_url}` : `Detected: ${I(X).auto.advertised_base_url || "unavailable"}`), 1) + ]), G("label", Dl, [d[77] ||= G("span", null, "Tater URL", -1), R(G("input", { + "onUpdate:modelValue": d[19] ||= (e) => I(X).autoForm.tater_url = e, + type: "text" + }, null, 512), [[Xo, I(X).autoForm.tater_url]])])]), + G("div", Ol, [ + G("span", { class: O(["pill", I(js) ? "success" : "warning"]) }, k(I(js) ? `Linked${I(X).auto.trainer_link?.tater_name ? ` · ${I(X).auto.trainer_link.tater_name}` : ""}` : "Not linked"), 3), + G("button", { + type: "button", + class: "button primary", + disabled: I(Z)("auto"), + onClick: C + }, k(I(js) ? "Relink Tater" : "Link Tater"), 9, kl), + I(js) ? (U(), W("button", { + key: 0, + type: "button", + class: "button danger", + disabled: I(Z)("auto"), + onClick: d[20] ||= (...e) => I(tc) && I(tc)(...e) + }, "Unlink", 8, Al)) : q("", !0) + ]), + G("div", jl, [G("label", null, [R(G("input", { + "onUpdate:modelValue": d[21] ||= (e) => I(X).autoForm.notify_satellites = e, + type: "checkbox" + }, null, 512), [[Zo, I(X).autoForm.notify_satellites]]), d[78] ||= G("span", null, [G("strong", null, "Activate after successful training"), G("small", null, "Tater applies the new word globally.")], -1)])]) + ]), + G("section", Ml, [G("div", Nl, [ + G("button", { + type: "button", + class: "button primary", + disabled: I(Z)("auto"), + onClick: d[22] ||= (...e) => I(Qs) && I(Qs)(...e) + }, "Save Auto Training", 8, Pl), + G("button", { + type: "button", + disabled: I(Z)("auto"), + onClick: d[23] ||= (e) => I($s)("review_now") + }, "Review inbox now", 8, Fl), + G("button", { + type: "button", + disabled: I(Z)("auto") || I(X).training.running, + onClick: d[24] ||= (e) => I($s)("train_now") + }, "Train now", 8, Il), + G("button", { + type: "button", + disabled: I(Z)("auto") || !I(js), + onClick: d[25] ||= (e) => I($s)("notify_now") + }, "Publish current word", 8, Ll) + ]), G("p", Rl, k(f.value), 1)]) + ], 64)) : I(X).activeView === "captured" ? (U(), W(V, { key: 2 }, [ + G("section", zl, [d[80] ||= G("div", null, [ + G("span", { class: "eyebrow" }, "Capture review"), + G("h2", null, "Captured Audio"), + G("p", null, "Listen to clips from your satellites and turn every real-world event into a better model.") + ], -1), G("span", { class: O(["pill hero-pill", I(X).captured.captured_count ? "warning" : ""]) }, k(I(X).captured.captured_count ? `${I(X).captured.captured_count} waiting` : "Inbox idle"), 3)]), + G("section", Bl, [G("header", Vl, [ + d[81] ||= G("div", { class: "number" }, "1", -1), + d[82] ||= G("div", null, [G("h3", null, "Review queue"), G("p", null, "Approve good phrases, keep false positives as negatives, or discard noise.")], -1), + G("button", { + type: "button", + disabled: I(Z)("captured"), + onClick: d[26] ||= (e) => I(Hs)() + }, k(I(Z)("captured") ? "Refreshing…" : "Refresh inbox"), 9, Hl) + ]), G("div", Ul, [ + G("article", null, [d[83] ||= G("span", null, "Inbox", -1), G("strong", null, k(I(X).captured.captured_count), 1)]), + G("article", null, [d[84] ||= G("span", null, "Reviewed negatives", -1), G("strong", null, k(I(Es)), 1)]), + G("article", null, [d[85] ||= G("span", null, "Personal samples", -1), G("strong", null, k(I(Ts)), 1)]) + ])]), + G("section", Wl, [d[88] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "2"), G("div", null, [G("h3", null, "Listen + sort"), G("p", null, "Metadata remains visible so borderline detections are easy to understand.")])], -1), I(X).captured.items?.length ? (U(), W("div", Kl, [(U(!0), W(V, null, Lr(I(X).captured.items, (e) => (U(), W("article", { + key: e.saved_as, + class: "audio-card" + }, [ + G("header", null, [G("div", null, [G("strong", null, k(e.original_name || e.saved_as), 1), G("small", null, k(I(uc)(e.captured_at || e.received_at)) + " " + k(e.message || ""), 1)]), G("span", { class: O(["pill", I(pc)(e).tone]) }, k(I(pc)(e).label), 3)]), + te(e).length ? (U(), W("div", ql, [(U(!0), W(V, null, Lr(te(e), (e) => (U(), W("span", { key: e }, k(e), 1))), 128))])) : q("", !0), + e.transcript ? (U(), W("div", Jl, [d[86] ||= G("b", null, "STT", -1), da(" " + k(e.transcript), 1)])) : q("", !0), + e.auto_review_guided_transcript ? (U(), W("div", Yl, [d[87] ||= G("b", null, "Guided wake check", -1), da(" " + k(e.auto_review_guided_transcript), 1)])) : q("", !0), + G("audio", { + controls: "", + preload: "none", + src: I(mc)(e, "captured") + }, null, 8, Xl), + G("footer", null, [G("span", null, k(e.saved_as) + " · " + k(I(fc)(e.final_format)), 1), G("div", null, [ + G("button", { + type: "button", + disabled: I(Z)("review"), + onClick: (t) => I(Ks)(e, "approve_personal") + }, "Add positive", 8, Zl), + G("button", { + type: "button", + disabled: I(Z)("review"), + onClick: (t) => I(Ks)(e, "mark_negative") + }, "Mark negative", 8, Ql), + G("button", { + type: "button", + class: "button danger ghost", + disabled: I(Z)("review"), + onClick: (t) => I(Ks)(e, "discard") + }, "Discard", 8, $l) + ])]) + ]))), 128))])) : (U(), W("div", Gl, "No captured audio yet. Clips sent by satellites will appear here."))]) + ], 64)) : I(X).activeView === "samples" ? (U(), W(V, { key: 3 }, [ + G("section", eu, [d[89] ||= G("div", null, [ + G("span", { class: "eyebrow" }, "Sample library"), + G("h2", null, "Current Training Samples"), + G("p", null, "Audit positives and negatives, trim recordings precisely, and import seed audio.") + ], -1), G("span", tu, k(I(Ts) + I(Es)) + " total", 1)]), + G("section", nu, [ + G("header", ru, [ + d[92] ||= G("div", { class: "number" }, "1", -1), + d[93] ||= G("div", null, [G("h3", null, "Saved samples"), G("p", null, "Personal clips are positives. Negative clips are false wakes and hard negatives.")], -1), + G("div", iu, [G("button", { + type: "button", + class: O({ active: I(X).sampleBucket === "personal" }), + onClick: d[27] ||= (e) => x("personal") + }, [d[90] ||= da("Personal ", -1), G("b", null, k(I(Ts)), 1)], 2), G("button", { + type: "button", + class: O({ active: I(X).sampleBucket === "negative" }), + onClick: d[28] ||= (e) => x("negative") + }, [d[91] ||= da("Negative ", -1), G("b", null, k(I(Es)), 1)], 2)]) + ]), + G("div", au, [ + G("button", { + type: "button", + disabled: I(Z)("samples"), + onClick: d[29] ||= (e) => I(Vs)() + }, "Refresh", 8, ou), + G("button", { + type: "button", + class: "button danger ghost", + disabled: I(Z)("review") || I(Ts) === 0, + onClick: d[30] ||= (e) => I(Ys)("personal") + }, "Clear positives", 8, su), + G("button", { + type: "button", + class: "button danger ghost", + disabled: I(Z)("review") || I(Es) === 0, + onClick: d[31] ||= (e) => I(Ys)("negative") + }, "Clear negatives", 8, cu) + ]), + I(As).length ? (U(), W("div", uu, [(U(!0), W(V, null, Lr(c.value, (e) => (U(), W("article", { + key: e.saved_as, + class: "audio-card" + }, [ + G("header", null, [G("div", null, [G("strong", null, k(e.saved_as), 1), G("small", null, k(ne(e)), 1)]), G("div", du, [e.trimmed ? (U(), W("span", fu, "Trimmed")) : q("", !0), G("span", { class: O(["pill", I(X).sampleBucket === "personal" ? "success" : "error"]) }, k(I(X).sampleBucket === "personal" ? "Positive" : "Negative"), 3)])]), + G("audio", { + controls: "", + preload: "none", + src: I(mc)(e, I(X).sampleBucket) + }, null, 8, pu), + G("footer", null, [G("span", null, k(I(fc)(e.final_format)), 1), G("div", null, [ + G("button", { + type: "button", + onClick: (t) => S(e, I(X).sampleBucket) + }, "Trim", 8, mu), + e.trimmed ? (U(), W("button", { + key: 0, + type: "button", + onClick: (t) => I(Js)(e, I(X).sampleBucket) + }, "Revert", 8, hu)) : q("", !0), + G("button", { + type: "button", + class: "button danger ghost", + disabled: I(Z)("review"), + onClick: (t) => I(qs)(e, I(X).sampleBucket) + }, "Remove", 8, gu) + ])]) + ]))), 128))])) : (U(), W("div", lu, "No " + k(I(X).sampleBucket) + " samples saved yet.", 1)), + l.value > 1 ? (U(), W("div", _u, [ + G("button", { + type: "button", + disabled: I(X).samplePage[I(X).sampleBucket] === 0, + onClick: d[32] ||= (e) => I(X).samplePage[I(X).sampleBucket]-- + }, "Previous", 8, vu), + G("span", null, "Page " + k(I(X).samplePage[I(X).sampleBucket] + 1) + " of " + k(l.value), 1), + G("button", { + type: "button", + disabled: I(X).samplePage[I(X).sampleBucket] >= l.value - 1, + onClick: d[33] ||= (e) => I(X).samplePage[I(X).sampleBucket]++ + }, "Next", 8, yu) + ])) : q("", !0) + ]), + G("section", bu, [ + d[95] ||= G("header", { class: "panel-head" }, [G("div", { class: "number" }, "2"), G("div", null, [G("h3", null, "Manual sample import"), G("p", null, "Optional seed recordings are normalized to the trainer’s required WAV format.")])], -1), + G("label", xu, [ + G("input", { + ref_key: "uploadInput", + ref: t, + type: "file", + multiple: "", + accept: "audio/*,.wav,.mp3,.m4a,.flac,.ogg,.aac,.webm,.opus", + onChange: d[34] ||= (...e) => I(Us) && I(Us)(...e) + }, null, 544), + d[94] ||= G("span", null, [G("strong", null, "Choose one or many audio files"), G("small", null, "WAV, MP3, M4A, FLAC, OGG, AAC, OPUS, and WEBM")], -1), + G("b", null, k(I(X).selectedFiles.length ? `${I(X).selectedFiles.length} selected` : "Browse"), 1) + ]), + G("button", { + type: "button", + class: "button primary", + disabled: !I(X).session.safe_word || !I(X).selectedFiles.length || I(Z)("upload"), + onClick: d[35] ||= (e) => I(Gs)(t.value) + }, k(I(Z)("upload") ? "Uploading…" : "Upload selected samples"), 9, Su), + G("div", Cu, [ + G("div", null, [G("strong", null, k(I(X).uploadLabel), 1), G("span", null, k(I(X).uploadProgress) + "%", 1)]), + G("div", wu, [G("i", { style: fe({ width: `${I(X).uploadProgress}%` }) }, null, 4)]), + G("small", null, k(I(X).uploadDetail), 1) + ]) + ]) + ], 64)) : I(X).activeView === "data" ? (U(), W(V, { key: 4 }, [ + G("section", Tu, [d[96] ||= G("div", null, [ + G("span", { class: "eyebrow" }, "Local storage"), + G("h2", null, "Data Management"), + G("p", null, "See exactly what the trainer has downloaded, generated, recorded, and produced.") + ], -1), G("span", Eu, k(I(dc)(I(X).managedData.total_size_bytes)) + " total", 1)]), + G("section", Du, [ + G("header", Ou, [ + d[97] ||= G("div", { class: "number" }, "i", -1), + d[98] ||= G("div", null, [G("h3", null, "Trainer storage"), G("p", null, "Deleting an item is permanent. Required downloads and generated caches will be rebuilt the next time training needs them.")], -1), + G("button", { + type: "button", + disabled: I(Z)("data") || I(Z)("data-delete"), + onClick: d[36] ||= (e) => I(rc)() + }, k(I(Z)("data") ? "Scanning…" : "Refresh sizes"), 9, ku) + ]), + G("div", Au, [ + G("article", null, [d[99] ||= G("span", null, "Space used", -1), G("strong", ju, k(I(dc)(I(X).managedData.total_size_bytes)), 1)]), + G("article", null, [d[100] ||= G("span", null, "Files", -1), G("strong", null, k(Number(I(X).managedData.total_file_count || 0).toLocaleString()), 1)]), + G("article", null, [d[101] ||= G("span", null, "Individual items", -1), G("strong", null, k(I(X).managedData.items.length), 1)]) + ]), + I(X).training.running ? (U(), W("p", Mu, "Stop the active training session before deleting data.")) : q("", !0) + ]), + (U(!0), W(V, null, Lr(g.value, (e, t) => (U(), W("section", { + key: e.name, + class: "panel data-panel" + }, [G("header", Nu, [G("div", Pu, k(t + 1), 1), G("div", null, [G("h3", null, k(e.name), 1), G("p", null, k(e.items.length) + " separately managed item" + k(e.items.length === 1 ? "" : "s"), 1)])]), G("div", Fu, [(U(!0), W(V, null, Lr(e.items, (e) => (U(), W("article", { + key: e.id, + class: O(["data-row", { empty: !e.file_count }]) + }, [ + G("div", Iu, [ + G("div", Lu, [G("strong", null, k(e.label), 1), G("code", null, k(e.location), 1)]), + G("small", null, k(e.description), 1), + e.rebuild_note ? (U(), W("span", Ru, k(e.rebuild_note), 1)) : q("", !0) + ]), + G("div", zu, [G("strong", null, k(I(dc)(e.size_bytes)), 1), G("span", null, k(Number(e.file_count || 0).toLocaleString()) + " file" + k(e.file_count === 1 ? "" : "s"), 1)]), + G("button", { + type: "button", + class: "button danger ghost", + disabled: !e.file_count || I(X).training.running || I(Z)("data") || I(Z)("data-delete"), + onClick: (t) => I(ic)(e) + }, k(I(Z)("data-delete") ? "Please wait…" : "Delete"), 9, Bu) + ], 2))), 128))])]))), 128)), + !I(Z)("data") && !I(X).managedData.items.length ? (U(), W("section", Vu, "No managed trainer data was found.")) : q("", !0) + ], 64)) : I(X).activeView === "firmware" ? (U(), W(V, { key: 5 }, [ + G("section", Hu, [d[102] ||= G("div", null, [ + G("span", { class: "eyebrow" }, "Wake-word catalog"), + G("h2", null, "Trained Wake Words"), + G("p", null, "Copy a local JSON package URL into Tater to switch every native satellite live.") + ], -1), G("span", { class: O(["pill hero-pill", I(X).wakeWords.length ? "success" : "warning"]) }, k(I(X).wakeWords.length ? `${I(X).wakeWords.length} trained` : "Catalog empty"), 3)]), + d[105] ||= G("div", { class: "native-notice" }, [G("strong", null, "Tater Native"), G("span", null, "These packages include model metadata and a direct model URL for live satellite updates.")], -1), + G("section", Uu, [G("header", Wu, [ + d[103] ||= G("div", { class: "number" }, "v1", -1), + d[104] ||= G("div", null, [G("h3", null, "Published model URLs"), G("p", null, "URLs stay local and are refreshed after each successful run.")], -1), + G("button", { + type: "button", + disabled: I(Z)("firmware"), + onClick: d[37] ||= (e) => I(nc)() + }, "Refresh", 8, Gu) + ]), I(X).wakeWords.length ? (U(), W("div", qu, [(U(!0), W(V, null, Lr(I(X).wakeWords, (e) => (U(), W("article", { key: e.key || T(e) }, [G("div", null, [ + G("strong", null, k(e.label || e.name || "Trained wake word"), 1), + T(e) ? (U(), W("a", { + key: 0, + href: T(e), + target: "_blank", + rel: "noreferrer" + }, "JSON · " + k(T(e)), 9, Ju)) : (U(), W("span", Yu, "JSON package URL unavailable")), + re(e) ? (U(), W("a", { + key: 2, + href: re(e), + target: "_blank", + rel: "noreferrer" + }, "Model · " + k(re(e)), 9, Xu)) : q("", !0), + G("div", Zu, [ + e.language ? (U(), W("span", Qu, k(e.language), 1)) : q("", !0), + e.trained_at ? (U(), W("span", $u, k(I(uc)(e.trained_at)), 1)) : q("", !0), + e.recall === void 0 ? q("", !0) : (U(), W("span", ed, "recall " + k(e.recall), 1)) + ]) + ]), G("button", { + type: "button", + disabled: !T(e), + onClick: (t) => I(ac)(T(e)) + }, "Copy URL", 8, td)]))), 128))])) : (U(), W("div", Ku, "Train a wake word and its package will appear here."))]) + ], 64)) : q("", !0)], 64)) : (U(), W("div", Lc, [...d[46] ||= [G("span", { class: "spinner" }, null, -1), G("strong", null, "Connecting to the local trainer…", -1)]]))]), + (U(), ra(Jn, { to: "body" }, [I(X).consoleOpen ? (U(), W("div", { + key: 0, + class: "modal-backdrop console-backdrop", + onClick: d[39] ||= as((e) => I(X).consoleOpen = !1, ["self"]) + }, [G("section", nd, [G("header", rd, [d[106] ||= G("div", null, [ + G("span", { class: "eyebrow" }, "Live pipeline"), + G("h2", null, "Training Console"), + G("p", null, "Closing this window does not interrupt training.") + ], -1), G("div", id, [ + r.value ? q("", !0) : (U(), W("button", { + key: 0, + type: "button", + class: "console-follow", + onClick: y + }, "Jump to latest")), + G("span", { class: O(["pill", p.value.tone]) }, k(p.value.text), 3), + G("button", { + type: "button", + onClick: d[38] ||= (e) => I(X).consoleOpen = !1 + }, "Close") + ])]), G("pre", { + ref_key: "consoleLog", + ref: n, + class: "console-log", + onScrollPassive: v + }, [(U(!0), W(V, null, Lr(h.value, (e, t) => (U(), W("span", { + key: `${t}-${e}`, + class: O(E(e)) + }, k(e), 3))), 128))], 544)])])) : q("", !0)])), + (U(), ra(Jn, { to: "body" }, [I(X).taterLinkOpen ? (U(), W("div", { + key: 0, + class: "modal-backdrop", + onClick: d[43] ||= as((e) => I(X).taterLinkOpen = !1, ["self"]) + }, [G("section", ad, [G("header", od, [G("div", null, [ + d[107] ||= G("span", { class: "eyebrow" }, "Secure pairing", -1), + G("h2", null, k(o.value ? "Tater linked" : "Link Tater"), 1), + G("p", null, k(o.value ? "This trainer can securely publish wake-word updates." : "Enter the short-lived code shown in Tater Voice Settings."), 1) + ]), G("button", { + type: "button", + onClick: d[40] ||= (e) => I(X).taterLinkOpen = !1 + }, "Close")]), o.value ? (U(), W("div", sd, [ + d[108] ||= G("i", null, "✓", -1), + G("strong", null, "Successfully linked" + k(I(X).auto.trainer_link?.tater_name ? ` to ${I(X).auto.trainer_link.tater_name}` : ""), 1), + d[109] ||= G("span", null, "The private link key is stored locally and is never displayed.", -1) + ])) : (U(), W("div", cd, [ + G("label", ld, [d[110] ||= G("span", null, "Tater address", -1), R(G("input", { + "onUpdate:modelValue": d[41] ||= (e) => i.value = e, + type: "text" + }, null, 512), [[Xo, i.value]])]), + G("label", ud, [d[111] ||= G("span", null, "Tater pairing code", -1), R(G("input", { + id: "pairing-code", + "onUpdate:modelValue": d[42] ||= (e) => a.value = e, + class: "pairing-code", + maxlength: "9", + placeholder: "ABCD-EFGH", + autocomplete: "off", + onInput: w + }, null, 544), [[Xo, a.value]])]), + d[112] ||= G("small", null, "In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.", -1), + G("button", { + type: "button", + class: "button primary", + disabled: I(Z)("link"), + onClick: ee + }, k(I(Z)("link") ? "Linking securely…" : "Link Tater"), 9, dd) + ]))])])) : q("", !0)])), + K(Ec), + K($a, { name: "toast" }, { + default: Dn(() => [I(X).toast.message ? (U(), W("div", { + key: 0, + class: O(["toast", I(X).toast.tone]), + role: "status" + }, k(I(X).toast.message), 3)) : q("", !0)]), + _: 1 + }) + ])); + } +}), hd = document.getElementById("trainer-app"); +if (!hd) throw Error("Missing #trainer-app mount point"); +ds(md).mount(hd); +//#endregion diff --git a/tests/test_auto_train.py b/tests/test_auto_train.py index 94f0502..c2898b8 100644 --- a/tests/test_auto_train.py +++ b/tests/test_auto_train.py @@ -292,13 +292,13 @@ class AutoTrainTests(unittest.TestCase): ) def test_ui_exposes_engine_selector_without_manual_runtime_fields(self): - source = (Path(__file__).resolve().parents[1] / "static" / "index.html").read_text( + source = (Path(__file__).resolve().parents[1] / "frontend" / "src" / "TrainerApp.vue").read_text( encoding="utf-8" ) - self.assertIn('id="autoSttEngine"', source) - self.assertNotIn('id="autoSttModel"', source) - self.assertNotIn('id="autoSttDevice"', source) - self.assertNotIn('id="autoSttComputeType"', source) + self.assertIn('v-model="trainer.autoForm.stt_engine"', source) + self.assertNotIn('trainer.autoForm.stt_model', source) + self.assertNotIn('trainer.autoForm.stt_device', source) + self.assertNotIn('trainer.autoForm.stt_compute_type', source) self.assertIn("Guided wake check", source) def test_phrase_miss_moves_wake_trigger_to_negative_samples(self): @@ -566,6 +566,24 @@ class AutoTrainTests(unittest.TestCase): }, ) + def test_trained_word_catalog_keeps_url_alias_for_json_package(self): + with tempfile.TemporaryDirectory() as directory: + trained_dir = Path(directory) + (trained_dir / "hey_tater.tflite").write_bytes(b"model") + (trained_dir / "hey_tater.json").write_text( + json.dumps({"wake_word": "hey tater", "model": "hey_tater.tflite"}), + encoding="utf-8", + ) + with ( + patch.object(trainer, "TRAINED_WAKE_WORDS_DIR", trained_dir), + patch.object(trainer, "_sync_trained_wake_word_artifacts"), + ): + rows = trainer._list_trained_wake_words("http://10.4.20.210:8789") + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["url"], rows[0]["json_url"]) + self.assertTrue(rows[0]["json_url"].endswith("/api/trained_wake_words/hey_tater.json")) + def test_tater_notification_fails_when_trained_word_is_missing(self): trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token" with ( diff --git a/tests/test_data_management.py b/tests/test_data_management.py new file mode 100644 index 0000000..b0779d4 --- /dev/null +++ b/tests/test_data_management.py @@ -0,0 +1,83 @@ +import tempfile +import unittest +from pathlib import Path + +import trainer_server as trainer + + +class DataManagementTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + root = Path(self.tempdir.name) + self.original_paths = { + "DATA_DIR": trainer.DATA_DIR, + "PERSONAL_DIR": trainer.PERSONAL_DIR, + "CAPTURED_DIR": trainer.CAPTURED_DIR, + "NEGATIVE_DIR": trainer.NEGATIVE_DIR, + "TRIM_HISTORY_DIR": trainer.TRIM_HISTORY_DIR, + "TRAINED_WAKE_WORDS_DIR": trainer.TRAINED_WAKE_WORDS_DIR, + "AUTO_TRAIN_MODEL_DIR": trainer.AUTO_TRAIN_MODEL_DIR, + "PIPER_ROOT": trainer.PIPER_ROOT, + "PIPER_VOICES_DIR": trainer.PIPER_VOICES_DIR, + "PIPER_CATALOG_CACHE_FILE": trainer.PIPER_CATALOG_CACHE_FILE, + "OMNIVOICE_CATALOG_CACHE_FILE": trainer.OMNIVOICE_CATALOG_CACHE_FILE, + } + trainer.DATA_DIR = root + trainer.PERSONAL_DIR = root / "personal_samples" + trainer.CAPTURED_DIR = root / "captured_audio" + trainer.NEGATIVE_DIR = root / "negative_samples" + trainer.TRIM_HISTORY_DIR = root / "trim_history" + trainer.TRAINED_WAKE_WORDS_DIR = root / "trained_wake_words" + trainer.AUTO_TRAIN_MODEL_DIR = root / "auto_train_models" + trainer.PIPER_ROOT = root / "tools" / "piper-sample-generator" + trainer.PIPER_VOICES_DIR = trainer.PIPER_ROOT / "voices" + trainer.PIPER_CATALOG_CACHE_FILE = root / ".cache" / "piper_voices_catalog.json" + trainer.OMNIVOICE_CATALOG_CACHE_FILE = root / ".cache" / "omnivoice_languages.json" + self.original_training_running = trainer.STATE["training"]["running"] + self.original_review_running = trainer.AUTO_TRAIN_RUNTIME["review_running"] + trainer.STATE["training"]["running"] = False + trainer.AUTO_TRAIN_RUNTIME["review_running"] = False + + def tearDown(self): + for name, value in self.original_paths.items(): + setattr(trainer, name, value) + trainer.STATE["training"]["running"] = self.original_training_running + trainer.AUTO_TRAIN_RUNTIME["review_running"] = self.original_review_running + self.tempdir.cleanup() + + def test_payload_counts_each_managed_item_and_does_not_follow_symlinks(self): + generated = trainer.DATA_DIR / "work" / "wake_word_samples" + generated.mkdir(parents=True) + (generated / "one.wav").write_bytes(b"a" * 128) + outside = trainer.DATA_DIR / "outside.bin" + outside.write_bytes(b"b" * 8192) + (generated / "outside-link").symlink_to(outside) + + payload = trainer._managed_data_payload() + item = next(row for row in payload["items"] if row["id"] == "generated_samples") + + self.assertEqual(item["file_count"], 2) + self.assertGreater(item["size_bytes"], 0) + self.assertEqual(item["location"], "work/wake_word_samples") + self.assertEqual(payload["total_file_count"], 2) + + deleted = trainer._delete_managed_data_item("generated_samples") + self.assertFalse(generated.exists()) + self.assertTrue(outside.exists()) + self.assertEqual(deleted["deleted_id"], "generated_samples") + + def test_unknown_ids_and_active_training_are_rejected(self): + with self.assertRaises(KeyError): + trainer._delete_managed_data_item("../../not-allowed") + + generated = trainer.DATA_DIR / "work" / "wake_word_samples" + generated.mkdir(parents=True) + (generated / "keep.wav").write_bytes(b"keep") + trainer.STATE["training"]["running"] = True + with self.assertRaisesRegex(RuntimeError, "Stop training"): + trainer._delete_managed_data_item("generated_samples") + self.assertTrue((generated / "keep.wav").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_modern_tts.py b/tests/test_modern_tts.py new file mode 100644 index 0000000..cd8ad5d --- /dev/null +++ b/tests/test_modern_tts.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +import shutil +import subprocess +import tempfile +import unittest +import wave +from array import array +from pathlib import Path +from unittest.mock import patch + +from tts_config import parse_omnivoice_catalog + +try: + import trainer_server as trainer +except ModuleNotFoundError: + trainer = None + + +REPO_ROOT = Path(__file__).resolve().parents[1] +GENERATOR_PATH = REPO_ROOT / "cli" / "tts_generate_samples.py" +SPEC = importlib.util.spec_from_file_location("tts_generate_samples", GENERATOR_PATH) +assert SPEC is not None and SPEC.loader is not None +generator_module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(generator_module) +QA_PATH = REPO_ROOT / "cli" / "tts_reference_qa.py" +QA_SPEC = importlib.util.spec_from_file_location("tts_reference_qa", QA_PATH) +assert QA_SPEC is not None and QA_SPEC.loader is not None +qa_module = importlib.util.module_from_spec(QA_SPEC) +QA_SPEC.loader.exec_module(qa_module) + + +def write_tone( + path: Path, + *, + duration: float = 0.8, + amplitude: int = 4000, + frequency: float = 220.0, +) -> None: + rate = 16000 + samples = array( + "h", + ( + int(amplitude * math.sin(2 * math.pi * frequency * index / rate)) + for index in range(int(rate * duration)) + ), + ) + with wave.open(str(path), "wb") as stream: + stream.setnchannels(1) + stream.setsampwidth(2) + stream.setframerate(rate) + stream.writeframes(samples.tobytes()) + + +class ModernTtsTests(unittest.TestCase): + def test_direct_generator_uses_one_wake_phrase(self) -> None: + self.assertEqual(generator_module.reference_text("hey tater"), "hey tater.") + self.assertEqual(generator_module.reference_text("hey tater!"), "hey tater.") + self.assertIn("four-provider-direct-corpus", generator_module.GENERATOR_VERSION) + self.assertIn("safe-limits", generator_module.GENERATOR_VERSION) + + def test_omnivoice_uses_upstream_sampling_defaults(self) -> None: + self.assertEqual( + generator_module.omnivoice_stability_args(), + ["--position_temperature", "5.0", "--class_temperature", "0.0"], + ) + + def test_omnivoice_uses_a_hidden_stable_prompt_before_short_clone(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + args = argparse.Namespace( + phrase="hey_tater", + language="en", + tts_mode="modern", + samples=1, + batch_size=4, + voice_count=2, + data_dir=data_dir, + output_dir=data_dir / "work" / "samples", + ffmpeg="ffmpeg", + dry_run=False, + ) + instance = generator_module.Generator(args) + destination = data_dir / "bank" + destination.mkdir() + + def create_model_outputs(command, *, only_first: bool = False) -> None: + input_flag = "--test_list" if "--test_list" in command else "--input-jsonl" + output_flag = "--res_dir" if "--res_dir" in command else "--output-dir" + input_path = Path(command[command.index(input_flag) + 1]) + output_dir = Path(command[command.index(output_flag) + 1]) + output_dir.mkdir(parents=True, exist_ok=True) + model_entries = [ + json.loads(line) + for line in input_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + for item in model_entries[:1] if only_first else model_entries: + write_tone(output_dir / f"{item['id']}.wav") + + with ( + patch.object(instance, "ensure_environment"), + patch.object( + generator_module, + "run_with_batch_retry", + side_effect=lambda command, _flag, **_kwargs: create_model_outputs(command), + ) as run_batch, + ): + entries = instance._generate_omni_bank(2, 0, destination) + + self.assertEqual(run_batch.call_count, 2) + self.assertEqual(entries[0]["text"], "hey tater.") + self.assertEqual( + entries[0]["ref_text"], + "In a calm and natural voice, I say hey tater clearly, then continue speaking at an even pace.", + ) + self.assertEqual(entries[0]["ref_text"].lower().count("hey tater"), 1) + self.assertIn(".omnivoice-prompts", entries[0]["ref_audio"]) + self.assertEqual( + entries[0]["voice_description"], + "automatic random voice", + ) + self.assertNotIn("instruct", entries[0]) + for call in run_batch.call_args_list: + command = call.args[0] + if "--position_temperature" in command: + self.assertEqual(command[command.index("--position_temperature") + 1], "5.0") + self.assertEqual(command[command.index("--class_temperature") + 1], "0.0") + + def test_omnivoice_corpus_uses_reference_without_a_second_instruction(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + args = argparse.Namespace( + phrase="hey_tater", + language="en", + tts_mode="modern", + samples=1, + batch_size=4, + voice_count=2, + data_dir=data_dir, + output_dir=data_dir / "work" / "samples", + ffmpeg="ffmpeg", + dry_run=False, + ) + instance = generator_module.Generator(args) + entries = instance.make_entries( + generator_module.ENGINE_OMNIVOICE, + 1, + [{ + "id": "omni_ref", + "path": "/tmp/short.wav", + "ref_text": "hey tater.", + "omnivoice_prompt_path": "/tmp/prompt.wav", + "omnivoice_prompt_text": "A natural carrier sentence.", + "instruct": "female, elderly, low pitch, british accent", + }], + data_dir, + ) + + self.assertNotIn("instruct", entries[0]) + + def test_omnivoice_corpus_repairs_only_vad_rejected_outputs(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + args = argparse.Namespace( + phrase="hey_tater", + language="en", + tts_mode="modern", + samples=2, + batch_size=4, + voice_count=2, + data_dir=data_dir, + output_dir=data_dir / "work" / "samples", + ffmpeg="ffmpeg", + dry_run=False, + ) + instance = generator_module.Generator(args) + destination = data_dir / "raw" + destination.mkdir() + entries = [{"id": "omni_a", "text": "hey tater."}, {"id": "omni_b", "text": "hey tater."}] + for entry in entries: + write_tone(destination / f"{entry['id']}.wav") + generation_command = [ + "omnivoice", + "--test_list", + str(data_dir / "input.jsonl"), + "--res_dir", + str(destination), + "--batch_size", + "4", + ] + qa_calls = 0 + + def fake_qa(command, **_kwargs): + nonlocal qa_calls + qa_calls += 1 + self.assertIn("--speech-only", command) + qa_input = Path(command[command.index("--input-jsonl") + 1]) + qa_output = Path(command[command.index("--output-jsonl") + 1]) + candidates = [json.loads(line) for line in qa_input.read_text().splitlines()] + results = [ + { + "id": item["id"], + "accepted": qa_calls > 1 or item["id"] == "omni_a", + } + for item in candidates + ] + qa_output.write_text("".join(json.dumps(item) + "\n" for item in results)) + + def fake_retry(command, _flag, **_kwargs): + retry_input = Path(command[command.index("--test_list") + 1]) + retry_entries = [json.loads(line) for line in retry_input.read_text().splitlines()] + for entry in retry_entries: + write_tone(destination / f"{entry['id']}.wav") + + with ( + patch.object(instance, "_reference_qa_python", return_value=data_dir / "python"), + patch.object(generator_module, "run", side_effect=fake_qa), + patch.object(generator_module, "run_with_batch_retry", side_effect=fake_retry) as retry, + ): + accepted = instance._repair_generated_corpus( + generator_module.ENGINE_OMNIVOICE, + entries, + destination, + generation_command, + "", + speech_only=True, + input_flag="--test_list", + batch_flag="--batch_size", + ) + retry_input = Path(retry.call_args.args[0][retry.call_args.args[0].index("--test_list") + 1]) + retried_ids = [json.loads(line)["id"] for line in retry_input.read_text().splitlines()] + + self.assertEqual([path.name for path in accepted], ["omni_a.wav", "omni_b.wav"]) + self.assertEqual(retried_ids, ["omni_b"]) + self.assertEqual(qa_calls, 2) + + def test_omnivoice_repairs_outputs_missing_from_a_successful_seed_batch(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + args = argparse.Namespace( + phrase="hey_tater", + language="en", + tts_mode="modern", + samples=1, + batch_size=4, + voice_count=2, + data_dir=data_dir, + output_dir=data_dir / "work" / "samples", + ffmpeg="ffmpeg", + dry_run=False, + ) + instance = generator_module.Generator(args) + destination = data_dir / "bank" + destination.mkdir() + + def create_outputs(command, *, only_first: bool = False) -> None: + input_flag = "--test_list" if "--test_list" in command else "--input-jsonl" + output_flag = "--res_dir" if "--res_dir" in command else "--output-dir" + input_path = Path(command[command.index(input_flag) + 1]) + output_dir = Path(command[command.index(output_flag) + 1]) + output_dir.mkdir(parents=True, exist_ok=True) + model_entries = [ + json.loads(line) + for line in input_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + for item in model_entries[:1] if only_first else model_entries: + write_tone(output_dir / f"{item['id']}.wav") + + batched_calls = 0 + + def fake_batched(command, _flag, **_kwargs): + nonlocal batched_calls + batched_calls += 1 + create_outputs(command, only_first=batched_calls == 1) + + with ( + patch.object(instance, "ensure_environment"), + patch.object(generator_module, "run_with_batch_retry", side_effect=fake_batched), + patch.object( + generator_module, + "run", + side_effect=lambda command, **_kwargs: create_outputs(command), + ) as run_single, + ): + entries = instance._generate_omni_bank(2, 0, destination) + retry_command = run_single.call_args.args[0] + retry_input = Path(retry_command[retry_command.index("--test_list") + 1]) + retried_ids = [json.loads(line)["id"] for line in retry_input.read_text().splitlines()] + + self.assertEqual(len(entries), 2) + self.assertEqual(batched_calls, 2) + self.assertEqual(run_single.call_count, 1) + self.assertEqual(retry_command[retry_command.index("--batch_size") + 1], "1") + self.assertEqual(retried_ids, ["omni_prompt_0001"]) + + def test_reference_semantic_qa_rejects_noise_and_missing_words(self) -> None: + self.assertTrue(qa_module.transcript_matches_phrase("Hey, Tater.", "hey tater")) + self.assertTrue(qa_module.transcript_matches_phrase("Hey, gator.", "hey tater")) + self.assertFalse(qa_module.transcript_matches_phrase("Tater.", "hey tater")) + self.assertFalse(qa_module.transcript_matches_phrase("Hater.", "hey tater")) + self.assertFalse(qa_module.transcript_matches_phrase("Thanks for watching!", "hey tater")) + self.assertFalse( + qa_module.transcript_matches_phrase("Hey tater. Hey tater.", "hey tater") + ) + self.assertFalse(qa_module.transcript_matches_phrase("Hey hey Tate", "hey tater")) + self.assertFalse(qa_module.transcript_matches_phrase("", "hey tater")) + self.assertEqual( + qa_module.semantic_rejection_reason("Ehhhhh...", "hey tater", 0.8), + "decoder_collapse", + ) + self.assertEqual( + qa_module.semantic_rejection_reason("Hey tater. Hey tater.", "hey tater", 0.8), + "repeated_phrase", + ) + self.assertEqual( + qa_module.semantic_rejection_reason("Hey hey Tate", "hey tater", 0.8), + "repeated_phrase", + ) + self.assertEqual( + qa_module.semantic_rejection_reason("Hey Taylor", "hey tater", 0.8), + "phrase_mismatch", + ) + + def test_omnivoice_sample_generation_requires_a_stable_prompt(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + args = argparse.Namespace( + phrase="hey_tater", + language="en", + tts_mode="modern", + samples=1, + batch_size=4, + voice_count=2, + data_dir=data_dir, + output_dir=data_dir / "work" / "samples", + ffmpeg="ffmpeg", + dry_run=False, + ) + instance = generator_module.Generator(args) + with self.assertRaisesRegex(RuntimeError, "long-form seed prompt"): + instance.make_entries( + generator_module.ENGINE_OMNIVOICE, + 1, + [{"id": "qwen_ref", "path": "/tmp/qwen.wav", "ref_text": "hey tater."}], + data_dir, + ) + + def test_omnivoice_markdown_catalog_parser(self) -> None: + markdown = """ +| # | Language | OmniVoice ID | ISO 639-3 | Duration (h) | +|--:|----------|:------------:|:---------:|:------------:| +| 1 | English | en | eng | 100000.5 | +| 2 | Amdo Tibetan | adx | adx | 56.94 | +""" + parsed = parse_omnivoice_catalog(markdown) + self.assertEqual(parsed["en"]["name"], "English") + self.assertEqual(parsed["adx"]["iso_639_3"], "adx") + self.assertEqual(parsed["adx"]["duration_hours"], 56.94) + + @unittest.skipIf(trainer is None, "trainer server dependencies are not installed") + def test_language_catalog_merges_engine_coverage_and_quality(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + with ( + patch.object( + trainer, + "_load_omnivoice_catalog", + return_value={ + "en": {"name": "English"}, + "zu": {"name": "Zulu"}, + }, + ), + patch.object(trainer, "_load_piper_catalog", return_value={}), + patch.object(trainer, "PIPER_ROOT", Path(temp_dir) / "piper"), + patch.object(trainer, "PIPER_VOICES_DIR", Path(temp_dir) / "voices"), + ): + catalog = {item["code"]: item for item in trainer._available_languages()} + + self.assertEqual(catalog["en"]["quality"], "recommended") + self.assertEqual(catalog["en"]["engines"], ["omnivoice", "qwen3", "moss"]) + self.assertEqual(catalog["zu"]["quality"], "experimental") + self.assertEqual(catalog["zu"]["engines"], ["omnivoice"]) + + @unittest.skipIf(trainer is None, "trainer server dependencies are not installed") + def test_server_resolves_unavailable_tts_modes_safely(self) -> None: + languages = [ + {"code": "en", "engines": ["omnivoice", "qwen3", "moss"]}, + {"code": "legacy", "engines": ["piper"]}, + ] + self.assertEqual( + trainer._resolve_tts_mode_for_language("piper", "en", languages), + "modern", + ) + self.assertEqual( + trainer._resolve_tts_mode_for_language("modern", "legacy", languages), + "piper", + ) + + def test_generator_plan_and_piper_discovery_do_not_load_models(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + output_dir = data_dir / "work" / "wake_word_samples" + args = argparse.Namespace( + phrase="hey_tater", + language="en", + tts_mode="modern", + samples=101, + batch_size=8, + voice_count=128, + data_dir=data_dir, + output_dir=output_dir, + ffmpeg="ffmpeg", + dry_run=True, + ) + instance = generator_module.Generator(args) + self.assertEqual(instance.spoken_phrase, "hey tater") + self.assertEqual(instance.reference_text, "hey tater.") + self.assertEqual(instance.voice_bank_dir.name, generator_module.phrase_key("hey tater")) + self.assertEqual(instance.engines(), ["omnivoice", "qwen3", "moss"]) + self.assertEqual(sum(generator_module.distribute_samples(101, instance.engines()).values()), 101) + + model = data_dir / "tools" / "piper-sample-generator" / "models" / "en_US-libritts_r-medium.pt" + model.parent.mkdir(parents=True) + model.touch() + args.tts_mode = "hybrid" + self.assertEqual(instance.engines()[-1], "piper") + + def test_voice_descriptions_are_distinct_for_default_bank(self) -> None: + descriptions = generator_module.qwen_descriptions("English", 128) + self.assertEqual(len(descriptions), 128) + self.assertEqual(len(set(descriptions)), 128) + + first_bank = descriptions[:64] + self.assertEqual(sum(" female speaker " in item for item in first_bank), 32) + self.assertEqual(sum(" male speaker " in item for item in first_bank), 32) + for trait in ( + "child", + "teenager", + "young adult", + "middle-aged adult", + "elderly adult", + "low pitch", + "medium pitch", + "high pitch", + "calm neutral delivery", + "bright energetic delivery", + "soft careful delivery", + "confident resonant delivery", + "casual conversational delivery", + "clear timbre", + "warm timbre", + "slightly breathy timbre", + "crisp timbre", + "gently rough timbre", + ): + self.assertGreaterEqual(sum(trait in item for item in first_bank), 10, trait) + + def test_failed_model_batch_retries_one_item_at_a_time(self) -> None: + command = ["worker", "--batch-size", "4"] + with patch.object( + generator_module, + "run", + side_effect=(subprocess.CalledProcessError(1, command), None), + ) as mocked_run: + generator_module.run_with_batch_retry(command, "--batch-size") + + self.assertEqual(mocked_run.call_count, 2) + self.assertEqual(mocked_run.call_args_list[1].args[0], ["worker", "--batch-size", "1"]) + + def test_acoustic_qa_accepts_speech_like_pcm_and_rejects_silence(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + tone = root / "tone.wav" + silence = root / "silence.wav" + write_tone(tone) + write_tone(silence, amplitude=0) + self.assertTrue(generator_module.valid_sample(tone)) + self.assertTrue(generator_module.valid_reference(tone)) + self.assertFalse(generator_module.valid_sample(silence)) + + def test_provider_safety_gate_rejects_static_and_rambling(self) -> None: + clean = { + "duration": 1.2, + "rms": 0.08, + "peak": 0.5, + "clipped_ratio": 0.0, + "dc_offset": 0.0, + "spectral_flatness": 0.05, + "high_frequency_ratio": 0.04, + "zero_crossing_rate": 0.08, + } + self.assertEqual( + qa_module.acoustic_rejection_reason(clean, 0.7, "omnivoice", 0.4, 2.7), + "accepted", + ) + self.assertEqual( + qa_module.acoustic_rejection_reason( + {**clean, "spectral_flatness": 0.8}, 0.8, "omnivoice", 0.4, 2.7 + ), + "static_or_broadband_noise", + ) + self.assertEqual( + qa_module.acoustic_rejection_reason( + {**clean, "duration": 3.5}, 0.8, "qwen3", 0.4, 2.7 + ), + "too_long_or_rambling", + ) + + def test_direct_entries_do_not_clone_the_old_voice_bank(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + args = argparse.Namespace( + phrase="hey_tater", + language="en", + tts_mode="hybrid", + samples=12, + batch_size=4, + voice_count=128, + data_dir=data_dir, + output_dir=data_dir / "work" / "samples", + ffmpeg="ffmpeg", + dry_run=False, + ) + instance = generator_module.Generator(args) + qwen = instance.make_direct_entries("qwen3", 4, data_dir, []) + omni = instance.make_direct_entries("omnivoice", 4, data_dir, []) + refs = [data_dir / f"accepted-{index}.wav" for index in range(4)] + moss = instance.make_direct_entries("moss", 4, data_dir, refs) + + self.assertTrue(all("ref_audio" not in item for item in qwen + omni)) + self.assertEqual(len({item["instruct"] for item in qwen}), 4) + self.assertEqual([item["ref_audio"] for item in moss], [str(path) for path in refs]) + + @unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg is required for normalization") + def test_orchestrator_produces_exact_normalized_corpus_and_manifest(self) -> None: + class FakeGenerator(generator_module.Generator): + generated = 0 + + def generate_direct_engine(self, engine, count, reference_paths, prefix=""): + destination = self.raw_dir / f"{engine}_{prefix or 'main'}" + destination.mkdir(parents=True, exist_ok=True) + paths = [] + for index in range(count): + path = destination / f"{engine}_{prefix}{index}.wav" + write_tone(path, frequency=180 + self.generated) + self.generated += 1 + self.speed_by_path[path.resolve()] = 1.0 + paths.append(path) + entries = [ + { + "id": path.stem, + "minimum_duration": self.minimum_duration, + "maximum_duration": self.maximum_duration, + } + for path in paths + ] + return entries, paths + + def qualify_direct_candidates(self, engine, entries, paths, prefix=""): + return paths + + with tempfile.TemporaryDirectory() as temp_dir: + data_dir = Path(temp_dir) + output_dir = data_dir / "work" / "wake_word_samples" + args = argparse.Namespace( + phrase="hey tater", + language="en", + tts_mode="modern", + samples=13, + batch_size=4, + voice_count=8, + data_dir=data_dir, + output_dir=output_dir, + ffmpeg=shutil.which("ffmpeg"), + dry_run=False, + ) + instance = FakeGenerator(args) + instance.generate() + + self.assertEqual(len(list(output_dir.glob("*.wav"))), 13) + self.assertTrue((output_dir / ".generation_manifest.json").is_file()) + self.assertTrue(instance.cache_hit()) + + def test_docker_and_ui_are_wired_for_modern_tts(self) -> None: + for dockerfile in ("dockerfile", "dockerfile.blackwell"): + source = (REPO_ROOT / dockerfile).read_text(encoding="utf-8") + self.assertIn("ffmpeg", source) + self.assertIn("tts_config.py", source) + + ui = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8") + store = (REPO_ROOT / "frontend" / "src" / "trainerStore.ts").read_text(encoding="utf-8") + self.assertIn('v-model="trainer.ttsMode"', ui) + self.assertIn("tts_mode: trainer.ttsMode", store) + self.assertIn("OmniVoice", store) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_session_stop.py b/tests/test_session_stop.py new file mode 100644 index 0000000..e943427 --- /dev/null +++ b/tests/test_session_stop.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import signal +import unittest +from unittest.mock import patch + +import trainer_server as trainer + + +class _FakeTrainingProcess: + def __init__(self): + self.pid = 5432 + self.returncode = None + + def poll(self): + return self.returncode + + def wait(self, timeout=None): + self.returncode = -signal.SIGTERM + return self.returncode + + def terminate(self): + self.returncode = -signal.SIGTERM + + def kill(self): + self.returncode = -signal.SIGKILL + + +class SessionStopTests(unittest.TestCase): + def tearDown(self): + trainer.TRAINING_STOP_EVENT.clear() + + def test_session_stop_terminates_the_process_group_and_allows_another_run(self): + proc = _FakeTrainingProcess() + original_process = trainer.TRAINING_PROCESS + original_thread = trainer.TRAINING_THREAD + try: + trainer.TRAINING_PROCESS = proc + trainer.TRAINING_THREAD = None + with ( + patch.object(trainer.os, "getpgid", return_value=proc.pid), + patch.object(trainer.os, "getpgrp", return_value=999), + patch.object(trainer.os, "killpg") as killpg, + ): + self.assertTrue(trainer._stop_current_training(timeout=0.2)) + killpg.assert_called_once_with(proc.pid, signal.SIGTERM) + self.assertFalse(trainer.TRAINING_STOP_EVENT.is_set()) + finally: + trainer.TRAINING_PROCESS = original_process + trainer.TRAINING_THREAD = original_thread + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tts_config.py b/tests/test_tts_config.py new file mode 100644 index 0000000..280cd71 --- /dev/null +++ b/tests/test_tts_config.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import unittest + +from tts_config import ( + ENGINE_MOSS, + ENGINE_OMNIVOICE, + ENGINE_PIPER, + ENGINE_QWEN3, + distribute_samples, + engines_for_language, + language_for_engine, + normalize_tts_mode, + quality_for_engines, +) + + +class TtsConfigTests(unittest.TestCase): + def test_recommended_languages_use_all_modern_engines(self) -> None: + self.assertEqual( + engines_for_language("en", "modern"), + [ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS], + ) + self.assertEqual( + quality_for_engines(engines_for_language("fr", "modern")), + "recommended", + ) + + def test_broad_language_coverage_routes_through_omnivoice(self) -> None: + self.assertEqual(engines_for_language("zu", "modern"), [ENGINE_OMNIVOICE]) + self.assertEqual(quality_for_engines([ENGINE_OMNIVOICE]), "experimental") + + def test_hybrid_and_legacy_modes_require_available_piper(self) -> None: + self.assertEqual( + engines_for_language("en", "hybrid", piper_available=True), + [ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS, ENGINE_PIPER], + ) + self.assertEqual(engines_for_language("en", "piper"), []) + self.assertEqual( + engines_for_language("en", "piper", piper_available=True), + [ENGINE_PIPER], + ) + + def test_sample_distribution_is_exact_and_deterministic(self) -> None: + self.assertEqual( + distribute_samples(10, [ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS]), + {ENGINE_OMNIVOICE: 4, ENGINE_QWEN3: 3, ENGINE_MOSS: 3}, + ) + self.assertEqual(sum(distribute_samples(50000, ["a", "b", "c"]).values()), 50000) + + def test_invalid_mode_falls_back_to_four_provider_route(self) -> None: + self.assertEqual(normalize_tts_mode("unknown"), "hybrid") + + def test_common_language_aliases_use_model_catalog_ids(self) -> None: + self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ar"), "arb") + self.assertEqual(language_for_engine(ENGINE_OMNIVOICE, "ne"), "npi") + self.assertEqual(language_for_engine(ENGINE_MOSS, "ar"), "ar") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vue_ui.py b/tests/test_vue_ui.py new file mode 100644 index 0000000..d578432 --- /dev/null +++ b/tests/test_vue_ui.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import json +import pathlib +import unittest + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class VueTrainerUiTests(unittest.TestCase): + def test_frontend_uses_typed_vue_and_vite(self) -> None: + package = json.loads((REPO_ROOT / "frontend" / "package.json").read_text(encoding="utf-8")) + self.assertEqual(package["dependencies"]["vue"], "3.5.40") + self.assertIn("vue-tsc --noEmit", package["scripts"]["build"]) + self.assertIn("vite build", package["scripts"]["build"]) + + config = (REPO_ROOT / "frontend" / "vite.config.ts").read_text(encoding="utf-8") + self.assertIn('"../static/ui"', config) + self.assertIn('fileName: () => "trainer-ui.js"', config) + + def test_static_shell_loads_prebuilt_bundle(self) -> None: + index = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8") + self.assertIn('id="trainer-app"', index) + self.assertIn('/static/ui/trainer-ui.css', index) + self.assertIn('/static/ui/trainer-ui.js', index) + self.assertNotIn("fonts.googleapis.com", index) + + self.assertGreater((REPO_ROOT / "static" / "ui" / "trainer-ui.js").stat().st_size, 100_000) + self.assertGreater((REPO_ROOT / "static" / "ui" / "trainer-ui.css").stat().st_size, 10_000) + + def test_theme_uses_tater_orange_and_neutral_greys(self) -> None: + styles = (REPO_ROOT / "frontend" / "src" / "trainer.css").read_text(encoding="utf-8") + self.assertIn("--orange: #ff9134", styles) + self.assertIn("--surface: rgba(29, 29, 31, .9)", styles) + for old_blue in ("#070b15", "#11192b", "#5db6ff", "#8d75ff", "#7fc7ff", "#78caff"): + self.assertNotIn(old_blue, styles) + + def test_reactive_ui_keeps_trainer_workflows(self) -> None: + app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8") + store = (REPO_ROOT / "frontend" / "src" / "trainerStore.ts").read_text(encoding="utf-8") + trim = (REPO_ROOT / "frontend" / "src" / "components" / "AudioTrimModal.vue").read_text(encoding="utf-8") + + for workflow in ( + "startSession", + "stopSession", + "startTraining", + "saveAuto", + "runAutoAction", + "reviewCaptured", + "uploadSelectedFiles", + "copyWakeWord", + "deleteManagedData", + ): + self.assertIn(workflow, app) + + for endpoint in ( + "/api/start_session", + "/api/stop_session", + "/api/upload_personal_sample", + "/api/captured_audio", + "/api/auto_train", + "/api/train_status", + "/api/trained_wake_words/catalog", + "/api/data", + ): + self.assertIn(endpoint, store) + + self.assertIn("OfflineAudioContext", trim) + self.assertIn("/api/samples/trim", trim) + self.assertIn(':disabled="Boolean(trainer.session.safe_word)', app) + self.assertIn('{ id: "data", label: "Data"', app) + + def test_training_console_pauses_follow_mode_when_scrolled_up(self) -> None: + app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8") + + self.assertIn("const consoleFollowing = ref(true)", app) + self.assertIn("distanceFromBottom <= 32", app) + self.assertIn('if (!consoleFollowing.value) return', app) + self.assertIn('@scroll.passive="onConsoleScroll"', app) + self.assertIn("Jump to latest", app) + + def test_wake_word_card_uses_explicit_json_catalog_url(self) -> None: + app = (REPO_ROOT / "frontend" / "src" / "TrainerApp.vue").read_text(encoding="utf-8") + types = (REPO_ROOT / "frontend" / "src" / "types.ts").read_text(encoding="utf-8") + + self.assertIn("item.json_url || item.url || item.jsonUrl", app) + self.assertIn("copyWakeWord(wordJsonUrl(word))", app) + self.assertNotIn("copyWakeWord(word.url)", app) + self.assertIn("json_url?: string", types) + + def test_runtime_packaging_uses_bundle_without_node(self) -> None: + dockerfiles = [REPO_ROOT / "dockerfile", REPO_ROOT / "dockerfile.blackwell"] + for dockerfile in dockerfiles: + if not dockerfile.exists(): + continue + source = dockerfile.read_text(encoding="utf-8") + self.assertIn("COPY --chown=root:root static/ /root/mww-scripts/static/", source) + self.assertNotIn("npm install", source) + + macos_builder = REPO_ROOT / "macos" / "WakeWordTrainer" / "scripts" / "build_app.sh" + if macos_builder.exists(): + source = macos_builder.read_text(encoding="utf-8") + self.assertIn("--exclude='frontend/node_modules/'", source) + self.assertNotIn("--exclude='static/'", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/train_wake_word b/train_wake_word index 659a446..84489dd 100755 --- a/train_wake_word +++ b/train_wake_word @@ -5,7 +5,7 @@ PROGPATH=$(realpath "$0") PROGDIR=$(dirname "${PROGPATH}") CLIDIR="${PROGDIR}/cli" -KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir language ) +KNOWN_ARGS=( samples batch-size training-steps data-dir cleanup-work-dir language tts-mode tts-voice-count ) source "${CLIDIR}/shell.functions" WAKE_WORD=${POSITIONAL_ARGS[0]} @@ -19,6 +19,8 @@ if [ "${HELP}" == "true" ] || [ -z "${WAKE_WORD}" ] ; then Usage: train_wake_word [ --samples= ] [ --batch-size= ] [ --training-steps= ] [ --cleanup-work-dir ] [ --language= ] + [ --tts-mode= ] + [ --tts-voice-count= ] [ ] Options: @@ -39,6 +41,12 @@ Options: --language: Language for TTS voice selection (e.g. "en", "nl"). Default: ${DEFAULT_LANGUAGE} +--tts-mode: TTS source: modern (OmniVoice plus Qwen3/MOSS where + supported), hybrid (modern plus Piper), or piper. + Default: ${DEFAULT_TTS_MODE} + +--tts-voice-count: Deprecated compatibility option; direct generation ignores it. + The word to train spelled phonetically. Required. @@ -116,6 +124,8 @@ export GRPC_VERBOSITY=ERROR --samples=${SAMPLES} \ --batch-size=${BATCH_SIZE} \ --language="${LANGUAGE}" \ + --tts-mode="${TTS_MODE}" \ + --tts-voice-count="${TTS_VOICE_COUNT}" \ --data-dir="${DATA_DIR}" "${WAKE_WORD}" POST_GEN_TS=$EPOCHSECONDS diff --git a/trainer_server.py b/trainer_server.py index 928ad2e..eac153e 100644 --- a/trainer_server.py +++ b/trainer_server.py @@ -8,8 +8,11 @@ import os import queue import re import json +import signal import secrets +import shlex import socket +import stat as stat_module import shutil import subprocess import sys @@ -34,6 +37,21 @@ from fastapi.staticfiles import StaticFiles ROOT_DIR = Path(__file__).resolve().parent +from tts_config import ( + COMMON_OMNIVOICE_LANGUAGES, + DEFAULT_TTS_MODE, + ENGINE_MOSS, + ENGINE_OMNIVOICE, + ENGINE_PIPER, + ENGINE_QWEN3, + MOSS_LANGUAGES, + OMNIVOICE_LANGUAGE_ALIASES, + QWEN_LANGUAGES, + normalize_tts_mode, + parse_omnivoice_catalog, + quality_for_engines, +) + # In Docker, /data is the persistent workspace mounted by the user. DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")).resolve() STATIC_DIR = Path(os.environ.get("STATIC_DIR", str(ROOT_DIR / "static"))).resolve() @@ -72,6 +90,19 @@ PIPER_CATALOG_CACHE_FILE = Path( str(DATA_DIR / ".cache" / "piper_voices_catalog.json"), ) ).resolve() +OMNIVOICE_LANGUAGES_URL = os.environ.get( + "OMNIVOICE_LANGUAGES_URL", + "https://raw.githubusercontent.com/k2-fsa/OmniVoice/main/docs/languages.md", +) +OMNIVOICE_CATALOG_CACHE_TTL_SECONDS = int( + os.environ.get("OMNIVOICE_CATALOG_CACHE_TTL_SECONDS", "86400") +) +OMNIVOICE_CATALOG_CACHE_FILE = Path( + os.environ.get( + "OMNIVOICE_CATALOG_CACHE_FILE", + str(DATA_DIR / ".cache" / "omnivoice_languages.json"), + ) +).resolve() TRAIN_LOG_TAIL_LINES = int(os.environ.get("REC_TRAIN_LOG_TAIL_LINES", "400")) TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 1024))) @@ -83,6 +114,7 @@ TRAIN_CMD = os.environ.get( f"source '{DATA_DIR}/.venv/bin/activate' && train_wake_word --data-dir '{DATA_DIR}'", ) DEFAULT_LANGUAGE = os.environ.get("MWW_LANGUAGE", "en") +DEFAULT_SERVER_TTS_MODE = normalize_tts_mode(os.environ.get("MWW_TTS_MODE", DEFAULT_TTS_MODE)) TAKES_PER_SPEAKER_DEFAULT = int(os.environ.get("REC_TAKES_PER_SPEAKER", "10")) SPEAKERS_TOTAL_DEFAULT = int(os.environ.get("REC_SPEAKERS_TOTAL", "1")) @@ -181,6 +213,7 @@ STATE: Dict[str, Any] = { "raw_phrase": None, "safe_word": None, "language": DEFAULT_LANGUAGE, + "tts_mode": DEFAULT_SERVER_TTS_MODE, # multi-speaker "speakers_total": SPEAKERS_TOTAL_DEFAULT, @@ -201,13 +234,19 @@ STATE: Dict[str, Any] = { STATE_LOCK = threading.Lock() SAMPLES_LOCK = threading.Lock() +DATA_MANAGEMENT_LOCK = threading.RLock() PIPER_CATALOG_LOCK = threading.Lock() +OMNIVOICE_CATALOG_LOCK = threading.Lock() AUTO_TRAIN_LOCK = threading.RLock() AUTO_TRAIN_WAKE_EVENT = threading.Event() AUTO_TRAIN_STOP_EVENT = threading.Event() AUTO_TRAIN_REVIEW_QUEUE: queue.Queue[str] = queue.Queue() AUTO_TRAIN_QUEUED_FILES: set[str] = set() AUTO_TRAIN_WORKER: threading.Thread | None = None +TRAINING_RUNTIME_LOCK = threading.RLock() +TRAINING_STOP_EVENT = threading.Event() +TRAINING_PROCESS: subprocess.Popen | None = None +TRAINING_THREAD: threading.Thread | None = None AUTO_TRAIN_RUNTIME: Dict[str, Any] = { "review_running": False, "review_file": "", @@ -225,6 +264,181 @@ PIPER_CATALOG_CACHE: Dict[str, Any] = { "fetched_at": 0.0, "entries": None, } +OMNIVOICE_CATALOG_CACHE: Dict[str, Any] = { + "fetched_at": 0.0, + "entries": None, +} + + +def _managed_data_registry() -> List[Dict[str, Any]]: + """Return the exact trainer-owned paths that the Data tab may remove.""" + rebuild = "The trainer will rebuild this automatically when it is needed again." + redownload = "The trainer will download this again when it is needed." + irreplaceable = "These recordings are not generated and cannot be restored automatically." + work_dir = DATA_DIR / "work" + training_data_dir = DATA_DIR / "training_datasets" + return [ + {"id": "personal_samples", "label": "Personal positive samples", "category": "Recordings", "description": "User recordings and imported positive wake-word clips.", "paths": [PERSONAL_DIR], "rebuild_note": irreplaceable}, + {"id": "negative_samples", "label": "Reviewed negative samples", "category": "Recordings", "description": "Reviewed false wakes and other hard-negative recordings.", "paths": [NEGATIVE_DIR], "rebuild_note": irreplaceable}, + {"id": "captured_audio", "label": "Captured-audio inbox", "category": "Recordings", "description": "Unreviewed audio received from Tater satellites.", "paths": [CAPTURED_DIR], "rebuild_note": irreplaceable}, + {"id": "trim_history", "label": "Audio trim history", "category": "Recordings", "description": "Original audio retained so sample trims can be reverted.", "paths": [TRIM_HISTORY_DIR], "rebuild_note": "Deleting this removes the ability to revert existing trims."}, + + {"id": "generated_samples", "label": "Generated wake-word samples", "category": "Generated training data", "description": "The direct TTS corpus used for the current wake word.", "paths": [work_dir / "wake_word_samples"], "rebuild_note": rebuild}, + {"id": "generation_staging", "label": "TTS generation staging", "category": "Generated training data", "description": "Raw, quality-check, and partial files from an in-progress or interrupted generation.", "paths": [work_dir / ".wake_word_samples.build"], "rebuild_note": rebuild}, + {"id": "generated_features", "label": "Generated augmented features", "category": "Generated training data", "description": "Augmented model features produced from generated speech.", "paths": [work_dir / "wake_word_samples_augmented"], "rebuild_note": rebuild}, + {"id": "personal_features", "label": "Personal augmented features", "category": "Generated training data", "description": "Training features derived from personal positive samples.", "paths": [work_dir / "personal_augmented_features"], "rebuild_note": rebuild}, + {"id": "reviewed_negative_features", "label": "Reviewed-negative features", "category": "Generated training data", "description": "Training features derived from reviewed false wakes.", "paths": [work_dir / "reviewed_negative_features"], "rebuild_note": rebuild}, + {"id": "generation_marker", "label": "Last wake-word cache marker", "category": "Generated training data", "description": "The small marker used to decide whether generation can be reused.", "paths": [work_dir / "last_wake_word"], "rebuild_note": rebuild}, + + {"id": "negative_speech", "label": "Speech negatives", "category": "Downloaded training datasets", "description": "Stock non-wake speech features used to reduce false activations.", "paths": [training_data_dir / "negative_datasets" / "speech"], "rebuild_note": redownload}, + {"id": "negative_dinner_party", "label": "Dinner-party negatives", "category": "Downloaded training datasets", "description": "Overlapping conversational noise used during training.", "paths": [training_data_dir / "negative_datasets" / "dinner_party"], "rebuild_note": redownload}, + {"id": "negative_no_speech", "label": "No-speech negatives", "category": "Downloaded training datasets", "description": "Ambient non-speech features used during training.", "paths": [training_data_dir / "negative_datasets" / "no_speech"], "rebuild_note": redownload}, + {"id": "negative_dinner_eval", "label": "Dinner-party evaluation set", "category": "Downloaded training datasets", "description": "Held-out conversational audio used to evaluate false activations.", "paths": [training_data_dir / "negative_datasets" / "dinner_party_eval"], "rebuild_note": redownload}, + {"id": "mit_rirs_source", "label": "MIT RIR source download", "category": "Downloaded training datasets", "description": "Original room impulse response download.", "paths": [training_data_dir / "mit_rirs"], "rebuild_note": redownload}, + {"id": "mit_rirs_16k", "label": "MIT RIR 16 kHz training audio", "category": "Downloaded training datasets", "description": "Prepared room acoustics used to augment generated voices.", "paths": [training_data_dir / "mit_rirs_16k"], "rebuild_note": redownload}, + {"id": "audioset_source", "label": "AudioSet source download", "category": "Downloaded training datasets", "description": "Original downloaded AudioSet material retained for preparation.", "paths": [training_data_dir / "audioset"], "rebuild_note": redownload}, + {"id": "audioset_16k", "label": "AudioSet 16 kHz training audio", "category": "Downloaded training datasets", "description": "Prepared AudioSet audio used for augmentation.", "paths": [training_data_dir / "audioset_16k"], "rebuild_note": redownload}, + {"id": "fma_source", "label": "FMA source download", "category": "Downloaded training datasets", "description": "Original downloaded Free Music Archive material.", "paths": [training_data_dir / "fma"], "rebuild_note": redownload}, + {"id": "fma_16k", "label": "FMA 16 kHz training audio", "category": "Downloaded training datasets", "description": "Prepared music audio used for augmentation.", "paths": [training_data_dir / "fma_16k"], "rebuild_note": redownload}, + {"id": "wham_source", "label": "WHAM! source download", "category": "Downloaded training datasets", "description": "Original downloaded WHAM! background-noise material.", "paths": [training_data_dir / "wham"], "rebuild_note": redownload}, + {"id": "wham_16k", "label": "WHAM! 16 kHz training audio", "category": "Downloaded training datasets", "description": "Prepared WHAM! noise used for augmentation.", "paths": [training_data_dir / "wham_16k"], "rebuild_note": redownload}, + {"id": "chime_source", "label": "CHiME source download", "category": "Downloaded training datasets", "description": "Original downloaded CHiME household-noise material.", "paths": [training_data_dir / "chime"], "rebuild_note": redownload}, + {"id": "chime_16k", "label": "CHiME 16 kHz training audio", "category": "Downloaded training datasets", "description": "Prepared CHiME noise used for augmentation.", "paths": [training_data_dir / "chime_16k"], "rebuild_note": redownload}, + {"id": "dataset_downloads", "label": "Dataset archives and markers", "category": "Downloaded training datasets", "description": "Downloaded archives and preparation markers retained by dataset setup.", "paths": [training_data_dir / "downloads"], "rebuild_note": redownload}, + + {"id": "omnivoice_environment", "label": "OmniVoice engine", "category": "Voice and speech models", "description": "The isolated OmniVoice runtime and installed packages.", "paths": [DATA_DIR / "tts-envs" / "omnivoice"], "rebuild_note": redownload}, + {"id": "qwen_environment", "label": "Qwen3-TTS engine", "category": "Voice and speech models", "description": "The isolated Qwen3-TTS runtime and installed packages.", "paths": [DATA_DIR / "tts-envs" / "qwen3"], "rebuild_note": redownload}, + {"id": "moss_environment", "label": "MOSS-TTS engine", "category": "Voice and speech models", "description": "The isolated MOSS-TTS runtime and installed packages.", "paths": [DATA_DIR / "tts-envs" / "moss"], "rebuild_note": redownload}, + {"id": "tts_model_cache", "label": "TTS model downloads", "category": "Voice and speech models", "description": "Hugging Face model weights shared by the modern TTS providers.", "paths": [DATA_DIR / ".cache" / "huggingface"], "rebuild_note": redownload}, + {"id": "piper_models", "label": "Piper voice models", "category": "Voice and speech models", "description": "Downloaded Piper model weights used by hybrid and legacy generation.", "paths": [PIPER_ROOT / "models"], "rebuild_note": redownload}, + {"id": "piper_voices", "label": "Additional Piper voices", "category": "Voice and speech models", "description": "Language-specific Piper voices selected by the trainer.", "paths": [PIPER_VOICES_DIR], "rebuild_note": redownload}, + {"id": "stt_models", "label": "Auto-training STT models", "category": "Voice and speech models", "description": "Whisper and Parakeet speech-recognition model downloads.", "paths": [AUTO_TRAIN_MODEL_DIR], "rebuild_note": redownload}, + {"id": "provider_catalogs", "label": "Voice-provider catalogs", "category": "Voice and speech models", "description": "Cached OmniVoice language and Piper voice listings.", "paths": [OMNIVOICE_CATALOG_CACHE_FILE, PIPER_CATALOG_CACHE_FILE], "rebuild_note": redownload}, + {"id": "voice_bank", "label": "Legacy voice-bank references", "category": "Voice and speech models", "description": "Reference clips left by older voice-bank generation runs.", "paths": [DATA_DIR / "voice-bank"], "rebuild_note": rebuild}, + + {"id": "training_workspace", "label": "Model training workspace", "category": "Training results", "description": "Checkpoints, logs, and intermediate files from the latest model run.", "paths": [work_dir / "trained_models"], "rebuild_note": rebuild}, + {"id": "training_archives", "label": "Completed training archives", "category": "Training results", "description": "Timestamped final models and detailed logs from completed runs.", "paths": [DATA_DIR / "output"], "rebuild_note": "These historical results cannot be restored automatically."}, + {"id": "published_models", "label": "Published wake-word models", "category": "Training results", "description": "Finished TFLite models and JSON packages shown in Wake Words.", "paths": [TRAINED_WAKE_WORDS_DIR], "rebuild_note": "Tater links to these files will stop working. Train again to recreate them."}, + {"id": "training_log", "label": "Training console log", "category": "Training results", "description": "Saved console output from the most recent training run.", "paths": [DATA_DIR / "recorder_training.log"], "rebuild_note": "The deleted history cannot be restored; the next run creates a new log."}, + ] + + +def _managed_data_location(paths: List[Path]) -> str: + locations: List[str] = [] + for path in paths: + try: + locations.append(str(path.relative_to(DATA_DIR))) + except ValueError: + locations.append(path.name) + return ", ".join(locations) + + +def _managed_path_usage(path: Path) -> Tuple[int, int]: + """Return allocated bytes and file count without following symbolic links.""" + if not os.path.lexists(path): + return 0, 0 + total_bytes = 0 + file_count = 0 + stack = [os.fspath(path)] + seen: set[Tuple[int, int]] = set() + while stack: + current = stack.pop() + try: + stat = os.lstat(current) + except OSError: + continue + if stat_module.S_ISLNK(stat.st_mode) or not stat_module.S_ISDIR(stat.st_mode): + inode = (int(stat.st_dev), int(stat.st_ino)) + if inode in seen: + continue + seen.add(inode) + allocated = int(getattr(stat, "st_blocks", 0) or 0) * 512 + total_bytes += allocated or int(stat.st_size) + file_count += 1 + continue + try: + with os.scandir(current) as entries: + stack.extend(entry.path for entry in entries) + except OSError: + continue + return total_bytes, file_count + + +def _managed_data_payload() -> Dict[str, Any]: + items: List[Dict[str, Any]] = [] + total_size = 0 + total_files = 0 + with DATA_MANAGEMENT_LOCK: + for definition in _managed_data_registry(): + paths = [Path(path) for path in definition["paths"]] + usages = [_managed_path_usage(path) for path in paths] + size_bytes = sum(size for size, _ in usages) + file_count = sum(count for _, count in usages) + total_size += size_bytes + total_files += file_count + items.append({ + **{key: value for key, value in definition.items() if key != "paths"}, + "location": _managed_data_location(paths), + "size_bytes": size_bytes, + "file_count": file_count, + "exists": any(os.path.lexists(path) for path in paths), + }) + return {"ok": True, "items": items, "total_size_bytes": total_size, "total_file_count": total_files} + + +def _remove_managed_path(path: Path) -> None: + if not os.path.lexists(path): + return + if path.is_symlink() or not path.is_dir(): + path.unlink() + else: + shutil.rmtree(path) + + +def _clear_auto_review_queue() -> None: + with AUTO_TRAIN_LOCK: + AUTO_TRAIN_QUEUED_FILES.clear() + while True: + try: + AUTO_TRAIN_REVIEW_QUEUE.get_nowait() + except queue.Empty: + break + else: + AUTO_TRAIN_REVIEW_QUEUE.task_done() + + +def _delete_managed_data_item(item_id: str) -> Dict[str, Any]: + definitions = {item["id"]: item for item in _managed_data_registry()} + definition = definitions.get(str(item_id or "")) + if definition is None: + raise KeyError("Unknown managed data item.") + paths = [Path(path) for path in definition["paths"]] + with DATA_MANAGEMENT_LOCK: + with STATE_LOCK: + if STATE["training"]["running"]: + raise RuntimeError("Stop training before deleting trainer data.") + with AUTO_TRAIN_LOCK: + if AUTO_TRAIN_RUNTIME.get("review_running"): + raise RuntimeError("Wait for the current automatic audio review to finish before deleting data.") + previous_size = sum(_managed_path_usage(path)[0] for path in paths) + for path in paths: + _remove_managed_path(path) + if item_id == "personal_samples": + PERSONAL_DIR.mkdir(parents=True, exist_ok=True) + _sync_personal_samples_state() + elif item_id == "negative_samples": + NEGATIVE_DIR.mkdir(parents=True, exist_ok=True) + with AUTO_TRAIN_LOCK: + AUTO_TRAIN_STATE["pending_negative_count"] = 0 + _save_auto_train_state_locked() + elif item_id == "captured_audio": + CAPTURED_DIR.mkdir(parents=True, exist_ok=True) + _clear_auto_review_queue() + elif item_id == "trim_history": + TRIM_HISTORY_DIR.mkdir(parents=True, exist_ok=True) + payload = _managed_data_payload() + payload.update({"deleted_id": item_id, "released_bytes": previous_size}) + return payload # --- Silero VAD (lazy-loaded) --- _silero_vad_model = None @@ -416,6 +630,9 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]: "label": wake_word or safe, "wake_word_name": safe, "wake_word": wake_word or safe, + # `url` is retained for older trainer UIs and integrations. + # New consumers should prefer the explicit `json_url` field. + "url": json_url, "json_url": json_url, "model_url": model_url, "json_file": json_path.name, @@ -1485,24 +1702,39 @@ def _start_auto_training() -> Dict[str, Any]: if not wake_phrase: return {"ok": False, "error": "Auto Training needs a wake phrase."} safe_word = safe_name(wake_phrase) - language = str(config.get("language") or DEFAULT_LANGUAGE) - with STATE_LOCK: - if STATE["training"]["running"]: - return {"ok": False, "error": "Training already running."} - STATE["raw_phrase"] = wake_phrase - STATE["safe_word"] = safe_word - STATE["language"] = language - STATE["training"]["running"] = True + available_languages = _available_languages() + language = _normalize_language(str(config.get("language") or DEFAULT_LANGUAGE)) + tts_mode = _resolve_tts_mode_for_language( + DEFAULT_SERVER_TTS_MODE, + language, + available_languages, + ) + with DATA_MANAGEMENT_LOCK: + with STATE_LOCK: + if STATE["training"]["running"]: + return {"ok": False, "error": "Training already running."} + STATE["raw_phrase"] = wake_phrase + STATE["safe_word"] = safe_word + STATE["language"] = language + STATE["tts_mode"] = tts_mode + STATE["training"]["running"] = True with AUTO_TRAIN_LOCK: AUTO_TRAIN_STATE["last_train_started_at"] = _iso_now() AUTO_TRAIN_RUNTIME["training_pending_consumed"] = int(AUTO_TRAIN_STATE.get("pending_negative_count") or 0) _save_auto_train_state_locked() - threading.Thread( - target=_run_training_background, - args=(safe_word, language, True, True), - daemon=True, - ).start() - return {"ok": True, "started": True, "safe_word": safe_word, "language": language} + try: + _start_training_thread(safe_word, language, True, True, tts_mode) + except Exception as exc: + with STATE_LOCK: + STATE["training"]["running"] = False + return {"ok": False, "error": f"Could not start training: {exc}"} + return { + "ok": True, + "started": True, + "safe_word": safe_word, + "language": language, + "tts_mode": tts_mode, + } def _maybe_run_scheduled_auto_training() -> None: @@ -1545,7 +1777,8 @@ def _auto_train_worker_loop() -> None: file_name = "" if file_name: try: - _auto_review_capture(file_name) + with DATA_MANAGEMENT_LOCK: + _auto_review_capture(file_name) finally: with AUTO_TRAIN_LOCK: AUTO_TRAIN_QUEUED_FILES.discard(file_name) @@ -1601,6 +1834,7 @@ def _register_language( name: str, region: str = "", count: int = 1, + engine: str = "", ): if not family: return @@ -1612,11 +1846,69 @@ def _register_language( "name": name, "voice_count": 0, "regions": [], + "engines": [], }, ) entry["voice_count"] += count if region and region not in entry["regions"]: entry["regions"].append(region) + if engine and engine not in entry["engines"]: + entry["engines"].append(engine) + + +def _fetch_omnivoice_catalog() -> Dict[str, Dict[str, Any]] | None: + request = URLRequest( + OMNIVOICE_LANGUAGES_URL, + headers={"User-Agent": "microWakeWord-Trainer/modern-tts-v1"}, + ) + with urlopen(request, timeout=15) as response: + entries = parse_omnivoice_catalog(response.read().decode("utf-8")) + return entries or None + + +def _read_cached_omnivoice_catalog_file() -> Dict[str, Dict[str, Any]] | None: + try: + data = json.loads(OMNIVOICE_CATALOG_CACHE_FILE.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + +def _write_cached_omnivoice_catalog_file(data: Dict[str, Dict[str, Any]]) -> None: + try: + OMNIVOICE_CATALOG_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + OMNIVOICE_CATALOG_CACHE_FILE.write_text( + json.dumps(data, ensure_ascii=False), + encoding="utf-8", + ) + except Exception: + pass + + +def _load_omnivoice_catalog() -> Dict[str, Dict[str, Any]]: + now = time.time() + with OMNIVOICE_CATALOG_LOCK: + cached = OMNIVOICE_CATALOG_CACHE.get("entries") + fetched_at = float(OMNIVOICE_CATALOG_CACHE.get("fetched_at") or 0.0) + if cached is not None and (now - fetched_at) < OMNIVOICE_CATALOG_CACHE_TTL_SECONDS: + return cached + + disk_cached = _read_cached_omnivoice_catalog_file() + try: + fresh = _fetch_omnivoice_catalog() + except Exception: + fresh = None + + selected = fresh or disk_cached or { + code: {"name": name, "iso_639_3": "", "duration_hours": 0.0} + for code, name in COMMON_OMNIVOICE_LANGUAGES.items() + } + with OMNIVOICE_CATALOG_LOCK: + OMNIVOICE_CATALOG_CACHE["entries"] = selected + OMNIVOICE_CATALOG_CACHE["fetched_at"] = now + if fresh: + _write_cached_omnivoice_catalog_file(fresh) + return selected def _fetch_piper_catalog() -> Dict[str, Any] | None: @@ -1683,15 +1975,44 @@ def _load_piper_catalog() -> Dict[str, Any] | None: def _available_languages() -> List[Dict[str, Any]]: - languages: Dict[str, Dict[str, Any]] = { - "en": { - "code": "en", - "label": "English (en)", - "name": "English", - "voice_count": 1, - "regions": [], - } - } + languages: Dict[str, Dict[str, Any]] = {} + omnivoice_catalog = _load_omnivoice_catalog() + + for code, metadata in omnivoice_catalog.items(): + if not isinstance(metadata, dict): + continue + _register_language( + languages, + family=code, + name=str(metadata.get("name") or code.upper()), + count=0, + engine=ENGINE_OMNIVOICE, + ) + + for alias, catalog_code in OMNIVOICE_LANGUAGE_ALIASES.items(): + metadata = omnivoice_catalog.get(catalog_code) or {} + _register_language( + languages, + family=alias, + name=COMMON_OMNIVOICE_LANGUAGES.get(alias, str(metadata.get("name") or alias.upper())), + count=0, + engine=ENGINE_OMNIVOICE, + ) + + for code, name in QWEN_LANGUAGES.items(): + _register_language(languages, family=code, name=name, count=0, engine=ENGINE_QWEN3) + for code, name in MOSS_LANGUAGES.items(): + _register_language(languages, family=code, name=name, count=0, engine=ENGINE_MOSS) + + piper_english_model = PIPER_ROOT / "models" / "en_US-libritts_r-medium.pt" + if piper_english_model.is_file(): + _register_language( + languages, + family="en", + name="English", + count=1, + engine=ENGINE_PIPER, + ) if PIPER_VOICES_DIR.exists(): for meta_path in sorted(PIPER_VOICES_DIR.glob("*.onnx.json")): @@ -1702,12 +2023,19 @@ def _available_languages() -> List[Dict[str, Any]]: language = data.get("language") or {} family = _registered_language_family(language) - if not family or family == "en": + if not family: continue name = str(language.get("name_english") or language.get("name_native") or family.upper()).strip() region = str(language.get("country_english") or language.get("region") or "").strip() - _register_language(languages, family=family, name=name, region=region, count=1) + _register_language( + languages, + family=family, + name=name, + region=region, + count=1, + engine=ENGINE_PIPER, + ) catalog = _load_piper_catalog() or {} for entry in catalog.values(): @@ -1715,11 +2043,43 @@ def _available_languages() -> List[Dict[str, Any]]: continue language = entry.get("language") or {} family = _registered_language_family(language) - if not family or family == "en": + if not family: continue name = str(language.get("name_english") or language.get("name_native") or family.upper()).strip() region = str(language.get("country_english") or language.get("region") or "").strip() - _register_language(languages, family=family, name=name, region=region, count=0) + _register_language( + languages, + family=family, + name=name, + region=region, + count=0, + engine=ENGINE_PIPER, + ) + + if "en" not in languages: + _register_language(languages, family="en", name="English", count=0, engine=ENGINE_OMNIVOICE) + + engine_order = (ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS, ENGINE_PIPER) + display_names = { + ENGINE_OMNIVOICE: "OmniVoice", + ENGINE_QWEN3: "Qwen3", + ENGINE_MOSS: "MOSS", + ENGINE_PIPER: "Piper", + } + quality_labels = { + "recommended": "Recommended", + "supported": "Supported", + "experimental": "Experimental", + "legacy": "Legacy", + } + for entry in languages.values(): + entry["engines"] = [engine for engine in engine_order if engine in entry["engines"]] + entry["quality"] = quality_for_engines(entry["engines"]) + entry["engine_labels"] = [display_names[engine] for engine in entry["engines"]] + entry["label"] = ( + f"{entry['name']} ({entry['code']}) — " + f"{quality_labels[entry['quality']]}" + ) ordered = [languages["en"]] ordered.extend( @@ -1732,15 +2092,40 @@ def _available_languages() -> List[Dict[str, Any]]: def _normalize_language(language: str | None) -> str: - requested = (language or DEFAULT_LANGUAGE).strip().lower() or DEFAULT_LANGUAGE + requested = (language or DEFAULT_LANGUAGE).strip().lower().replace("-", "_") or DEFAULT_LANGUAGE available_codes = {item["code"] for item in _available_languages()} if requested in available_codes: return requested + family = requested.split("_", 1)[0] + if family in available_codes: + return family if DEFAULT_LANGUAGE in available_codes: return DEFAULT_LANGUAGE return "en" +def _resolve_tts_mode_for_language( + mode: Any, + language: str, + available_languages: List[Dict[str, Any]], +) -> str: + selected = normalize_tts_mode(mode) + entry = next( + (item for item in available_languages if item.get("code") == language), + {}, + ) + engines = set(entry.get("engines") or []) + has_modern = bool(engines.intersection({ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS})) + has_piper = ENGINE_PIPER in engines + if selected == "piper" and not has_piper: + return "modern" if has_modern else selected + if selected in {"modern", "hybrid"} and not has_modern and has_piper: + return "piper" + if selected == "hybrid" and not has_piper: + return "modern" + return selected + + def _catalog_voice_files(language_family: str) -> List[tuple[str, str]]: if not language_family or language_family == "en": return [] @@ -2410,6 +2795,7 @@ def _run_streamed( header: Optional[str] = None, env: Optional[Dict[str, str]] = None, ) -> int: + global TRAINING_PROCESS if header: _append_train_log(header) @@ -2430,15 +2816,26 @@ def _run_streamed( text=True, bufsize=1, env=env, + start_new_session=(os.name == "posix"), ) - - assert proc.stdout is not None - for line in proc.stdout: - lf.write(line) - lf.flush() - _append_train_log(line) - - return proc.wait() + with TRAINING_RUNTIME_LOCK: + TRAINING_PROCESS = proc + if TRAINING_STOP_EVENT.is_set(): + _terminate_training_process_tree(proc) + try: + assert proc.stdout is not None + for line in proc.stdout: + lf.write(line) + lf.flush() + _append_train_log(line) + return proc.wait() + finally: + with contextlib.suppress(Exception): + if proc.stdout is not None: + proc.stdout.close() + with TRAINING_RUNTIME_LOCK: + if TRAINING_PROCESS is proc: + TRAINING_PROCESS = None def _ensure_training_venv(log_path: Path) -> None: @@ -2602,21 +2999,120 @@ def _normalize_output_artifacts(safe_word: str, log_path: Path) -> None: _append_train_log(f"✅ Trained wake words synced to {TRAINED_WAKE_WORDS_DIR}") +def _terminate_training_process_tree( + proc: subprocess.Popen, + *, + graceful_timeout: float = 12.0, + kill_timeout: float = 3.0, +) -> bool: + if proc.poll() is not None: + return True + process_group = None + if os.name == "posix": + with contextlib.suppress(Exception): + candidate = os.getpgid(proc.pid) + if candidate > 0 and candidate != os.getpgrp(): + process_group = candidate + try: + if process_group is not None: + os.killpg(process_group, signal.SIGTERM) + else: + proc.terminate() + except ProcessLookupError: + return True + except Exception as exc: + _append_train_log(f"⚠ Could not request a graceful training stop: {exc}") + try: + proc.wait(timeout=max(0.1, float(graceful_timeout))) + return True + except subprocess.TimeoutExpired: + _append_train_log("⚠ Training did not stop gracefully; forcing its process group to exit.") + try: + if process_group is not None: + os.killpg(process_group, signal.SIGKILL) + else: + proc.kill() + except ProcessLookupError: + return True + except Exception as exc: + _append_train_log(f"⚠ Could not force the training process to stop: {exc}") + try: + proc.wait(timeout=max(0.1, float(kill_timeout))) + except subprocess.TimeoutExpired: + return False + return proc.poll() is not None + + +def _start_training_thread( + safe_word: str, + language: str, + allow_no_personal: bool, + auto_run: bool, + tts_mode: str, +) -> threading.Thread: + global TRAINING_THREAD + thread = threading.Thread( + target=_run_training_background, + args=(safe_word, language, allow_no_personal, auto_run, tts_mode), + daemon=True, + name="wake-word-training", + ) + with TRAINING_RUNTIME_LOCK: + if TRAINING_THREAD is not None and TRAINING_THREAD.is_alive(): + raise RuntimeError("Training is already running.") + TRAINING_STOP_EVENT.clear() + TRAINING_THREAD = thread + try: + thread.start() + except Exception: + with TRAINING_RUNTIME_LOCK: + if TRAINING_THREAD is thread: + TRAINING_THREAD = None + raise + return thread + + +def _stop_current_training(timeout: float = 20.0) -> bool: + TRAINING_STOP_EVENT.set() + with TRAINING_RUNTIME_LOCK: + proc = TRAINING_PROCESS + thread = TRAINING_THREAD + stopped = True + if proc is not None: + stopped = _terminate_training_process_tree( + proc, + graceful_timeout=min(12.0, max(1.0, float(timeout))), + ) + if thread is not None and thread is not threading.current_thread() and thread.is_alive(): + thread.join(timeout=max(0.1, float(timeout))) + stopped = stopped and not thread.is_alive() + if stopped: + TRAINING_STOP_EVENT.clear() + return stopped + + def _run_training_background( safe_word: str, language: str, allow_no_personal: bool, auto_run: bool = False, + tts_mode: str = DEFAULT_SERVER_TTS_MODE, ): + global TRAINING_PROCESS, TRAINING_THREAD language = (language or DEFAULT_LANGUAGE).strip().lower() or DEFAULT_LANGUAGE + tts_mode = normalize_tts_mode(tts_mode) rc = 999 + proc: subprocess.Popen | None = None with STATE_LOCK: raw_phrase = STATE.get("raw_phrase") or "" wake_word_title = _title_from_phrase(raw_phrase) - with STATE_LOCK: - STATE["training"]["running"] = True + with DATA_MANAGEMENT_LOCK: + with STATE_LOCK: + if STATE["training"]["running"]: + return JSONResponse({"ok": False, "error": "Training already running"}, status_code=400) + STATE["training"]["running"] = True STATE["training"]["exit_code"] = None STATE["training"]["log_lines"] = [] STATE["training"]["safe_word"] = safe_word @@ -2641,13 +3137,18 @@ def _run_training_background( try: _ensure_training_venv(log_path) _ensure_training_datasets(log_path) - if language != "en": + if language != "en" and tts_mode == "piper": _ensure_non_english_language_voices(language, _append_train_log) + elif language != "en" and tts_mode == "hybrid": + try: + _ensure_non_english_language_voices(language, _append_train_log) + except Exception as error: + _append_train_log(f"⚠️ Piper is unavailable for hybrid mode; using modern TTS only: {error}") + command_args = [f"--language={language}", f"--tts-mode={tts_mode}", safe_word] if wake_word_title: - cmd_str = f"{TRAIN_CMD} --language='{language}' '{safe_word}' '{wake_word_title}'" - else: - cmd_str = f"{TRAIN_CMD} --language='{language}' '{safe_word}'" + command_args.append(wake_word_title) + cmd_str = f"{TRAIN_CMD} " + " ".join(shlex.quote(argument) for argument in command_args) env = os.environ.copy() env["MWW_ALLOW_NO_PERSONAL"] = "true" if allow_no_personal else "false" @@ -2664,29 +3165,50 @@ def _run_training_background( text=True, bufsize=1, env=env, + start_new_session=(os.name == "posix"), ) + with TRAINING_RUNTIME_LOCK: + TRAINING_PROCESS = proc + if TRAINING_STOP_EVENT.is_set(): + _append_train_log("→ Session stop requested; stopping the active training run.") + _terminate_training_process_tree(proc) assert proc.stdout is not None - for line in proc.stdout: - lf.write(line) - lf.flush() - _append_train_log(line) + try: + for line in proc.stdout: + lf.write(line) + lf.flush() + _append_train_log(line) + finally: + with contextlib.suppress(Exception): + proc.stdout.close() rc = proc.wait() - _append_train_log(f"✓ Training finished (exit_code={rc})") + if TRAINING_STOP_EVENT.is_set() and rc != 0: + _append_train_log(f"→ Training stopped for session stop (exit_code={rc})") + else: + _append_train_log(f"✓ Training finished (exit_code={rc})") with STATE_LOCK: STATE["training"]["exit_code"] = rc - if rc == 0: + if rc == 0 and not TRAINING_STOP_EVENT.is_set(): _normalize_output_artifacts(safe_word, log_path) except Exception as e: - rc = 999 - _append_train_log(f"✗ Training crashed: {e!r}") + rc = -signal.SIGTERM if TRAINING_STOP_EVENT.is_set() else 999 + if TRAINING_STOP_EVENT.is_set(): + _append_train_log("→ Training stopped cleanly for session stop.") + else: + _append_train_log(f"✗ Training crashed: {e!r}") with STATE_LOCK: - STATE["training"]["exit_code"] = 999 + STATE["training"]["exit_code"] = rc finally: + with TRAINING_RUNTIME_LOCK: + if TRAINING_PROCESS is proc: + TRAINING_PROCESS = None + if TRAINING_THREAD is threading.current_thread(): + TRAINING_THREAD = None with STATE_LOCK: STATE["training"]["running"] = False @@ -2694,7 +3216,7 @@ def _run_training_background( with AUTO_TRAIN_LOCK: AUTO_TRAIN_STATE["last_train_finished_at"] = _iso_now() AUTO_TRAIN_STATE["last_train_exit_code"] = rc - if rc == 0: + if rc == 0 and not TRAINING_STOP_EVENT.is_set(): consumed = int(AUTO_TRAIN_RUNTIME.get("training_pending_consumed") or 0) AUTO_TRAIN_STATE["pending_negative_count"] = max( 0, @@ -2702,7 +3224,7 @@ def _run_training_background( ) AUTO_TRAIN_RUNTIME["training_pending_consumed"] = 0 _save_auto_train_state_locked() - if rc == 0: + if rc == 0 and not TRAINING_STOP_EVENT.is_set(): _append_train_log("→ Publishing the newly trained wake word to Tater and all satellites") notify_result = _notify_tater_satellites(safe_word) if notify_result.get("ok"): @@ -2714,6 +3236,7 @@ def _run_training_background( _append_train_log(f"✓ New wake word activated through Tater{suffix}") else: _append_train_log(f"✗ Tater wake-word activation failed: {notify_result.get('error')}") + TRAINING_STOP_EVENT.clear() # -------------------- Routes -------------------- @@ -2725,6 +3248,7 @@ def start_auto_train_worker_event(): @app.on_event("shutdown") def stop_auto_train_worker_event(): _stop_auto_train_worker() + _stop_current_training(timeout=20.0) @app.get("/api/auto_train") @@ -2835,6 +3359,18 @@ def index(): @app.post("/api/start_session") def start_session(payload: Dict[str, Any]): + with STATE_LOCK: + active_safe_word = STATE.get("safe_word") + if active_safe_word: + return JSONResponse( + { + "ok": False, + "error": f"Session '{active_safe_word}' is already active. Stop it before changing the wake phrase.", + "code": "SESSION_ACTIVE", + }, + status_code=409, + ) + raw = (payload.get("phrase") or "").strip() if not raw: return JSONResponse({"ok": False, "error": "phrase is required"}, status_code=400) @@ -2843,8 +3379,13 @@ def start_session(payload: Dict[str, Any]): speakers_total = int(payload.get("speakers_total") or SPEAKERS_TOTAL_DEFAULT) takes_per_speaker = int(payload.get("takes_per_speaker") or TAKES_PER_SPEAKER_DEFAULT) - language = _normalize_language(payload.get("language")) available_languages = _available_languages() + language = _normalize_language(payload.get("language")) + tts_mode = _resolve_tts_mode_for_language( + payload.get("tts_mode", DEFAULT_SERVER_TTS_MODE), + language, + available_languages, + ) speakers_total = max(1, min(10, speakers_total)) takes_per_speaker = max(1, min(50, takes_per_speaker)) @@ -2853,6 +3394,7 @@ def start_session(payload: Dict[str, Any]): STATE["raw_phrase"] = raw STATE["safe_word"] = safe STATE["language"] = language + STATE["tts_mode"] = tts_mode STATE["speakers_total"] = speakers_total STATE["takes_per_speaker"] = takes_per_speaker # do not interrupt training if running @@ -2865,6 +3407,7 @@ def start_session(payload: Dict[str, Any]): "raw_phrase": raw, "safe_word": safe, "language": language, + "tts_mode": tts_mode, "speakers_total": speakers_total, "takes_per_speaker": takes_per_speaker, "takes_total": speakers_total * takes_per_speaker, @@ -2876,18 +3419,61 @@ def start_session(payload: Dict[str, Any]): } +@app.post("/api/stop_session") +def stop_session(): + with STATE_LOCK: + had_session = bool(STATE.get("safe_word")) + training_running = bool(STATE["training"].get("running")) + + if training_running and not _stop_current_training(timeout=20.0): + return JSONResponse( + { + "ok": False, + "error": "Training did not stop cleanly; the session remains active.", + "code": "TRAINING_STOP_TIMEOUT", + }, + status_code=500, + ) + + takes = _sync_personal_samples_state() + available_languages = _available_languages() + with STATE_LOCK: + STATE["raw_phrase"] = None + STATE["safe_word"] = None + STATE["training"]["safe_word"] = None + training = dict(STATE["training"]) + language = _normalize_language(STATE["language"]) + tts_mode = normalize_tts_mode(STATE.get("tts_mode")) + return { + "ok": True, + "session_stopped": had_session, + "training_stopped": training_running, + "raw_phrase": None, + "safe_word": None, + "language": language, + "tts_mode": tts_mode, + "takes_received": len(takes), + "takes": list(takes), + "training": training, + "available_languages": available_languages, + } + + @app.get("/api/session") def get_session(): takes = _sync_personal_samples_state() available_languages = _available_languages() with STATE_LOCK: current_language = _normalize_language(STATE["language"]) + current_tts_mode = normalize_tts_mode(STATE.get("tts_mode")) STATE["language"] = current_language + STATE["tts_mode"] = current_tts_mode return { "ok": True, "raw_phrase": STATE["raw_phrase"], "safe_word": STATE["safe_word"], "language": current_language, + "tts_mode": current_tts_mode, "speakers_total": STATE["speakers_total"], "takes_per_speaker": STATE["takes_per_speaker"], "takes_received": len(takes), @@ -3129,6 +3715,23 @@ def samples(): return _samples_payload() +@app.get("/api/data") +def managed_data(): + return _managed_data_payload() + + +@app.delete("/api/data/{item_id}") +def delete_managed_data(item_id: str): + try: + return _delete_managed_data_item(item_id) + except KeyError as exc: + return JSONResponse({"ok": False, "error": str(exc.args[0])}, status_code=404) + except RuntimeError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=409) + except OSError as exc: + return JSONResponse({"ok": False, "error": f"Could not delete trainer data: {exc}"}, status_code=500) + + @app.get("/api/audio/{bucket}/{file_name}") def audio_file(bucket: str, file_name: str): bucket_map = { @@ -3371,6 +3974,7 @@ def train_now(payload: Dict[str, Any] = None): with STATE_LOCK: safe_word = STATE["safe_word"] language = (STATE.get("language") or DEFAULT_LANGUAGE) + tts_mode = normalize_tts_mode(STATE.get("tts_mode")) takes_received = int(STATE["takes_received"]) speakers_total = int(STATE["speakers_total"]) takes_per_speaker = int(STATE["takes_per_speaker"]) @@ -3398,17 +4002,22 @@ def train_now(payload: Dict[str, Any] = None): with STATE_LOCK: STATE["training"]["running"] = True - t = threading.Thread( - target=_run_training_background, - args=(safe_word, language, allow_no_personal, False), - daemon=True, - ) - t.start() + try: + _start_training_thread(safe_word, language, allow_no_personal, False, tts_mode) + except Exception as exc: + with STATE_LOCK: + STATE["training"]["running"] = False + return JSONResponse( + {"ok": False, "error": f"Could not start training: {exc}"}, + status_code=500, + ) return { "ok": True, "started": True, "safe_word": safe_word, + "language": language, + "tts_mode": tts_mode, "personal_samples_used": takes_received > 0, "allow_no_personal": allow_no_personal, } diff --git a/tts_config.py b/tts_config.py new file mode 100644 index 0000000..2490017 --- /dev/null +++ b/tts_config.py @@ -0,0 +1,221 @@ +"""Shared modern-TTS catalog and routing helpers. + +This module intentionally has no third-party dependencies. It is imported by +the web server, the shell-facing generator, and unit tests before any of the +large model environments have been installed. +""" + +from __future__ import annotations + +from typing import Iterable + + +TTS_MODE_MODERN = "modern" +TTS_MODE_HYBRID = "hybrid" +TTS_MODE_PIPER = "piper" +TTS_MODES = (TTS_MODE_MODERN, TTS_MODE_HYBRID, TTS_MODE_PIPER) +DEFAULT_TTS_MODE = TTS_MODE_HYBRID + +ENGINE_OMNIVOICE = "omnivoice" +ENGINE_QWEN3 = "qwen3" +ENGINE_MOSS = "moss" +ENGINE_PIPER = "piper" +MODERN_ENGINES = (ENGINE_OMNIVOICE, ENGINE_QWEN3, ENGINE_MOSS) + +# Friendly/common codes whose OmniVoice IDs follow the model's catalog IDs. +OMNIVOICE_LANGUAGE_ALIASES = { + "ar": "arb", # Standard Arabic + "ne": "npi", # Nepali +} + + +QWEN_LANGUAGES = { + "zh": "Chinese", + "en": "English", + "ja": "Japanese", + "ko": "Korean", + "de": "German", + "fr": "French", + "ru": "Russian", + "pt": "Portuguese", + "es": "Spanish", + "it": "Italian", +} + +# The upstream MOSS-TTS-Nano README calls this a 20-language list, although +# the published table currently contains the 19 concrete entries below. +MOSS_LANGUAGES = { + "zh": "Chinese", + "en": "English", + "de": "German", + "es": "Spanish", + "fr": "French", + "ja": "Japanese", + "it": "Italian", + "hu": "Hungarian", + "ko": "Korean", + "ru": "Russian", + "fa": "Persian (Farsi)", + "ar": "Arabic", + "pl": "Polish", + "pt": "Portuguese", + "cs": "Czech", + "da": "Danish", + "sv": "Swedish", + "el": "Greek", + "tr": "Turkish", +} + +# Used when the live OmniVoice catalog has not been downloaded yet. The web +# server expands this to the full upstream catalog (currently 646 languages) +# and persists it under /data/.cache. +COMMON_OMNIVOICE_LANGUAGES = { + **QWEN_LANGUAGES, + **MOSS_LANGUAGES, + "af": "Afrikaans", + "am": "Amharic", + "as": "Assamese", + "az": "Azerbaijani", + "be": "Belarusian", + "bg": "Bulgarian", + "bn": "Bengali", + "bs": "Bosnian", + "ca": "Catalan", + "cy": "Welsh", + "et": "Estonian", + "eu": "Basque", + "fi": "Finnish", + "fil": "Filipino", + "gl": "Galician", + "gu": "Gujarati", + "he": "Hebrew", + "hi": "Hindi", + "hr": "Croatian", + "hy": "Armenian", + "id": "Indonesian", + "ka": "Georgian", + "kk": "Kazakh", + "lt": "Lithuanian", + "lv": "Latvian", + "mk": "Macedonian", + "ml": "Malayalam", + "mr": "Marathi", + "ms": "Malay", + "my": "Burmese", + "ne": "Nepali", + "nl": "Dutch", + "no": "Norwegian", + "pa": "Punjabi", + "ro": "Romanian", + "sk": "Slovak", + "sl": "Slovenian", + "sq": "Albanian", + "sr": "Serbian", + "sw": "Swahili", + "ta": "Tamil", + "te": "Telugu", + "th": "Thai", + "uk": "Ukrainian", + "ur": "Urdu", + "vi": "Vietnamese", + "yue": "Cantonese", + "yo": "Yoruba", + "zu": "Zulu", +} + +QWEN_LANGUAGE_NAMES = {code: name for code, name in QWEN_LANGUAGES.items()} + + +def parse_omnivoice_catalog(markdown: str) -> dict[str, dict[str, object]]: + """Parse the upstream Markdown language table without third-party packages.""" + + entries: dict[str, dict[str, object]] = {} + for line in str(markdown or "").splitlines(): + if not line.lstrip().startswith("|"): + continue + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if len(cells) < 5 or not cells[0].isdigit(): + continue + name, code, iso_code, duration_text = cells[1:5] + code = code.strip().lower().replace("-", "_") + if not code or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789_" for character in code): + continue + try: + duration_hours = float(duration_text.replace(",", "")) + except ValueError: + duration_hours = 0.0 + entries[code] = { + "name": name or code.upper(), + "iso_639_3": iso_code, + "duration_hours": duration_hours, + } + return entries + + +def normalize_tts_mode(value: object) -> str: + token = str(value or DEFAULT_TTS_MODE).strip().lower().replace("-", "_") + return token if token in TTS_MODES else DEFAULT_TTS_MODE + + +def language_for_engine(engine: str, language: str) -> str: + code = str(language or "en").strip().lower().replace("-", "_") + if engine == ENGINE_OMNIVOICE: + return OMNIVOICE_LANGUAGE_ALIASES.get(code, code) + return code + + +def modern_engines_for_language(language: str) -> list[str]: + """Return modern engines ordered from broadest to most specialized.""" + + code = str(language or "en").strip().lower().replace("-", "_") + engines = [ENGINE_OMNIVOICE] + if code in QWEN_LANGUAGES: + engines.append(ENGINE_QWEN3) + if code in MOSS_LANGUAGES: + engines.append(ENGINE_MOSS) + return engines + + +def engines_for_language( + language: str, + mode: object = DEFAULT_TTS_MODE, + *, + piper_available: bool = False, +) -> list[str]: + selected_mode = normalize_tts_mode(mode) + if selected_mode == TTS_MODE_PIPER: + return [ENGINE_PIPER] if piper_available else [] + + engines = modern_engines_for_language(language) + if selected_mode == TTS_MODE_HYBRID and piper_available: + engines.append(ENGINE_PIPER) + return engines + + +def quality_for_engines(engines: Iterable[str]) -> str: + engine_set = set(engines) + if ENGINE_QWEN3 in engine_set and ENGINE_MOSS in engine_set: + return "recommended" + if ENGINE_MOSS in engine_set: + return "supported" + if ENGINE_OMNIVOICE in engine_set: + return "experimental" + return "legacy" + + +def distribute_samples(total: int, engines: Iterable[str]) -> dict[str, int]: + """Distribute an exact sample total as evenly as possible.""" + + ordered = list(dict.fromkeys(str(engine) for engine in engines if engine)) + if total < 0: + raise ValueError("total must be non-negative") + if not ordered: + if total: + raise ValueError("at least one engine is required") + return {} + + quotient, remainder = divmod(total, len(ordered)) + return { + engine: quotient + (1 if index < remainder else 0) + for index, engine in enumerate(ordered) + }