From ed86a92aeb09202083c6a47ff033eba64c2b1b02 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Fri, 25 Sep 2026 19:37:42 +0800 Subject: [PATCH] feat(audio): GPT-SoVITS setup card and character import Uses Anomalous_TTS interface v3 so a new user can get going without editing extra_model_paths.yaml or sorting model folders by hand. - Setup card on the GPT-SoVITS studio: character libraries (add/remove), pretrained files (per-item or all download with progress, or use an existing GPT-SoVITS package) and missing Python packages with the install command. Collapses to one line when ready. - Import form: drop files, pick them in the browser, or pick them on the ComfyUI machine (server-side path picker, no upload). Name, SoVITS version, clip length, lines, main voice and language are filled in from the node's inspect; emotions per clip. Uploads go in 8 MB chunks. - "Add files" on a card, or a drop onto it, adds clips and emotions to that character without replacing its main voice. - Hidden for nodes without v3; disabled when the page is not opened on the ComfyUI machine. - New modules: tts_setup_api.js (calls + pure form rules), ui_tts_setup.js, ui_tts_import.js, ui_tts_path_picker.js and styles/12-tts-setup.css. ui_audio_studio.js only wires them in. Tested: node tests/tts_setup_api.mjs (new, local) plus the audio engine, node target and script rule tests. Isolated ComfyUI with both plugins in the browser: setup card for a fresh user, package as pretrained source, library add, import from local paths (lines found in all.txt), file drop onto a card (chunked upload, add mode), cancel discards the upload. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 20 +- CHANGELOG.md | 1 + docs/guides/audio-studio.md | 29 +- web/modules/audio_engines.js | 20 ++ web/modules/locales.js | 192 +++++++++++++- web/modules/tts_setup_api.js | 164 ++++++++++++ web/modules/ui_audio_studio.js | 27 +- web/modules/ui_tts_import.js | 422 ++++++++++++++++++++++++++++++ web/modules/ui_tts_path_picker.js | 159 +++++++++++ web/modules/ui_tts_setup.js | 218 +++++++++++++++ web/styles.css | 1 + web/styles/12-tts-setup.css | 398 ++++++++++++++++++++++++++++ 12 files changed, 1638 insertions(+), 13 deletions(-) create mode 100644 web/modules/tts_setup_api.js create mode 100644 web/modules/ui_tts_import.js create mode 100644 web/modules/ui_tts_path_picker.js create mode 100644 web/modules/ui_tts_setup.js create mode 100644 web/styles/12-tts-setup.css diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 39f3fbf..d341a5d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,10 +134,12 @@ DOM or live LiteGraph state. voice-group shape (`node_value` = what the node's voice widget takes), so cards, sidebar and Script Director share one implementation. A missing engine stays in the switch marked "not installed" with install steps; nothing else depends on it. - GPT-SoVITS data comes only from the node's HTTP contract, version 2 - (`/anomalous_tts/characters`, `/audio`, `/settings`; the node repo's - `docs/INTERFACE.md`, mirrored as the project doc `anomalous-tts-interface.md`); - Anomalous never reads or writes its model folders. The character list is a + GPT-SoVITS data comes only from the node's HTTP contract, version 3 + (`/anomalous_tts/characters`, `/audio`, `/settings`, `/status`, the library, + pretrained, browse and import routes; the node repo's `docs/INTERFACE.md`, + mirrored as the project doc `anomalous-tts-interface.md`); Anomalous never + reads or writes its model folders. `loadGptSovitsStatus` caches the setup status + with the engine data (null for a node without v3, which hides setup and import). The character list is a summary without file lists; `fetchGptSovitsCharacter` gets one character's files. Engine presence and loaded voice groups are cached (`MAX_AGE_MS`) so re-renders from sidebar clicks, domain switches and the studio + sidebar pair cost no @@ -147,6 +149,16 @@ DOM or live LiteGraph state. `ui_audio_tts_editor.js` edits a character's emotion references through that API: it opens at once, loads the character's audio list in the background (save waits for it), and keeps settings fields it does not know. + `tts_setup_api.js` holds the other v3 calls (libraries, pretrained sources and + downloads, folder browse, chunked upload, inspect, commit, discard) and the pure + import-form rules (`importKind`, `buildImportBody`, `importProblem`, + `setupSummary`); no DOM. `ui_tts_setup.js` is the setup card at the top of the + GPT-SoVITS studio (polls the status only while a download runs and it is in the + page). `ui_tts_import.js` is the import form (new character or `target` = add + files; rows are built once and updated in place; closing discards unfinished + uploads) and `bindTtsFileDrop`, which turns OS file drops on the studio or a card + into that form. `ui_tts_path_picker.js` picks server-side folders or files + through the node's browse route, since the browser cannot see local paths. - `audio_node_targets.js` is the single table of canvas nodes the audio studio writes into and what each takes: F5-TTS (`F5TTSAudio`, `F5TTSAudioAdvanced`, `F5TTSAudioFromModel`) takes a whole character (main voice in `sample`, diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed992a..92860a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - **Clear drag rules (拖动规则明确)**: drag a character card's header onto an F5-TTS node (sets the main voice), or one row onto a Load Audio node (uses that exact clip). The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed. A single emotion clip is never written into F5-TTS's `sample`, which would break `{emotion}` tags. - **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`. - **Engine switch: F5-TTS / GPT-SoVITS (引擎切换)**: the audio page now manages voices per engine. Installed engines are detected at runtime; a missing one stays visible as "not installed" with install steps instead of disappearing, and nothing else depends on it. GPT-SoVITS characters come from the separate Anomalous_TTS node pack: preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and push scripts with the Script Director. +- **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: a setup card shows the character libraries, pretrained files and missing Python packages; add a library, download pretrained files with progress, or use an existing GPT-SoVITS package, all without editing `extra_model_paths.yaml` or restarting. **Import character** builds a character from dropped or picked GPT / SoVITS weights, reference clips and annotation files: the name, SoVITS version, clip lengths and lines are filled in automatically, and everything is copied (originals stay). Drop files on a card, or use **Add files**, to add clips and emotions to an existing character. Needs Anomalous_TTS with interface version 3; file actions only work on the computer running ComfyUI. ### Fixes - All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative. diff --git a/docs/guides/audio-studio.md b/docs/guides/audio-studio.md index c0a7cfe..1b25b14 100644 --- a/docs/guides/audio-studio.md +++ b/docs/guides/audio-studio.md @@ -110,10 +110,33 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行 ## 6. GPT-SoVITS(Anomalous TTS) -### 角色放在哪 +### 准备状态 + +切到 GPT-SoVITS 后,最上面是 **GPT-SoVITS 准备状态**。全部就绪时它收成一行,点一下展开: + +- **角色库**:节点从哪些文件夹里找角色,每个有几个角色。**+ 添加角色库** 选一个你已经整理好的文件夹(里面每个子文件夹是一个角色),马上生效,不用重启,也不用改 `extra_model_paths.yaml`。在这里添加的可以再移除;移除只是不再列出,不会删文件。 +- **底模**:每个底模是已就绪、缺少还是下载中。可以单个 **下载**、**全部下载**,或者 **使用已有的 GPT-SoVITS 整合包**(选整合包里的 `GPT_SoVITS` 文件夹),就不用再下一遍。只说日语的角色不需要中文底模;标“可选”的缺了也能用。 +- **Python 包**:缺了哪种语言要的包,会给出安装命令,复制到命令行运行后重启 ComfyUI。 + +这些操作会读写运行 ComfyUI 那台电脑上的文件,所以只能在那台电脑上用;从别的设备打开时按钮是灰的。 + +### 导入角色(新用户从这里开始) + +点 **+ 导入角色**,或者直接把文件拖到音频页上: + +1. 放进 GPT 权重(`.ckpt`)、SoVITS 权重(`.pth`)、参考音频,最好再加上 GPT-SoVITS 的标注文件(`.list` 或同格式的 `.txt`)。可以拖进来、用 **选择文件…**,或者用 **从本机路径选择…** 直接选电脑上的文件(比如整合包里的 `GPT_weights_v2`,不用上传,更快)。 +2. 表单会自动填好:角色名(取自 GPT 文件名)、SoVITS 的版本(v3 / v4 暂不支持,会提示)、每段音频的长度和台词(从标注文件里找)、主声音(第一段 3~10 秒且有台词的)、语言。都可以改。 +3. 其他音频填上情绪名(例如“开心”),就成了 `{开心}`。 +4. 选放到哪个角色库,点 **导入**。 + +节点会建好 `角色名/GPT_weights/`、`SoVITS_weights/`、`参考音频/` 和设置文件。**一律复制,原文件不动**;中途出错时角色库保持原样,不会留下半个角色。 + +给已有角色加音频或情绪:点卡片上的 **添加文件**,或者把文件拖到那张卡片上。已有的文件不会被覆盖,主声音也不会被换掉(除非你在表单里点了“主声音”)。 + +### 角色文件夹的规则 - 一个角色就是一个文件夹,里面至少有一个 `.ckpt`(GPT 权重)和一个 `.pth`(SoVITS 权重)。 -- 文件夹放在 `ComfyUI/models/gpt_sovits/` 下,或者在 `extra_model_paths.yaml` 里加一个 `gpt_sovits` 目录,直接用你原来的模型文件夹,不用复制。 +- 文件夹放在任何一个角色库里:默认的 `ComfyUI/models/gpt_sovits/`、在上面添加的角色库,或者 `extra_model_paths.yaml` 里的 `gpt_sovits` 目录。 - 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。 ### 编辑情绪 @@ -150,3 +173,5 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行 | 提示“这是 GPT-SoVITS 的声音,F5-TTS 节点用不了” | 两种引擎的声音不能混用。切到对应的引擎,拖到它自己的节点上 | | GPT-SoVITS 角色卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“编辑情绪”手动选一条 | | 新放进去的 GPT-SoVITS 角色或音频没出现 | 点 **刷新音色**。节点那边在 ComfyUI 里按 **R** 刷新下拉框 | +| 看不到“准备状态”和“导入角色” | Anomalous_TTS 版本太旧(没有接口版本 3)。更新 Anomalous_TTS 后重启 ComfyUI | +| “导入角色”是灰的 | 页面是从别的设备打开的。导入会写文件,只能在运行 ComfyUI 的电脑上做 | diff --git a/web/modules/audio_engines.js b/web/modules/audio_engines.js index 03c43e1..cbe9cae 100644 --- a/web/modules/audio_engines.js +++ b/web/modules/audio_engines.js @@ -174,9 +174,29 @@ async function loadGroups(engineId) { export function invalidateEngineCache({ rescan = false } = {}) { loadCache.clear(); installCache = null; + statusCache = null; if (rescan) rescanNext = true; } +let statusCache = null; // { at, promise } — GPT-SoVITS setup status + +/** + * The GPT-SoVITS node's setup status (libraries, pretrained files, packages; interface v3), + * or null for a node that predates it (no `/anomalous_tts/status`). Cached like loadEngine; + * `force` skips the cache (download progress polling). + */ +export function loadGptSovitsStatus({ force = false } = {}) { + if (!force && fresh(statusCache)) return statusCache.promise; + const promise = getJson('/anomalous_tts/status').then(({ ok, status, data }) => { + if (status === 404) return null; + if (!ok || !data) throw new Error(`HTTP ${status}`); + return data; + }); + statusCache = { at: Date.now(), promise }; + promise.catch(() => { if (statusCache?.promise === promise) statusCache = null; }); + return promise; +} + /** * { installed, groups, error } for one engine. The F5-TTS library is the plugin's own * input/F5-TTS folder, so it is listed even when the F5-TTS node is not installed. diff --git a/web/modules/locales.js b/web/modules/locales.js index d382226..304749a 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1368,7 +1368,101 @@ export const i18n = { ttsCharacterError: "这个角色读取失败:{error}", ttsMainMissing: "没有找到合适的主声音(3~10 秒的参考音频)。点“编辑情绪”选一条。", ttsEmptyTitle: "还没有 GPT-SoVITS 角色", - ttsEmptyDesc: "把角色文件夹(里面有 .ckpt 和 .pth)放进 ComfyUI/models/gpt_sovits/,或在 extra_model_paths.yaml 里加上 gpt_sovits 目录,然后点“刷新音色”。", + ttsEmptyDesc: "点上面的“导入角色”,或者直接把 GPT(.ckpt)、SoVITS(.pth)和参考音频拖到这里。已经整理好的角色文件夹可以用“添加角色库”。", + ttsSetupTitle: "GPT-SoVITS 准备状态", + ttsSetupCharacters: "{count} 个角色", + ttsSetupPretrainedOk: "底模齐全", + ttsSetupPretrainedMissing: "缺 {count} 个底模", + ttsSetupDownloading: "底模下载中…", + ttsSetupPackagesMissing: "缺 {count} 个 Python 包", + ttsSetupLibraries: "角色库", + ttsSetupPretrained: "底模", + ttsSetupPackages: "Python 包", + ttsSetupRemote: "这些操作只能在运行 ComfyUI 的那台电脑上进行(当前是从别的设备打开的)。", + ttsSetupFailed: "操作失败:{error}", + ttsLibrarySource_default: "默认", + ttsLibrarySource_yaml: "yaml 配置", + ttsLibrarySource_app: "在这里添加", + ttsLibraryMissing: "文件夹不存在", + ttsLibraryAdd: "+ 添加角色库", + ttsLibraryAddHint: "选一个已经整理好的文件夹:里面每个子文件夹是一个角色(有 .ckpt 和 .pth)。不会复制或移动任何文件。", + ttsLibraryHint: "角色库里每个子文件夹是一个角色。添加后马上生效,不用重启,也不用改 extra_model_paths.yaml。", + ttsLibraryRemove: "移除", + ttsLibraryRemoveConfirm: "不再使用这个角色库?\n{path}\n\n文件不会被删除,只是不再列出里面的角色。", + ttsNeededFor_all: "所有模型", + ttsNeededFor_zh: "中文", + ttsNeededFor_en: "英语", + ttsNeededFor_ja: "日语", + ttsNeededFor_v2pro: "v2Pro 模型", + ttsPretrainedOk: "已就绪", + ttsPretrainedMissing: "缺少", + ttsPretrainedOptional: "缺少(可选)", + ttsPretrainedQueued: "等待下载", + ttsPretrainedDownloading: "下载中 {percent}%", + ttsPretrainedError: "下载失败", + ttsPretrainedDownload: "下载(约 {size})", + ttsPretrainedDownloadAll: "全部下载(约 {size})", + ttsPretrainedUsePackage: "使用已有的 GPT-SoVITS 整合包", + ttsPretrainedPackageHint: "选择整合包里的 GPT_SoVITS 文件夹(里面有 pretrained_models 和 text),就不用再下载底模了。", + ttsPretrainedSource: "底模来源", + ttsPretrainedHint: "底模也会在第一次生成时自动下载;只说日语的角色不需要中文底模。标“可选”的缺了也能用,只是效果差一点。", + ttsPackagesFor: "{lang}缺少 {packages}", + ttsPackagesHint: "在命令行运行上面的命令,然后重启 ComfyUI。", + ttsCopyCommand: "复制命令", + ttsCopied: "已复制", + ttsKind_gpt: "GPT", + ttsKind_sovits: "SoVITS", + ttsKind_audio: "音频", + ttsKind_text: "台词", + ttsPickFolderTitle: "选择文件夹", + ttsPickFilesTitle: "选择文件", + ttsPickPathPlaceholder: "输入或粘贴路径,按回车打开", + ttsPickUp: "上一级", + ttsPickGo: "打开", + ttsPickThisFolder: "选择这个文件夹", + ttsPickAddFiles: "添加选中的文件({count})", + ttsPickEmpty: "这里没有可用的文件或文件夹", + ttsPickTruncated: "文件太多,只显示了前一部分", + ttsImportOpen: "+ 导入角色", + ttsImportAddOpen: "添加文件", + ttsImportTitle: "导入 GPT-SoVITS 角色", + ttsImportSubtitle: "放入 GPT 权重(.ckpt)、SoVITS 权重(.pth)、参考音频和台词文件,自动建好角色文件夹。所有文件都是复制,原文件不动。", + ttsImportAddTitle: "添加文件 · {name}", + ttsImportAddSubtitle: "给这个角色加参考音频、情绪或新的权重。文件会复制进角色文件夹,已有的文件不会被覆盖。", + ttsImportName: "角色名", + ttsImportNamePlaceholder: "例如 阿罗娜", + ttsImportLibrary: "放到哪个角色库", + ttsImportLibraryOption: "{path}({count} 个角色)", + ttsImportDropHint: "把文件拖到这里(可以一次拖多个)", + ttsImportDropFormats: ".ckpt · .pth · WAV / FLAC / OGG / MP3 · .txt / .list(GPT-SoVITS 标注文件,用来自动找台词)", + ttsImportPickFiles: "选择文件…", + ttsImportPickLocal: "从本机路径选择…", + ttsImportPickLocalHint: "直接选择电脑上的文件(比如整合包里的 GPT_weights_v2 / SoVITS_weights_v2),不用上传,更快。", + ttsImportSkipped: "跳过了不支持或重复的文件:{files}", + ttsImportRowUploading: "上传中 {percent}%", + ttsImportRowReady: "就绪", + ttsImportRowError: "失败:{error}", + ttsImportSeconds: "{seconds} 秒", + ttsImportUnsupported: "{version}(不支持)", + ttsImportMain: "主声音", + ttsImportMainTitle: "设为主声音(剧本里没标情绪的部分用它)", + ttsImportEmotionPlaceholder: "情绪名(可选)", + ttsImportTextPlaceholder: "参考台词(没找到就手动粘贴)", + ttsImportLanguage: "角色说的语言", + ttsImportLanguageAuto: "自动判断", + ttsImportLanguageAutoFound: "自动(识别为{lang})", + ttsImportNote: "会建立:{path}", + ttsImportAddNote: "文件会加进「{name}」的角色文件夹。", + ttsImportSubmit: "导入", + ttsImportAddSubmit: "添加", + ttsImportFailed: "导入失败:{error}", + ttsImportNoLibrary: "没有可以写入的角色库。先在准备状态里添加一个角色库。", + ttsImportNoFiles: "还没有放入文件", + ttsImportStillUploading: "还有 {count} 个文件在上传,请稍等", + ttsImportNameMissing: "请填写角色名", + ttsImportWeightsMissing: "新角色至少要有一个 GPT 权重(.ckpt)和一个 SoVITS 权重(.pth)", + ttsDropNew: "松开:导入为新角色", + ttsDropAddTo: "松开:添加到「{name}」", scriptDirectorMainMissingGptSovits: "这个角色还没有主声音,请先在卡片上点“编辑情绪”选一条参考音频", scriptDirectorNoCharactersGptSovits: "还没有 GPT-SoVITS 角色。", audioDragCardHint: "拖标题栏 → F5-TTS 节点 · 拖单条 → Load Audio · 点 {标签} 复制", @@ -2868,7 +2962,101 @@ export const i18n = { ttsCharacterError: "This character could not be read: {error}", ttsMainMissing: "No suitable main voice (a 3–10 s reference clip) was found. Use \"Edit emotions\" to pick one.", ttsEmptyTitle: "No GPT-SoVITS characters yet", - ttsEmptyDesc: "Put character folders (with .ckpt and .pth) into ComfyUI/models/gpt_sovits/, or add a gpt_sovits folder in extra_model_paths.yaml, then press Refresh Voices.", + ttsEmptyDesc: "Press \"Import character\" above, or drop the GPT (.ckpt), SoVITS (.pth) and reference clips here. Folders you already organised can be added with \"Add library\".", + ttsSetupTitle: "GPT-SoVITS setup", + ttsSetupCharacters: "{count} characters", + ttsSetupPretrainedOk: "pretrained files ready", + ttsSetupPretrainedMissing: "{count} pretrained files missing", + ttsSetupDownloading: "downloading pretrained files…", + ttsSetupPackagesMissing: "{count} Python packages missing", + ttsSetupLibraries: "Character libraries", + ttsSetupPretrained: "Pretrained files", + ttsSetupPackages: "Python packages", + ttsSetupRemote: "These actions only work on the computer running ComfyUI (this page was opened from another device).", + ttsSetupFailed: "Could not do that: {error}", + ttsLibrarySource_default: "default", + ttsLibrarySource_yaml: "yaml config", + ttsLibrarySource_app: "added here", + ttsLibraryMissing: "folder not found", + ttsLibraryAdd: "+ Add library", + ttsLibraryAddHint: "Pick a folder you already organised: each subfolder is one character (with .ckpt and .pth). Nothing is copied or moved.", + ttsLibraryHint: "Each subfolder of a library is one character. Added libraries work at once: no restart, no extra_model_paths.yaml.", + ttsLibraryRemove: "Remove", + ttsLibraryRemoveConfirm: "Stop using this library?\n{path}\n\nNo files are deleted; its characters are just no longer listed.", + ttsNeededFor_all: "all models", + ttsNeededFor_zh: "Chinese", + ttsNeededFor_en: "English", + ttsNeededFor_ja: "Japanese", + ttsNeededFor_v2pro: "v2Pro models", + ttsPretrainedOk: "ready", + ttsPretrainedMissing: "missing", + ttsPretrainedOptional: "missing (optional)", + ttsPretrainedQueued: "queued", + ttsPretrainedDownloading: "downloading {percent}%", + ttsPretrainedError: "download failed", + ttsPretrainedDownload: "Download (~{size})", + ttsPretrainedDownloadAll: "Download all (~{size})", + ttsPretrainedUsePackage: "Use an existing GPT-SoVITS package", + ttsPretrainedPackageHint: "Pick the package's GPT_SoVITS folder (it has pretrained_models and text); nothing needs downloading then.", + ttsPretrainedSource: "pretrained source", + ttsPretrainedHint: "Pretrained files are also downloaded on first use; Japanese-only characters do not need the Chinese ones. \"Optional\" ones only improve quality.", + ttsPackagesFor: "{lang}: missing {packages}", + ttsPackagesHint: "Run the command above in a terminal, then restart ComfyUI.", + ttsCopyCommand: "Copy command", + ttsCopied: "Copied", + ttsKind_gpt: "GPT", + ttsKind_sovits: "SoVITS", + ttsKind_audio: "audio", + ttsKind_text: "text", + ttsPickFolderTitle: "Choose a folder", + ttsPickFilesTitle: "Choose files", + ttsPickPathPlaceholder: "Type or paste a path, press Enter", + ttsPickUp: "Up", + ttsPickGo: "Open", + ttsPickThisFolder: "Choose this folder", + ttsPickAddFiles: "Add selected files ({count})", + ttsPickEmpty: "No usable files or folders here", + ttsPickTruncated: "Too many files: only the first ones are shown", + ttsImportOpen: "+ Import character", + ttsImportAddOpen: "Add files", + ttsImportTitle: "Import a GPT-SoVITS character", + ttsImportSubtitle: "Add the GPT weight (.ckpt), SoVITS weight (.pth), reference clips and line files; the character folder is built for you. Everything is copied; your files stay where they are.", + ttsImportAddTitle: "Add files · {name}", + ttsImportAddSubtitle: "Add reference clips, emotions or new weights to this character. Files are copied into its folder; existing files are never overwritten.", + ttsImportName: "Character name", + ttsImportNamePlaceholder: "e.g. Arona", + ttsImportLibrary: "Library", + ttsImportLibraryOption: "{path} ({count} characters)", + ttsImportDropHint: "Drop files here (several at once is fine)", + ttsImportDropFormats: ".ckpt · .pth · WAV / FLAC / OGG / MP3 · .txt / .list (GPT-SoVITS annotation files, used to find the lines)", + ttsImportPickFiles: "Choose files…", + ttsImportPickLocal: "Choose from this computer…", + ttsImportPickLocalHint: "Pick files on the computer directly (e.g. a package's GPT_weights_v2 / SoVITS_weights_v2): no upload, faster.", + ttsImportSkipped: "Skipped unsupported or duplicate files: {files}", + ttsImportRowUploading: "uploading {percent}%", + ttsImportRowReady: "ready", + ttsImportRowError: "failed: {error}", + ttsImportSeconds: "{seconds} s", + ttsImportUnsupported: "{version} (not supported)", + ttsImportMain: "Main voice", + ttsImportMainTitle: "Use as the main voice (for text without an emotion tag)", + ttsImportEmotionPlaceholder: "Emotion (optional)", + ttsImportTextPlaceholder: "Reference line (paste it if not found)", + ttsImportLanguage: "Character's language", + ttsImportLanguageAuto: "Detect", + ttsImportLanguageAutoFound: "Detect (found {lang})", + ttsImportNote: "Creates: {path}", + ttsImportAddNote: "Files are added to the folder of \"{name}\".", + ttsImportSubmit: "Import", + ttsImportAddSubmit: "Add", + ttsImportFailed: "Import failed: {error}", + ttsImportNoLibrary: "There is no writable character library. Add one in the setup card first.", + ttsImportNoFiles: "No files added yet", + ttsImportStillUploading: "{count} files are still uploading, please wait", + ttsImportNameMissing: "Enter a character name", + ttsImportWeightsMissing: "A new character needs at least one GPT weight (.ckpt) and one SoVITS weight (.pth)", + ttsDropNew: "Drop to import as a new character", + ttsDropAddTo: "Drop to add to \"{name}\"", scriptDirectorMainMissingGptSovits: "This character has no main voice yet. Pick a reference clip with \"Edit emotions\" on its card first", scriptDirectorNoCharactersGptSovits: "No GPT-SoVITS characters yet.", audioDragCardHint: "Drag the header → F5-TTS node · drag a row → Load Audio · click a {tag} to copy", diff --git a/web/modules/tts_setup_api.js b/web/modules/tts_setup_api.js new file mode 100644 index 0000000..1a01af2 --- /dev/null +++ b/web/modules/tts_setup_api.js @@ -0,0 +1,164 @@ +import { invalidateEngineCache } from './audio_engines.js'; + +/** + * GPT-SoVITS setup and import through the Anomalous_TTS node (interface v3, §5.2–5.3). + * The node owns every file: character libraries, pretrained files and imported + * characters. This module only calls its API and holds the pure rules the import + * form needs; it has no DOM. + */ + +export const UPLOAD_CHUNK = 8 * 1024 * 1024; // under ComfyUI's default 100 MB request limit + +const IMPORT_KINDS = { + '.ckpt': 'gpt', '.pth': 'sovits', + '.wav': 'audio', '.flac': 'audio', '.ogg': 'audio', '.mp3': 'audio', + '.txt': 'text', '.list': 'text', +}; +const FORBIDDEN_IN_EMOTION = /[{}[\]]/; + +/** gpt | sovits | audio | text | null — the node checks again; this only avoids uploading unusable files. */ +export function importKind(name) { + const dot = String(name || '').lastIndexOf('.'); + return dot < 0 ? null : IMPORT_KINDS[String(name).slice(dot).toLowerCase()] || null; +} + +async function request(url, { method = 'POST', body, raw, signal, keepalive } = {}) { + const headers = raw ? { 'Content-Type': 'application/octet-stream' } : body !== undefined ? { 'Content-Type': 'application/json' } : undefined; + const resp = await fetch(url, { + method, signal, keepalive, headers, + body: raw ?? (body !== undefined ? JSON.stringify(body) : undefined), + }); + const text = await resp.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch (_) { /* plain-text error message */ } + if (!resp.ok) { + const error = new Error(data?.error || text || `HTTP ${resp.status}`); + error.status = resp.status; + error.data = data; + throw error; + } + return data; +} + +// ---------- libraries, pretrained files ---------- + +/** Add (or remove) a character library. Returns the new status. */ +export async function changeLibrary(path, remove = false) { + const status = await request('/anomalous_tts/libraries', { body: { path, remove } }); + invalidateEngineCache(); + return status; +} + +/** Use (or stop using) a GPT-SoVITS package as a pretrained source. Returns the new status. */ +export async function changePretrainedSource(path, remove = false) { + const status = await request('/anomalous_tts/pretrained/source', { body: { path, remove } }); + invalidateEngineCache(); + return status; +} + +/** Start background downloads; progress shows up in the status. */ +export async function startPretrainedDownload(ids) { + await request('/anomalous_tts/pretrained/download', { body: { ids } }); + invalidateEngineCache(); +} + +export function browseFolder(path, signal) { + return request(`/anomalous_tts/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`, { method: 'GET', signal }); +} + +// ---------- import ---------- + +/** + * Upload one browser File in chunks. `onStart(id)` gets the upload id at once so the + * caller can discard it if the form is closed; a 409 resumes from what the node has. + */ +export async function uploadFile(file, { library, signal, onStart, onProgress } = {}) { + const { upload } = await request('/anomalous_tts/import/upload', { body: { name: file.name, size: file.size, library }, signal }); + onStart?.(upload); + let offset = 0; + while (offset < file.size) { + try { + const { received } = await request(`/anomalous_tts/import/upload?upload=${upload}&offset=${offset}`, { + raw: file.slice(offset, offset + UPLOAD_CHUNK), signal, + }); + offset = received; + } catch (e) { + if (e.status !== 409 || !Number.isInteger(e.data?.received)) throw e; + offset = e.data.received; + } + onProgress?.(offset / file.size); + } + return upload; +} + +export function inspectImport(files, signal) { + return request('/anomalous_tts/import/inspect', { body: { files }, signal }); +} + +/** Not tied to the form's AbortSignal: closing the form must not pretend to cancel a write. */ +export async function commitImport(body) { + const result = await request('/anomalous_tts/import/commit', { body }); + invalidateEngineCache(); + return result.character; +} + +/** Fire and forget; `keepalive` lets it finish while the page is closing. */ +export function discardUploads(ids) { + if (!ids.length) return; + request('/anomalous_tts/import/discard', { body: { uploads: ids }, keepalive: true }).catch(() => {}); +} + +/** + * The commit request for the import form. `rows` are in file order: + * { spec: {upload}|{path}, kind, emotion, text }. `language` '' = let the node decide. + */ +export function buildImportBody({ target = null, library = '', character = '', rows, referenceIndex = null, language = '' }) { + const settings = {}; + if (language) settings.language = language; + const main = referenceIndex !== null ? rows[referenceIndex] : null; + if (main?.kind === 'audio') { + settings.reference = { file: referenceIndex }; + if (main.text) settings.reference.text = main.text; + if (language) settings.reference.language = language; + } + const emotions = {}; + rows.forEach((row, index) => { + if (row.kind !== 'audio' || index === referenceIndex || !row.emotion) return; + emotions[row.emotion] = { file: index }; + if (row.text) emotions[row.emotion].text = row.text; + }); + if (Object.keys(emotions).length) settings.emotions = emotions; + const body = { files: rows.map(row => row.spec), settings }; + if (target) body.target = target; + else Object.assign(body, { library, character }); + return body; +} + +/** First reason the form cannot be sent, as [locale key, params], or null. */ +export function importProblem({ target = null, character = '', rows, uploading = 0 }) { + if (!rows.length) return ['ttsImportNoFiles', {}]; + if (uploading) return ['ttsImportStillUploading', { count: uploading }]; + if (!target) { + if (!character.trim()) return ['ttsImportNameMissing', {}]; + if (!rows.some(row => row.kind === 'gpt') || !rows.some(row => row.kind === 'sovits')) return ['ttsImportWeightsMissing', {}]; + } + const seen = new Set(); + for (const row of rows) { + if (row.kind !== 'audio' || !row.emotion) continue; + if (row.emotion === 'main' || FORBIDDEN_IN_EMOTION.test(row.emotion)) return ['ttsEditorNameInvalid', { name: row.emotion }]; + if (seen.has(row.emotion)) return ['ttsEditorNameDuplicate', { name: row.emotion }]; + seen.add(row.emotion); + } + return null; +} + +/** Summary for the setup card: what still needs doing. */ +export function setupSummary(status) { + const characters = (status?.libraries || []).reduce((sum, lib) => sum + (lib.characters || 0), 0); + const missing = (status?.pretrained || []).filter(item => item.state !== 'ok'); + const packages = Object.values(status?.dependencies || {}).reduce((sum, dep) => sum + (dep.missing?.length || 0), 0); + const downloading = missing.some(item => item.state === 'queued' || item.state === 'downloading'); + const requiredMissing = missing.filter(item => item.required).length; + return { characters, missing: missing.length, requiredMissing, packages, downloading, + ready: characters > 0 && requiredMissing === 0 && packages === 0 }; +} diff --git a/web/modules/ui_audio_studio.js b/web/modules/ui_audio_studio.js index be320cb..4a47ae5 100644 --- a/web/modules/ui_audio_studio.js +++ b/web/modules/ui_audio_studio.js @@ -5,8 +5,10 @@ import { stopGalleryAudio } from './ui_audio_gallery.js'; import { openAudioUploaderModal } from './ui_audio_uploader.js'; import { getActiveAudioFilter, renderAudioSidebar, setActiveAudioFilter } from './ui_audio_sidebar.js'; import { AUDIO_NODE_TARGETS, alignedVoiceValue, planVoiceDrop } from './audio_node_targets.js'; -import { AUDIO_ENGINES, detectEngines, engineById, engineTargetLabels, getStoredEngine, invalidateEngineCache, loadEngine, pickEngine, setStoredEngine } from './audio_engines.js'; +import { AUDIO_ENGINES, detectEngines, engineById, engineTargetLabels, getStoredEngine, invalidateEngineCache, loadEngine, loadGptSovitsStatus, pickEngine, setStoredEngine } from './audio_engines.js'; import { openGptSovitsEditor } from './ui_audio_tts_editor.js'; +import { renderTtsSetup } from './ui_tts_setup.js'; +import { bindTtsFileDrop, openTtsImport } from './ui_tts_import.js'; import { bindMaterialDrag } from './material_drag.js'; import { openScriptDirector, @@ -263,10 +265,11 @@ function renderSliceRow(slice, owner) { return row; } -function renderCharacterCard(group, owner, { onChanged } = {}) { +function renderCharacterCard(group, owner, { onChanged, canImport = false } = {}) { const isTts = group.engine === 'gpt_sovits'; const card = document.createElement('div'); card.className = 'anomalous-character-voice-card'; + if (isTts && canImport && !group.error) card.dataset.ttsCharacter = group.character; // file drop target const header = document.createElement('div'); header.className = 'anomalous-character-voice-header'; @@ -314,6 +317,11 @@ function renderCharacterCard(group, owner, { onChanged } = {}) { const edit = createToolButton(SVG.EDIT, t('ttsEditorOpen'), 'is-compact'); edit.onclick = () => openGptSovitsEditor(group, { onSaved: () => onChanged?.() }); headerRight.append(edit); + if (canImport) { + const add = createToolButton(SVG.PLUS, t('ttsImportAddOpen'), 'is-compact'); + add.onclick = () => openTtsImport({ target: group.character, onDone: () => onChanged?.() }); + headerRight.append(add); + } } headerRight.append(countBadge); header.append(titleGroup, headerRight); @@ -614,7 +622,9 @@ export async function renderAudioStudio(container, { filter = null, owner = null studioWrapper.lastChild.replaceWith(renderInstallCard(engine)); return; } - const result = await loadEngine(engineId); + // The setup status only exists for GPT-SoVITS nodes with interface v3 (null otherwise). + const statusRequest = engineId === 'gpt_sovits' ? loadGptSovitsStatus().catch(() => null) : null; + const [result, ttsStatus] = await Promise.all([loadEngine(engineId), statusRequest]); if (renderTokens.get(container) !== token) return; if (result.error) { studioWrapper.lastChild.replaceWith(renderStatus('anomalous-audio-status is-error', t('audioLoadFailed', { error: result.error }))); @@ -624,6 +634,14 @@ export async function renderAudioStudio(container, { filter = null, owner = null updateScriptDirectorVoices(characters, characterFilter?.value || null, engineId); if (!installed) studioWrapper.insertBefore(renderInstallCard(engine, { compact: true }), studioWrapper.lastChild); + const onChanged = () => { rerender(); if (owner) renderAudioSidebar(owner); }; + const canImport = Boolean(ttsStatus) && ttsStatus.local !== false; + if (ttsStatus) { + const onImport = (options = {}) => openTtsImport({ ...options, onDone: onChanged }); + studioWrapper.insertBefore(renderTtsSetup(ttsStatus, { onChanged, onImport: () => onImport() }), studioWrapper.lastChild); + if (canImport) bindTtsFileDrop(studioWrapper, (files, target) => onImport({ files, target })); + } + if (characterFilter) { characters = characters.filter(group => group.group === characterFilter.value); } @@ -632,7 +650,6 @@ export async function renderAudioStudio(container, { filter = null, owner = null studioWrapper.lastChild.replaceWith(renderEmptyGuide(engineId)); return; } - const onChanged = () => { rerender(); if (owner) renderAudioSidebar(owner); }; - characters.forEach(group => grid.appendChild(renderCharacterCard(group, owner, { onChanged }))); + characters.forEach(group => grid.appendChild(renderCharacterCard(group, owner, { onChanged, canImport }))); studioWrapper.lastChild.replaceWith(grid); } diff --git a/web/modules/ui_tts_import.js b/web/modules/ui_tts_import.js new file mode 100644 index 0000000..c766586 --- /dev/null +++ b/web/modules/ui_tts_import.js @@ -0,0 +1,422 @@ +import { t } from './interface_settings.js'; +import { createViewScope } from './ui_lifecycle.js'; +import { anomalousAlert } from './ui_dialog.js'; +import { loadGptSovitsStatus } from './audio_engines.js'; +import { buildImportBody, commitImport, discardUploads, importKind, importProblem, inspectImport, uploadFile } from './tts_setup_api.js'; +import { PICKER_OVERLAY_CLASS, formatSize, pickServerPath } from './ui_tts_path_picker.js'; + +/** + * Import GPT-SoVITS files as a new character, or add them to one (`target`), through + * the Anomalous_TTS node. Files come from a drop, the browser's file dialog (both are + * uploaded in chunks) or the node's folder picker (sent as paths). The node copies + * everything; the originals are never touched. + * + * Each file is one row object; its DOM is built once and updated in place, so + * upload progress and inspect results never steal focus from a field being typed in. + */ + +let activeScope = null; +const LANGUAGES = ['', 'ja', 'zh', 'en']; +const ACCEPT = '.ckpt,.pth,.wav,.flac,.ogg,.mp3,.txt,.list'; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className, label, onClick, title) { + const btn = el('button', className, label); + btn.type = 'button'; + if (title) { btn.title = title; btn.setAttribute('aria-label', title); } + btn.onclick = onClick; + return btn; +} + +function input(className, placeholder, value = '') { + const node = el('input', className); + node.type = 'text'; + node.placeholder = placeholder; + node.value = value; + return node; +} + +/** + * Let OS files be dropped on `root` (the studio). `onDrop(files, character)` gets the + * character of the card they were dropped on, or null. Internal drags are ignored. + */ +export function bindTtsFileDrop(root, onDrop) { + const hasFiles = e => [...(e.dataTransfer?.types || [])].includes('Files'); + let marked = null; + root.dataset.dropHint = t('ttsDropNew'); + const mark = (card) => { + if (marked === card) return; + marked?.classList.remove('is-file-target'); + card?.classList.add('is-file-target'); + marked = card; + root.dataset.dropHint = card ? t('ttsDropAddTo', { name: card.dataset.ttsCharacter }) : t('ttsDropNew'); + }; + const cardAt = e => e.target?.closest?.('[data-tts-character]') || null; + root.addEventListener('dragover', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + root.classList.add('is-file-dragover'); + mark(cardAt(e)); + }); + root.addEventListener('dragleave', (e) => { + if (root.contains(e.relatedTarget)) return; + root.classList.remove('is-file-dragover'); + mark(null); + }); + root.addEventListener('drop', (e) => { + if (!hasFiles(e)) return; + e.preventDefault(); + e.stopPropagation(); // ComfyUI would try to open the files as a workflow + const card = cardAt(e); + root.classList.remove('is-file-dragover'); + mark(null); + onDrop([...e.dataTransfer.files], card?.dataset.ttsCharacter || null); + }); +} + +/** Open the import form. `onDone(character)` runs after the node created or updated it. */ +export async function openTtsImport({ files = [], target = null, onDone } = {}) { + let status; + try { + status = await loadGptSovitsStatus(); + } catch (e) { + await anomalousAlert(t('ttsSetupFailed', { error: e.message || String(e) })); + return; + } + if (!status) return; + if (status.local === false) { + await anomalousAlert(t('ttsSetupRemote')); + return; + } + const libraries = status.libraries.filter(lib => lib.writable); + if (!target && !libraries.length) { + await anomalousAlert(t('ttsImportNoLibrary')); + return; + } + + activeScope?.dispose(); + const scope = createViewScope(); + activeScope = scope; + const rows = []; + let committed = false; + let referenceKey = null; + let referenceChosen = Boolean(target); // adding files never replaces the main voice unless asked + let nameEdited = false; + let inspectTimer = null; + let inspectToken = 0; + let player = null; + let nextKey = 0; + let uploads = Promise.resolve(); + + const overlay = el('div', 'anomalous-voice-modal-overlay'); + const modal = el('div', 'anomalous-voice-modal anomalous-tts-import'); + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const close = () => scope.dispose(); + scope.onDispose(() => { + clearTimeout(inspectTimer); + player?.pause(); + for (const row of rows) { + row.controller.abort(); + if (row.url) URL.revokeObjectURL(row.url); + } + if (!committed) discardUploads(rows.map(row => row.uploadId).filter(Boolean)); + overlay.remove(); + if (activeScope === scope) activeScope = null; + }); + // No close on a backdrop click: a stray click would throw away the files and uploads. + scope.listen(window, 'keydown', (e) => { + if (e.key !== 'Escape' || document.querySelector(`.anomalous-dialog-overlay, .${PICKER_OVERLAY_CLASS}`)) return; + e.stopPropagation(); + close(); + }, true); + + // ---- header, target ---- + const header = el('div', 'anomalous-voice-modal-header'); + const heading = el('div', 'anomalous-voice-modal-heading'); + heading.append( + el('div', 'anomalous-voice-modal-title', target ? t('ttsImportAddTitle', { name: target }) : t('ttsImportTitle')), + el('div', 'anomalous-voice-modal-subtitle', t(target ? 'ttsImportAddSubtitle' : 'ttsImportSubtitle')), + ); + header.append(heading, button('anomalous-voice-modal-close', '×', close, t('close'))); + + const nameInput = input('anomalous-uploader-input', t('ttsImportNamePlaceholder')); + nameInput.oninput = () => { nameEdited = true; syncNote(); }; + const librarySelect = el('select', 'anomalous-uploader-input'); + for (const lib of libraries) { + const option = el('option', '', t('ttsImportLibraryOption', { path: lib.path, count: lib.characters })); + option.value = lib.path; + librarySelect.append(option); + } + librarySelect.onchange = () => syncNote(); + const targetBox = el('div', 'anomalous-tts-import-target'); + if (!target) { + const nameField = el('label', 'anomalous-voice-field'); + nameField.append(el('span', 'anomalous-voice-field-label', t('ttsImportName')), nameInput); + const libField = el('label', 'anomalous-voice-field'); + libField.append(el('span', 'anomalous-voice-field-label', t('ttsImportLibrary')), librarySelect); + targetBox.append(nameField, libField); + } + + // ---- sources ---- + const fileInput = el('input'); + fileInput.type = 'file'; + fileInput.multiple = true; + fileInput.accept = ACCEPT; + fileInput.hidden = true; + fileInput.onchange = () => { addBrowserFiles([...fileInput.files]); fileInput.value = ''; }; + const dropzone = el('div', 'anomalous-audio-dropzone anomalous-tts-import-drop'); + const pickLocal = button('anomalous-tts-add', t('ttsImportPickLocal'), async (e) => { + e.stopPropagation(); + const paths = await pickServerPath({ mode: 'files', title: t('ttsImportPickLocal'), hint: t('ttsImportPickLocalHint') }); + if (paths && !scope.signal.aborted) addPaths(paths); + }); + const pickBrowser = button('anomalous-tts-add', t('ttsImportPickFiles'), (e) => { e.stopPropagation(); fileInput.click(); }); + const dropButtons = el('div', 'anomalous-tts-setup-actions'); + dropButtons.append(pickBrowser, pickLocal); + dropzone.append(el('div', 'anomalous-audio-dropzone-hint', t('ttsImportDropHint')), + el('div', 'anomalous-audio-dropzone-formats', t('ttsImportDropFormats')), dropButtons, fileInput); + dropzone.ondragover = (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }; + dropzone.ondragleave = () => dropzone.classList.remove('dragover'); + dropzone.ondrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + dropzone.classList.remove('dragover'); + addBrowserFiles([...(e.dataTransfer?.files || [])]); + }; + + const list = el('div', 'anomalous-tts-import-list'); + const skipped = el('div', 'anomalous-tts-hint'); + const problems = el('div', 'anomalous-tts-import-problems'); + + const languageSelect = el('select', 'anomalous-uploader-input'); + const languageField = el('label', 'anomalous-voice-field anomalous-tts-import-language'); + languageField.append(el('span', 'anomalous-voice-field-label', t('ttsImportLanguage')), languageSelect); + const fillLanguages = (suggested) => { + const keep = languageSelect.value; + languageSelect.replaceChildren(...LANGUAGES.map(code => { + const option = el('option', '', code ? t(`ttsNeededFor_${code}`) + : suggested ? t('ttsImportLanguageAutoFound', { lang: t(`ttsNeededFor_${suggested}`) }) : t('ttsImportLanguageAuto')); + option.value = code; + return option; + })); + languageSelect.value = keep; + }; + fillLanguages(null); + const note = el('div', 'anomalous-tts-hint'); + const syncNote = () => { + note.textContent = target ? t('ttsImportAddNote', { name: target }) + : t('ttsImportNote', { path: `${librarySelect.value}/${nameInput.value.trim() || '…'}` }); + }; + syncNote(); + + // ---- rows ---- + const readyRows = () => rows.filter(row => row.spec && !row.error); + + function renderState(row) { + const { state } = row.els; + state.classList.toggle('is-error', Boolean(row.error)); + state.textContent = row.error ? t('ttsImportRowError', { error: row.error }) + : row.spec ? t('ttsImportRowReady') : t('ttsImportRowUploading', { percent: Math.floor(100 * row.progress) }); + } + + function renderMeta(row) { + const info = row.info || {}; + const parts = row.size ? [formatSize(row.size)] : []; + if (info.version) parts.push(info.supported ? info.version : t('ttsImportUnsupported', { version: info.version })); + if (info.seconds !== undefined) parts.push(t('ttsImportSeconds', { seconds: info.seconds })); + row.els.meta.textContent = parts.join(' · '); + } + + function syncReference() { + for (const row of rows) { + if (!row.els.main) continue; + const on = row.key === referenceKey; + row.els.main.setAttribute('aria-pressed', String(on)); + row.els.main.classList.toggle('active', on); + row.els.emotion.disabled = on; + } + } + + function removeRow(row) { + row.controller.abort(); + if (row.uploadId) discardUploads([row.uploadId]); + if (row.url) URL.revokeObjectURL(row.url); + rows.splice(rows.indexOf(row), 1); + row.els.root.remove(); + if (referenceKey === row.key) referenceKey = null; + scheduleInspect(); + } + + function buildRow(row) { + const root = el('div', 'anomalous-tts-import-row'); + root.dataset.kind = row.kind; + const line = el('div', 'anomalous-tts-import-line'); + const name = el('span', 'anomalous-tts-picker-name', row.name); + name.title = row.path || row.name; + row.els = { root, meta: el('span', 'anomalous-tts-picker-meta'), state: el('span', 'anomalous-tts-import-state') }; + line.append(el('span', 'anomalous-tts-kind', t(`ttsKind_${row.kind}`)), name, row.els.meta, row.els.state, + button('anomalous-tts-remove', '×', () => removeRow(row), t('ttsEditorRemove'))); + root.append(line); + if (row.kind === 'audio') { + const audio = el('div', 'anomalous-tts-import-audio'); + row.els.main = button('anomalous-tag-chip', t('ttsImportMain'), () => { + referenceKey = referenceKey === row.key ? null : row.key; + referenceChosen = true; + syncReference(); + }, t('ttsImportMainTitle')); + const play = button('anomalous-audio-play-btn', '▶', () => { + player?.pause(); + row.url = row.url || URL.createObjectURL(row.file); + player = new Audio(row.url); + player.play().catch(() => {}); + }, t('audioPlay')); + play.hidden = !row.file; // local paths cannot be played from the browser + row.els.emotion = input('anomalous-tts-name-input', t('ttsImportEmotionPlaceholder')); + row.els.emotion.oninput = () => { row.emotion = row.els.emotion.value.trim(); }; + row.els.text = input('anomalous-tts-text-input', t('ttsImportTextPlaceholder')); + row.els.text.oninput = () => { row.text = row.els.text.value.trim(); row.textEdited = true; }; + audio.append(row.els.main, play, row.els.emotion, row.els.text); + root.append(audio); + } + renderMeta(row); + renderState(row); + return root; + } + + function addRow(fields) { + const duplicate = rows.some(row => row.kind === fields.kind && row.name.toLowerCase() === fields.name.toLowerCase()); + if (duplicate) return null; + const row = { key: nextKey++, progress: 0, emotion: '', text: '', textEdited: false, uploadId: null, + spec: null, error: '', info: null, controller: new AbortController(), ...fields }; + rows.push(row); + list.append(buildRow(row)); + return row; + } + + function reportSkipped(names) { + skipped.textContent = names.length ? t('ttsImportSkipped', { files: names.join(', ') }) : ''; + } + + function addBrowserFiles(fileList) { + const bad = []; + for (const file of fileList) { + const kind = importKind(file.name); + const row = kind ? addRow({ name: file.name, kind, size: file.size, file }) : null; + if (!row) { bad.push(file.name); continue; } + uploads = uploads.then(() => upload(row)); + } + reportSkipped(bad); + } + + function addPaths(paths) { + const bad = []; + for (const path of paths) { + const name = path.split('/').pop(); + const kind = importKind(name); + const row = kind ? addRow({ name, kind, size: 0, path, spec: { path } }) : null; + if (!row) bad.push(name); + } + reportSkipped(bad); + scheduleInspect(); + } + + async function upload(row) { + if (row.controller.signal.aborted) return; + try { + await uploadFile(row.file, { + library: target ? undefined : librarySelect.value, + signal: row.controller.signal, + onStart: (id) => { row.uploadId = id; }, + onProgress: (fraction) => { row.progress = fraction; renderState(row); }, + }); + row.spec = { upload: row.uploadId }; + } catch (e) { + if (row.controller.signal.aborted) return; + row.error = e.message || String(e); + } + renderState(row); + scheduleInspect(); + } + + function scheduleInspect() { + clearTimeout(inspectTimer); + inspectTimer = setTimeout(runInspect, 250); + } + + async function runInspect() { + const ready = readyRows(); + if (!ready.length || rows.some(row => !row.spec && !row.error)) { + if (!ready.length) problems.replaceChildren(); + return; + } + const mine = ++inspectToken; + try { + const result = await inspectImport(ready.map(row => row.spec), scope.signal); + if (mine !== inspectToken || scope.signal.aborted) return; + ready.forEach((row, i) => { + row.info = result.files[i]; + if (row.info.size) row.size = row.info.size; + renderMeta(row); + if (row.els.text && !row.textEdited && row.info.text) { + row.text = row.info.text; + row.els.text.value = row.info.text; + } + }); + const { suggested } = result; + if (!target && !nameEdited && suggested.name) { nameInput.value = suggested.name; syncNote(); } + if (!referenceChosen) referenceKey = suggested.reference !== null ? ready[suggested.reference]?.key ?? null : null; + syncReference(); + fillLanguages(suggested.language); + problems.replaceChildren(...result.problems.map(text => el('div', 'anomalous-tts-import-problem', text))); + } catch (e) { + if (mine !== inspectToken || scope.signal.aborted) return; + problems.replaceChildren(el('div', 'anomalous-tts-import-problem is-error', e.message || String(e))); + } + } + + // ---- footer ---- + const footer = el('div', 'anomalous-voice-modal-footer'); + const submit = button('anomalous-voice-modal-submit', t(target ? 'ttsImportAddSubmit' : 'ttsImportSubmit'), async () => { + const ready = readyRows(); + const pending = rows.filter(row => !row.spec && !row.error).length; + const problem = importProblem({ target, character: nameInput.value, rows: ready, uploading: pending }); + if (problem) { + await anomalousAlert(t(problem[0], problem[1])); + return; + } + const index = ready.findIndex(row => row.key === referenceKey); + const body = buildImportBody({ + target, library: librarySelect.value, character: nameInput.value.trim(), language: languageSelect.value, + referenceIndex: index >= 0 ? index : null, + rows: ready.map(row => ({ spec: row.spec, kind: row.kind, emotion: row.emotion, text: row.text })), + }); + submit.disabled = true; + try { + const character = await commitImport(body); + committed = true; + onDone?.(character); + close(); + } catch (e) { + if (!scope.signal.aborted) await anomalousAlert(t('ttsImportFailed', { error: e.message || String(e) })); + } finally { + if (!scope.signal.aborted) submit.disabled = false; + } + }); + footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), submit); + + const body = el('div', 'anomalous-tts-editor-body'); + body.append(targetBox, dropzone, skipped, list, languageField, problems, note); + modal.append(header, body, footer); + overlay.appendChild(modal); + document.body.appendChild(overlay); + addBrowserFiles(files); + (target ? pickBrowser : nameInput).focus(); +} diff --git a/web/modules/ui_tts_path_picker.js b/web/modules/ui_tts_path_picker.js new file mode 100644 index 0000000..6b5a89e --- /dev/null +++ b/web/modules/ui_tts_path_picker.js @@ -0,0 +1,159 @@ +import { t } from './interface_settings.js'; +import { createViewScope } from './ui_lifecycle.js'; +import { browseFolder } from './tts_setup_api.js'; + +/** + * Choose a folder, or files, on the computer running ComfyUI. The browser cannot + * see local paths, so the Anomalous_TTS node lists folders (`/anomalous_tts/browse`). + * Resolves with the folder path, an array of file paths, or null when cancelled. + */ + +const LAST_PATH_KEY = 'anomalous_tts_last_path'; +export const PICKER_OVERLAY_CLASS = 'anomalous-tts-picker-overlay'; +const FOLDER_SVG = ''; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className, label, onClick, title) { + const btn = el('button', className, label); + btn.type = 'button'; + if (title) { btn.title = title; btn.setAttribute('aria-label', title); } + btn.onclick = onClick; + return btn; +} + +function lastPath() { + try { return localStorage.getItem(LAST_PATH_KEY) || ''; } catch (_) { return ''; } +} + +function rememberPath(path) { + try { localStorage.setItem(LAST_PATH_KEY, path); } catch (_) { /* convenience only */ } +} + +export function formatSize(bytes) { + if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`; + if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +/** + * `mode`: 'folder' (resolve with the open folder) or 'files' (tick files, resolve with their paths). + * `kinds` limits the files shown in 'files' mode (gpt, sovits, audio, text). + */ +export function pickServerPath({ mode = 'folder', title, hint = '', kinds = null } = {}) { + return new Promise(resolve => { + const scope = createViewScope(); + let result = null; + let current = ''; + let token = 0; + const picked = new Set(); + + const overlay = el('div', `anomalous-voice-modal-overlay ${PICKER_OVERLAY_CLASS}`); + const modal = el('div', 'anomalous-voice-modal anomalous-tts-picker'); + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + const close = () => scope.dispose(); + scope.onDispose(() => { overlay.remove(); resolve(result); }); + overlay.onclick = (e) => { if (e.target === overlay) close(); }; + scope.listen(window, 'keydown', (e) => { + if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return; + e.stopPropagation(); + close(); + }, true); + + const header = el('div', 'anomalous-voice-modal-header'); + const heading = el('div', 'anomalous-voice-modal-heading'); + heading.append(el('div', 'anomalous-voice-modal-title', title || t(mode === 'folder' ? 'ttsPickFolderTitle' : 'ttsPickFilesTitle'))); + if (hint) heading.append(el('div', 'anomalous-voice-modal-subtitle', hint)); + header.append(heading, button('anomalous-voice-modal-close', '×', close, t('close'))); + + const pathInput = el('input', 'anomalous-tts-picker-path'); + pathInput.type = 'text'; + pathInput.placeholder = t('ttsPickPathPlaceholder'); + pathInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); open(pathInput.value.trim()); } }; + const upBtn = button('anomalous-tts-add', t('ttsPickUp'), () => open(upBtn.dataset.parent || '')); + const goBtn = button('anomalous-tts-add', t('ttsPickGo'), () => open(pathInput.value.trim())); + const bar = el('div', 'anomalous-tts-picker-bar'); + bar.append(upBtn, pathInput, goBtn); + + const list = el('div', 'anomalous-tts-picker-list'); + list.setAttribute('role', 'listbox'); + const status = el('div', 'anomalous-tts-hint'); + + const footer = el('div', 'anomalous-voice-modal-footer'); + const confirm = button('anomalous-voice-modal-submit', '', () => { + if (mode === 'folder') { + if (!current) return; + result = current; + } else { + if (!picked.size) return; + result = [...picked]; + } + rememberPath(current); + close(); + }); + const syncConfirm = () => { + confirm.textContent = mode === 'folder' ? t('ttsPickThisFolder') : t('ttsPickAddFiles', { count: picked.size }); + confirm.disabled = mode === 'folder' ? !current : !picked.size; + }; + footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), confirm); + + const fileRow = (folder, file) => { + const path = `${folder.replace(/\/$/, '')}/${file.name}`; + const row = el('label', 'anomalous-tts-picker-item is-file'); + const box = el('input'); + box.type = 'checkbox'; + box.checked = picked.has(path); + box.onchange = () => { if (box.checked) picked.add(path); else picked.delete(path); syncConfirm(); }; + row.append(box, el('span', 'anomalous-tts-picker-name', file.name), + el('span', 'anomalous-tts-kind', t(`ttsKind_${file.kind}`)), el('span', 'anomalous-tts-picker-meta', formatSize(file.size))); + return row; + }; + + async function open(path) { + const mine = ++token; + status.textContent = t('audioLoading'); + status.classList.remove('is-error'); + try { + const data = await browseFolder(path, scope.signal); + if (mine !== token || scope.signal.aborted) return; + current = data.path; + pathInput.value = data.path; + upBtn.disabled = data.parent === null; + upBtn.dataset.parent = data.parent || ''; + const items = data.dirs.map(name => { + const target = data.path ? `${data.path.replace(/\/$/, '')}/${name}` : name; + const row = button('anomalous-tts-picker-item is-dir', '', () => open(target)); + const icon = el('span', 'anomalous-tts-picker-icon'); + icon.innerHTML = FOLDER_SVG; + row.append(icon, el('span', 'anomalous-tts-picker-name', name)); + return row; + }); + if (mode === 'files') { + items.push(...data.files.filter(f => !kinds || kinds.includes(f.kind)).map(f => fileRow(data.path, f))); + } + list.replaceChildren(...items); + status.textContent = !items.length ? t('ttsPickEmpty') : data.truncated ? t('ttsPickTruncated') : ''; + syncConfirm(); + } catch (e) { + if (mine !== token || scope.signal.aborted) return; + if (path && path === initial && !current) { open(''); return; } // remembered folder is gone + status.textContent = e.message || String(e); + status.classList.add('is-error'); + } + } + + modal.append(header, bar, list, status, footer); + overlay.appendChild(modal); + document.body.appendChild(overlay); + syncConfirm(); + const initial = lastPath(); + open(initial); + pathInput.focus(); + }); +} diff --git a/web/modules/ui_tts_setup.js b/web/modules/ui_tts_setup.js new file mode 100644 index 0000000..86650bd --- /dev/null +++ b/web/modules/ui_tts_setup.js @@ -0,0 +1,218 @@ +import { t } from './interface_settings.js'; +import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; +import { loadGptSovitsStatus } from './audio_engines.js'; +import { changeLibrary, changePretrainedSource, setupSummary, startPretrainedDownload } from './tts_setup_api.js'; +import { formatSize, pickServerPath } from './ui_tts_path_picker.js'; + +/** + * GPT-SoVITS setup card at the top of the audio studio: character libraries, + * pretrained files (download or use an existing GPT-SoVITS package) and missing + * Python packages, from the node's `/anomalous_tts/status`. Collapsed to one line + * once everything is ready. While a download runs the card polls the status and + * redraws itself; it stops as soon as it is no longer in the page. + */ + +const OPEN_KEY = 'anomalous_tts_setup_open'; +const POLL_MS = 1000; + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className, label, onClick, title) { + const btn = el('button', className, label); + btn.type = 'button'; + if (title) btn.title = title; + btn.onclick = onClick; + return btn; +} + +function storedOpen() { + try { return localStorage.getItem(OPEN_KEY); } catch (_) { return null; } +} + +function storeOpen(open) { + try { localStorage.setItem(OPEN_KEY, open ? '1' : '0'); } catch (_) { /* convenience only */ } +} + +/** + * Run a change, report failures, then let the studio redraw. The button stays disabled + * while it runs; `work` returns false when the user cancelled (nothing to redraw). + */ +async function act(btn, work, onChanged) { + btn.disabled = true; + try { + if (await work() === false) btn.disabled = false; + else onChanged(); + } catch (e) { + await anomalousAlert(t('ttsSetupFailed', { error: e.message || String(e) })); + btn.disabled = false; + } +} + +function summaryText(status, summary) { + const parts = [t('ttsSetupCharacters', { count: summary.characters })]; + parts.push(summary.downloading ? t('ttsSetupDownloading') + : summary.missing ? t('ttsSetupPretrainedMissing', { count: summary.missing }) : t('ttsSetupPretrainedOk')); + if (summary.packages) parts.push(t('ttsSetupPackagesMissing', { count: summary.packages })); + return parts.join(' · '); +} + +function renderLibraries(status, local, onChanged) { + const group = el('div', 'anomalous-tts-setup-group'); + group.append(el('div', 'anomalous-tts-section', t('ttsSetupLibraries'))); + for (const lib of status.libraries) { + const row = el('div', 'anomalous-tts-setup-row'); + const path = el('span', 'anomalous-tts-setup-path', lib.path); + path.title = lib.path; + row.append(path, el('span', 'anomalous-tts-kind', t(`ttsLibrarySource_${lib.source}`)), + el('span', 'anomalous-tts-setup-meta', lib.exists ? t('ttsSetupCharacters', { count: lib.characters }) : t('ttsLibraryMissing'))); + if (lib.source === 'app') { + const remove = button('anomalous-tts-remove', '×', () => act(remove, async () => { + if (!await anomalousConfirm(t('ttsLibraryRemoveConfirm', { path: lib.path }))) return false; + await changeLibrary(lib.path, true); + }, onChanged), t('ttsLibraryRemove')); + remove.disabled = !local; + row.append(remove); + } + group.append(row); + } + const add = button('anomalous-tts-add', t('ttsLibraryAdd'), () => act(add, async () => { + const path = await pickServerPath({ mode: 'folder', title: t('ttsLibraryAdd'), hint: t('ttsLibraryAddHint') }); + if (!path) return false; + await changeLibrary(path); + }, onChanged)); + add.disabled = !local; + group.append(add, el('div', 'anomalous-tts-hint', t('ttsLibraryHint'))); + return group; +} + +function pretrainedState(item) { + if (item.state === 'ok') return el('span', 'anomalous-tts-state is-ok', t('ttsPretrainedOk')); + if (item.state === 'downloading') { + const pct = item.size ? Math.min(99, Math.floor(100 * (item.done || 0) / item.size)) : 0; + return el('span', 'anomalous-tts-state is-busy', t('ttsPretrainedDownloading', { percent: pct })); + } + if (item.state === 'queued') return el('span', 'anomalous-tts-state is-busy', t('ttsPretrainedQueued')); + const state = el('span', `anomalous-tts-state ${item.state === 'error' ? 'is-error' : 'is-missing'}`, + item.state === 'error' ? t('ttsPretrainedError') : t(item.required ? 'ttsPretrainedMissing' : 'ttsPretrainedOptional')); + if (item.error) state.title = item.error; + return state; +} + +function renderPretrained(status, local, onChanged) { + const group = el('div', 'anomalous-tts-setup-group'); + group.append(el('div', 'anomalous-tts-section', t('ttsSetupPretrained'))); + const missing = status.pretrained.filter(item => item.state === 'missing' || item.state === 'error'); + for (const item of status.pretrained) { + const row = el('div', 'anomalous-tts-setup-row'); + const label = el('span', 'anomalous-tts-setup-path', item.label); + label.title = item.path || item.error || ''; + row.append(label, el('span', 'anomalous-tts-kind', t(`ttsNeededFor_${item.needed_for}`)), pretrainedState(item)); + if (item.state === 'missing' || item.state === 'error') { + const get = button('anomalous-tts-add', t('ttsPretrainedDownload', { size: formatSize(item.size) }), + () => act(get, () => startPretrainedDownload([item.id]), onChanged)); + get.disabled = !local; + row.append(get); + } + group.append(row); + } + const actions = el('div', 'anomalous-tts-setup-actions'); + if (missing.length > 1) { + const total = missing.reduce((sum, item) => sum + item.size, 0); + const all = button('anomalous-tts-add', t('ttsPretrainedDownloadAll', { size: formatSize(total) }), + () => act(all, () => startPretrainedDownload(missing.map(item => item.id)), onChanged)); + all.disabled = !local; + actions.append(all); + } + const useSource = button('anomalous-tts-add', t('ttsPretrainedUsePackage'), () => act(useSource, async () => { + const path = await pickServerPath({ mode: 'folder', title: t('ttsPretrainedUsePackage'), hint: t('ttsPretrainedPackageHint') }); + if (!path) return false; + await changePretrainedSource(path); + }, onChanged)); + useSource.disabled = !local; + actions.append(useSource); + group.append(actions); + for (const source of status.pretrained_sources || []) { + const row = el('div', 'anomalous-tts-setup-row'); + const path = el('span', 'anomalous-tts-setup-path', source); + path.title = source; + const remove = button('anomalous-tts-remove', '×', () => act(remove, () => changePretrainedSource(source, true), onChanged), t('ttsLibraryRemove')); + remove.disabled = !local; + row.append(path, el('span', 'anomalous-tts-kind', t('ttsPretrainedSource')), remove); + group.append(row); + } + group.append(el('div', 'anomalous-tts-hint', t('ttsPretrainedHint'))); + return group; +} + +function renderPackages(status) { + const missing = Object.entries(status.dependencies || {}).filter(([, dep]) => !dep.ok); + if (!missing.length) return null; + const group = el('div', 'anomalous-tts-setup-group'); + group.append(el('div', 'anomalous-tts-section', t('ttsSetupPackages'))); + for (const [lang, dep] of missing) { + const row = el('div', 'anomalous-tts-setup-row is-package'); + const code = el('code', 'anomalous-tts-setup-command', dep.command); + const copy = button('anomalous-tts-add', t('ttsCopyCommand'), async () => { + try { await navigator.clipboard.writeText(dep.command); copy.textContent = t('ttsCopied'); } catch (_) { /* select by hand */ } + }); + row.append(el('span', 'anomalous-tts-setup-meta', t('ttsPackagesFor', { lang: t(`ttsNeededFor_${lang}`), packages: dep.missing.join(', ') })), code, copy); + group.append(row); + } + group.append(el('div', 'anomalous-tts-hint', t('ttsPackagesHint'))); + return group; +} + +/** + * The card for one status payload. `onChanged()` redraws the studio (libraries and + * sources change the character list); `onImport()` opens the import form. + */ +export function renderTtsSetup(status, { onChanged, onImport }) { + const summary = setupSummary(status); + const local = status.local !== false; + const card = el('section', `anomalous-tts-setup${summary.ready ? ' is-ready' : ''}`); + const stored = storedOpen(); + let open = stored === null ? !summary.ready : stored === '1'; + + const head = el('div', 'anomalous-tts-setup-head'); + const toggle = button('anomalous-tts-setup-toggle', '', () => { + open = !open; + storeOpen(open); + card.classList.toggle('is-open', open); + toggle.setAttribute('aria-expanded', String(open)); + }); + toggle.setAttribute('aria-expanded', String(open)); + toggle.append(el('span', 'anomalous-tts-setup-dot'), el('span', 'anomalous-tts-setup-title', t('ttsSetupTitle')), + el('span', 'anomalous-tts-setup-summary', summaryText(status, summary)), el('span', 'anomalous-tts-setup-chevron', '▾')); + const importBtn = button('anomalous-voice-modal-submit anomalous-tts-setup-import', t('ttsImportOpen'), onImport); + importBtn.disabled = !local; + if (!local) importBtn.title = t('ttsSetupRemote'); + head.append(toggle, importBtn); + + const body = el('div', 'anomalous-tts-setup-body'); + if (!local) body.append(el('div', 'anomalous-tts-setup-notice', t('ttsSetupRemote'))); + body.append(renderLibraries(status, local, onChanged), renderPretrained(status, local, onChanged)); + const packages = renderPackages(status); + if (packages) body.append(packages); + + card.classList.toggle('is-open', open); + card.append(head, body); + if (summary.downloading) pollWhileDownloading(card, { onChanged, onImport }); + return card; +} + +function pollWhileDownloading(card, options) { + setTimeout(async () => { + if (!card.isConnected) return; + try { + const status = await loadGptSovitsStatus({ force: true }); + if (card.isConnected && status) card.replaceWith(renderTtsSetup(status, options)); + } catch (_) { + if (card.isConnected) pollWhileDownloading(card, options); + } + }, POLL_MS); +} diff --git a/web/styles.css b/web/styles.css index b229b01..db4cb5d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -10,5 +10,6 @@ @import url("./styles/09-prompt-drawers.css?v=20260918-model-sources-1"); @import url("./styles/10-model-sources.css?v=20260918-model-sources-1"); @import url("./styles/11-audio-studio.css?v=20260925-tts-cards-1"); +@import url("./styles/12-tts-setup.css?v=20260925-tts-setup-1"); @import url("./styles/13-script-director.css?v=20260923"); @import url("./styles/14-version-manager.css"); diff --git a/web/styles/12-tts-setup.css b/web/styles/12-tts-setup.css new file mode 100644 index 0000000..0822904 --- /dev/null +++ b/web/styles/12-tts-setup.css @@ -0,0 +1,398 @@ +/* GPT-SoVITS setup card, import form, server path picker and studio file drop. + Modal shell, fields, chips and buttons come from 11-audio-studio.css. */ + +/* ---------- Setup card ---------- */ +.anomalous-tts-setup { + margin: 0 0 12px; + border: 1px solid rgba(245, 158, 11, 0.35); + border-radius: 12px; + background: rgba(245, 158, 11, 0.05); + color: #cbd5e1; + font-size: 12px; +} + +.anomalous-tts-setup.is-ready { + border-color: rgba(52, 211, 153, 0.25); + background: rgba(52, 211, 153, 0.04); +} + +.anomalous-tts-setup-head { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 12px; +} + +.anomalous-tts-setup-toggle { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 8px; + padding: 2px 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.anomalous-tts-setup-dot { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: #f59e0b; +} + +.anomalous-tts-setup.is-ready .anomalous-tts-setup-dot { + background: #34d399; +} + +.anomalous-tts-setup-title { + flex-shrink: 0; + color: #f1f5f9; + font-weight: 700; +} + +.anomalous-tts-setup-summary { + min-width: 0; + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anomalous-tts-setup-chevron { + margin-left: auto; + color: #64748b; + transition: transform 0.15s ease; +} + +.anomalous-tts-setup.is-open .anomalous-tts-setup-chevron { + transform: rotate(180deg); +} + +.anomalous-voice-modal-submit.anomalous-tts-setup-import { + flex-shrink: 0; + padding: 5px 14px; + font-size: 12px; +} + +.anomalous-tts-setup-body { + display: none; + flex-direction: column; + gap: 14px; + padding: 4px 12px 12px; +} + +.anomalous-tts-setup.is-open .anomalous-tts-setup-body { + display: flex; +} + +.anomalous-tts-setup-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.anomalous-tts-setup-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 26px; +} + +.anomalous-tts-setup-row.is-package { + flex-wrap: wrap; +} + +.anomalous-tts-setup-path { + flex: 1; + min-width: 0; + overflow: hidden; + color: #e2e8f0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anomalous-tts-setup-meta { + flex-shrink: 0; + color: #94a3b8; +} + +.anomalous-tts-setup-command { + flex: 1 1 320px; + min-width: 0; + padding: 4px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + color: #e2e8f0; + font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; +} + +.anomalous-tts-setup-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.anomalous-tts-setup-notice { + padding: 8px 10px; + border-radius: 8px; + background: rgba(248, 113, 113, 0.08); + color: #fca5a5; +} + +.anomalous-tts-kind { + flex-shrink: 0; + padding: 1px 6px; + border-radius: 4px; + background: rgba(129, 140, 248, 0.12); + color: #a5b4fc; + font-size: 10.5px; + white-space: nowrap; +} + +.anomalous-tts-state { + flex-shrink: 0; + font-size: 11px; + white-space: nowrap; +} + +.anomalous-tts-state.is-ok { color: #34d399; } +.anomalous-tts-state.is-busy { color: #38bdf8; } +.anomalous-tts-state.is-missing { color: #f59e0b; } +.anomalous-tts-state.is-error { color: #f87171; cursor: help; } + +.anomalous-tts-add:disabled, +.anomalous-tts-remove:disabled, +.anomalous-voice-modal-submit.anomalous-tts-setup-import:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ---------- Import form ---------- */ +.anomalous-tts-import { + width: 720px; +} + +.anomalous-tts-import-target { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); + gap: 10px; +} + +.anomalous-tts-import-target:empty { + display: none; +} + +.anomalous-tts-import-drop { + cursor: default; +} + +.anomalous-tts-import-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.anomalous-tts-import-row { + display: flex; + flex-direction: column; + gap: 6px; + padding: 7px 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); +} + +.anomalous-tts-import-line, +.anomalous-tts-import-audio { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +/* The play button's own display rule would override [hidden] (local paths cannot be played). */ +.anomalous-tts-import-audio [hidden] { + display: none; +} + +.anomalous-tts-import-audio input { + min-width: 0; + padding: 5px 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); + color: #e2e8f0; + font-size: 12px; +} + +.anomalous-tts-import-audio .anomalous-tts-name-input { + flex: 0 0 120px; +} + +.anomalous-tts-import-audio .anomalous-tts-text-input { + flex: 1; +} + +.anomalous-tts-import-audio .anomalous-tts-name-input:disabled { + opacity: 0.4; +} + +.anomalous-tts-import-state { + flex-shrink: 0; + margin-left: auto; + color: #94a3b8; + font-size: 11px; +} + +.anomalous-tts-import-state.is-error { + max-width: 260px; + overflow: hidden; + color: #f87171; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anomalous-tts-import-language { + max-width: 260px; +} + +.anomalous-tts-import-problems { + display: flex; + flex-direction: column; + gap: 4px; +} + +.anomalous-tts-import-problem { + padding: 5px 8px; + border-radius: 6px; + background: rgba(245, 158, 11, 0.08); + color: #fbbf24; + font-size: 11.5px; +} + +.anomalous-tts-import-problem.is-error { + background: rgba(248, 113, 113, 0.08); + color: #f87171; +} + +@media (max-width: 600px) { + .anomalous-tts-import-target { + grid-template-columns: 1fr; + } + + .anomalous-tts-import-audio { + flex-wrap: wrap; + } +} + +/* ---------- Server path picker ---------- */ +.anomalous-tts-picker { + width: 620px; +} + +.anomalous-tts-picker-bar { + display: flex; + gap: 6px; +} + +.anomalous-tts-picker-path { + flex: 1; + min-width: 0; + padding: 6px 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); + color: #e2e8f0; + font-size: 12px; +} + +.anomalous-tts-picker-list { + display: flex; + flex-direction: column; + height: 320px; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); +} + +.anomalous-tts-picker-item { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 6px 10px; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + background: none; + color: #e2e8f0; + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.anomalous-tts-picker-item:hover, +.anomalous-tts-picker-item:focus-visible { + background: rgba(129, 140, 248, 0.1); +} + +.anomalous-tts-picker-icon { + display: inline-flex; + color: #fbbf24; +} + +.anomalous-tts-picker-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anomalous-tts-picker-meta { + flex-shrink: 0; + color: #64748b; + font-size: 11px; +} + +.anomalous-tts-hint.is-error { + color: #f87171; +} + +/* ---------- Dropping files on the studio ---------- */ +.anomalous-audio-studio-wrapper.is-file-dragover { + position: relative; + outline: 2px dashed rgba(129, 140, 248, 0.6); + outline-offset: -2px; + border-radius: 12px; +} + +.anomalous-audio-studio-wrapper.is-file-dragover::after { + content: attr(data-drop-hint); + position: absolute; + top: 8px; + left: 50%; + z-index: 2; + padding: 6px 14px; + border-radius: 999px; + background: #4f46e5; + color: #fff; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + transform: translateX(-50%); + pointer-events: none; +} + +.anomalous-character-voice-card.is-file-target { + outline: 2px solid #818cf8; + outline-offset: 1px; +}