From 87aa9a1ac23a4ece89bf9083c207d5d2d56c084c Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Fri, 2 Oct 2026 21:06:45 +0800 Subject: [PATCH] Put Voice-over back on the Voices page, with calmer settings and cards Voices has two views switched at the top, Characters and Voice-over, sharing the character list (clicking a character on Voice-over makes it speak); the rail's Voices entry reopens the view used last, and the separate Voice-over rail entry is gone. Generation settings: language as choices and speed as a slider in view, the sampling numbers folded under fine-tuning with plain names, no sideways scroll. A finished take says how long it took and links to the audio gallery. Line cards show editing buttons on hover, the emotion's reference clip sits beside its chips, and a character with only its main voice shows no chips. The audio gallery shows each file's lines once and can open them on Voice-over for another take; the list no longer repeats the gallery. Also fixes the audio list staying empty after a reload. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 28 ++-- CHANGELOG.md | 8 +- README.md | 4 +- docs/guides/audio-studio.md | 15 +- web/modules/browser.js | 23 ++- web/modules/locales.js | 88 ++++++----- web/modules/ui_audio_gallery.js | 15 +- web/modules/ui_audio_sidebar.js | 14 +- web/modules/ui_audio_studio.js | 39 ++--- web/modules/ui_script_director.js | 72 ++++++--- web/modules/ui_script_page.js | 70 ++++++--- web/modules/ui_script_run.js | 135 ++++++++++------- web/modules/ui_shell_nav.js | 24 ++- web/modules/ui_shell_rail.js | 2 +- web/modules/ui_voice_tabs.js | 30 ++++ web/modules/update_guide_data.js | 2 +- web/styles.css | 4 +- web/styles/11-audio-studio.css | 44 +++++- web/styles/13-script-director.css | 243 +++++++++++++++++++++++------- 19 files changed, 591 insertions(+), 269 deletions(-) create mode 100644 web/modules/ui_voice_tabs.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 197ad90..29225aa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -164,7 +164,7 @@ covered by the `styles.css` manifest. the separate Anomalous_TTS node pack (docs/decisions AD-016), detected at runtime through `/object_info/`. Characters are normalised into one voice-group shape (`node_value` = what the node's `character` widget takes), so - cards, sidebar and the Voice-over page share one implementation. While the pack is + cards, sidebar and the Voice-over view share one implementation. While the pack is missing the studio shows install steps instead of cards; nothing else depends on it. Its data comes only from the node's HTTP contract, version 11 (`/anomalous_tts/characters`, `/audio`, `/settings`, `/status`, the storage, @@ -241,26 +241,34 @@ covered by the `styles.css` manifest. `planVoiceDrop` decides every character drop and names the reason for each refusal; unlisted nodes are refused, never matched by widget name. Supporting a node means adding one entry plus a test. -- `ui_audio_studio.js` owns the Voices page: the character cards, preview playback, +- The rail's Voices entry has two views, switched by `ui_voice_tabs.js` at the top of + each and sharing the character list: Characters (shell page `voices`) and Voice-over + (shell page `script`; `ui_shell_nav.js` keeps the rail on Voices and reopens the view + used last). + `ui_audio_studio.js` owns the Characters view: the character cards, preview playback, tag copying, dragging a card header onto a node through the shared `bindMaterialDrag` with `targetHint`/`rejectHint` telling the user what releasing does, and each card's "Voice-over" button (`owner.openScript(group)`). - `ui_script_page.js` owns the Voice-over page (rail page `script`, between Voices and - Audio): it loads the characters and mounts the script director, or shows the install - steps or the way to Voices when there is nothing to voice yet. + `ui_script_page.js` owns the Voice-over view: it loads the characters and mounts the + script director, or shows the install steps or the way to import one when there is + nothing to voice yet. A character picked in the list speaks there; the audio + gallery's "voice it again" opens a file's lines with its character + (`owner.openScript(null, { speech, subfolder })`). `audio_script.js` holds the pure script rules: splitting, bundling (`buildScriptPackage`, with `[take:N]` for retakes), combo value matching, and `buildTtsPrompt` (a GPT-SoVITS script as a ComfyUI API prompt saving to `output/audio//`). - `ui_script_director.js` owns the script director, the Voice-over page's body: one - character, an emotion chip row per line card, and a bundle that is generated on the - page, or, under "Into your own workflow", pushed to the selected/only target node, + `ui_script_director.js` owns the script director, the Voice-over view's body: one + character, an emotion chip row per line card (with the chosen emotion's reference clip; + none when the character has only its main voice), editing buttons shown on hover, and a + bundle that is generated in the view, or, under "Into your own workflow", pushed to the selected/only target node, dragged onto one (writing the character and script widgets together, one Ctrl+Z step) or copied. The page feeds it the voice groups after each fetch. It puts the cards on one side and generating on the other: beside them once the page is wide, below them when it is narrow (docked browser). - `ui_script_run.js` is the director's "Generate" section (a folded settings line - and a short bar): it runs + `ui_script_run.js` is the director's "Generate" section (the bar with the result and + a way to the audio gallery; language and speed in view, the sampling numbers folded + under "fine-tuning" with plain names): it runs the script without the canvas, plays the result, retakes the whole script (new seed) or one line (`[take:N]`, the node caches the rest), and saves a character's language, speed and folded sampling parameters to its `defaults` diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4c92f..d238d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased ### 🧭 New layout (新布局) -- **Icon rail (左侧图标栏)**: every page now has one place on the left: Home, Models, Gallery, Workflows, Materials, then Voices, Voice-over and Audio for the audio side, then scan, Model Check, Node Assistant and the toolbox, with settings at the bottom. It replaces the tabs at the top, the image/audio switch and the tool bar under the folder list. +- **Icon rail (左侧图标栏)**: every page now has one place on the left: Home, Models, Gallery, Workflows, Materials, then Voices and Audio for the audio side, then scan, Model Check, Node Assistant and the toolbox, with settings at the bottom. It replaces the tabs at the top, the image/audio switch and the tool bar under the folder list. - **Folder list opens and closes per page (列表按页面收放)**: click the current page's icon again, or the button at the top left, to hide or show its list; each page remembers your choice. Pages without a list (Home, Gallery) give the space to the content. In a narrow or docked window the list starts closed and opens as a drawer against the rail, over a dimmed page; picking a folder or character, pressing the dimmed page or Esc closes it. - **Home page (主页)**: "What would you like to do?" with a card per task (find a model, fix missing models, give a character a voice, look through outputs, keep workflows, organize materials) and first steps (scan model folders, tour the interface, what's new). The first time you open the browser it starts here; after that it reopens the page you used last. - The header shows the current page's name; workflow and material workspaces keep the rail visible, so you can switch pages without closing them first. @@ -31,7 +31,10 @@ - **The scan page in plain words (扫描页说人话)**: it says what a scan does (the file's fingerprint is looked up on Civitai), and why a model was not found: not published on Civitai (voice, upscale and detection models mostly come from HuggingFace or GitHub), trained or merged by you, or changed after download; it works as usual, and you can add its download link. A model Civitai does not know that is no known image model says it may be a voice or upscale model. Model files in other formats (.pth, .ckpt, .bin…), which scans never read, are listed under **Other formats** with why, so they are no longer a mystery. - **Undo what you dropped (拖错了可以撤回)**: anything Anomalous writes on the canvas (a material dropped on a node, a prompt put in a box, values applied in Current node, a model swapped or a LoRA inserted, a recipe or prompt note sent to the canvas) is now one step of ComfyUI's own **Ctrl+Z**; before, a drop was not, and Ctrl+Z could undo the step before it instead. After a drop, the message at the top right has **Undo** and stays while the pointer is on it. Dropping a prompt on empty canvas can be undone the same way. In **Activity**, canvas changes made since the page was opened have **Undo** while nothing has changed them since; entries that removed nodes or rewired existing ones only record. - **A tidier scan page (扫描页更清爽)**: the scan page now holds only the counts, the scan button, the last scan in one card and a card saying how the next scan goes. **Not found**, **Not scanned yet**, the other formats and the last scan's models each open their own page with **‹ Back to scan**, a line on why those models are there, one button for all of them (scan these / look them up again) and the list; a model opened from it comes back to the same list where you were. **Show the result** after a scan opens the last scan's page. The old fold-out **Advanced** is now **Scan settings**, a page of its own like the lists: its card on the scan page shows how the next scan goes (all models or the picked ones, online or offline, renaming files…, with the ones that change files in yellow), and the page asks one question per card, which models as two choices side by side and each option with a switch, the ones that change files or take long marked so, the online-only ones under their own heading (faded for an offline scan). -- **Voice-over has its own page (配音单独成页)**: the Script Director used to be a drawer opened by one of four equal buttons on Voices, over the character cards. It is now **Voice-over** on the rail, between Voices (preparing characters) and Audio (the results), and each character card has **Voice-over** to go there with that character chosen; Home's "Give a character a voice" opens it too. **Generate** is the big button; pushing to a node, dragging onto one and copying sit below it under **Into your own workflow**, and a push or drop is one Ctrl+Z step. Plainer words: "Split into lines" instead of "Parse lines", "3 lines · using main voice" instead of "Bundled 3 lines". The Voices page keeps import, refresh and search. +- **Characters and Voice-over, one page (角色和配音在同一页)**: the Script Director used to be a drawer opened by one of four equal buttons on Voices, over the character cards. Voices now has two views switched at the top, **Characters** and **Voice-over**, sharing the character list on the left: on Voice-over, clicking a character makes it the one who speaks. Each character card has **Voice-over** to go there with that character chosen; Home's "Give a character a voice" opens it too, and the rail's Voices entry reopens the view you used last. **Generate** is the big button; when it is done it says how long it took (instead of the node's count of sentences, batches and cache) and **Saved to Audio ›** opens the audio gallery. Pushing to a node, dragging onto one and copying sit below under **Into your own workflow**, and a push or drop is one Ctrl+Z step. +- **Generation settings you can read (生成设置不再挤成一团)**: language is a row of choices and speed a slider, always in view; the four sampling numbers fold under **Fine-tuning (usually left alone)**, one per row with a plain name (Candidates, Randomness…) and the node's own name under it. The side no longer scrolls sideways. +- **Calmer line cards (台词卡片更清爽)**: split, merge and delete show when the pointer is on a card; another take of a line stays in view. The ▶ that played the emotion's reference clip (easily taken for the generated line) is now a small speaker beside the emotion chips. A character with only its main voice shows no chips at all, just one line on how to add emotions. Plainer words: "Split into lines" instead of "Parse lines", "3 lines · using main voice" instead of "Bundled 3 lines". +- **Audio gallery (音频库)**: each file's lines show once (the full text was always shown under the summary), 💬 opens them on Voice-over with their character for another take, and the list on the left no longer repeats the gallery as "Audio history". ### 🪶 Lighter on memory (更省内存) - The output gallery shows 512 px thumbnails instead of full images (cached like model cards), so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory with full-size pictures. Dragging an image to the canvas and the large viewer still use the original. When the browser has been closed for 90 seconds, its gallery images are released too, like the model grid. @@ -57,6 +60,7 @@ - **GPT-SoVITS setup and import (GPT-SoVITS 准备与导入)**: an empty studio is a drop area with **Import character**, which also sits in the toolbar. **GPT-SoVITS settings** at the bottom of the sidebar opens a dialog with where characters are kept, pretrained files and missing Python packages; missing pretrained files only get a small dot on that entry, with "don't remind me", once your characters need them, and the import form notes what the chosen language will download. **Batch import (批量导入)**: the import window takes one or many characters. It first asks what you have (one character, many characters or a package, or files for a character you already have), with three steps at the top and a spotlight tour the first time ("?" replays it). Each character card leads with a checklist that lights up the next step (GPT model, SoVITS model, clips, main voice, name) and offers its button right there; the card says how many steps are left. Drop files or whole folders, or pick a folder on this PC (a GPT-SoVITS package): a folder holding one set of GPT + SoVITS weights and its clips is one character named after the folder (two characters whose weights are both called `model` stay apart; `派蒙/v2` becomes "派蒙 v2"); weights in a package's weight folders pair up by name (latest epoch kept); clips and line files follow their character folder, the weights in their folder or the character their folder or file name points at; an annotation file (`.list`) no character claims goes to every character; two clips with the same name (`开心/01.wav`, `难过/01.wav`) both go in, the second as `难过_01.wav`; anything unclear waits in a card of its own, one line per folder that can be assigned at once, and is uploaded only once it is put into a character. Clips outside 3–10 s are left out with their line files. Python environments and base models (`runtime`, `pretrained_models`, `venv`…) are skipped, and a package's program and training folders (`GPT_SoVITS`, `logs`, `output`, `TEMP`, `tools`) only inside a package: your own folders called `output` or `GPT_SoVITS`, and the folder you chose or dropped, are always read; the window says which folders were skipped or too deep (6 levels) instead of files going missing quietly. A big add opens 20 characters (about 800 files) at a time; **Next batch** opens the rest, and it opens by itself once a batch is imported, so a huge folder does not freeze the window. Every clip has **▶** to listen while importing — uploading, unassigned or picked on this PC — and clicking another clip switches to it for quick comparing. Long clip lists show the first 60, and a very large upload first suggests picking the folder on this PC instead; uploads run three at a time. Each character is a card: one line with its name, a short status and what it holds, unfolding into its model tiles and clips; files move between characters with the picker on each row. **Import N characters** imports each ready one on its own, so one failure leaves the others alone. Status is colour-coded throughout: green ready, amber still missing something, red a failed or refused file, blue uploading; **Import** stays at the bottom. Adding to a character skips files it already has and merges annotation files (Anomalous_TTS interface 5). There is one storage place (default `models/gpt_sovits`); changing it can move the existing characters there with progress. 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. Lines are found in same-name `.txt` / `.lab` files, annotation files or, as a marked guess, the file name; a text file dropped on a clip's line box fills it. Needs Anomalous_TTS with interface version 11; file actions only work on the computer running ComfyUI. ### Fixes +- **Empty list on the audio pages (音频页左边列表是空的)**: after reloading ComfyUI, a browser that reopened straight onto Voices or Audio showed an empty list column until you switched to the image side and back. - The Material Library's top bar no longer cuts off **Prompt Studio**, **Prompt Notes** and refresh when the window is narrow (docked to the side, or a scaled display): the buttons move to a second row. - All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative. - The Workflows, Prompt Notes and Material Library workspace no longer covers the header: the Models / Gallery / Workflows tabs stay clickable while it is open, and **Esc** closes it (back to what was open before) unless a dialog is above it or a text field has focus. Closing the Prompt Translator brings back the browser it folded away. diff --git a/README.md b/README.md index 2200c0a..6d2fcb1 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Every page has an icon on the rail at the left of the window. | **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. | | **🪡 Workflows** | Workflow recipes: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. | | **✨ Materials** | Save an output with its workflow and node settings. Drop a material on an empty canvas to open the workflow, drop a prompt to create prompt nodes, or drop it on a node to fill in that node's settings. | -| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script on the Voice-over page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. | +| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script under Voice-over on the same page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. | Tools on the rail: @@ -100,7 +100,7 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c | **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。
可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。
点开图片能看到生成参数;拖到画布上可以还原当时的工作流。 | | **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。
卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。
可以比较、恢复历史版本。 | | **✨ 素材** | 把一张出图连同工作流和节点参数一起收藏。
拖到画布空白处:打开工作流,提示词素材会自动生成提示词节点。
拖到已有节点上:把参数填进去。 | -| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:
导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;在“配音”页写台词并直接生成。
生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 | +| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:
导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。
生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 | 图标栏上的工具: diff --git a/docs/guides/audio-studio.md b/docs/guides/audio-studio.md index 95a589a..4df186a 100644 --- a/docs/guides/audio-studio.md +++ b/docs/guides/audio-studio.md @@ -59,18 +59,19 @@ ## 2. 配音:一段话多种情绪 -1. 点图标栏的 **配音**(在“角色语音”和“音频库”之间),在上方选角色。也可以在角色卡片上点 **配音**,直接带着这个角色过去。 +1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**,或者在角色卡片上点 **配音**,直接带着这个角色过去。谁来念,在左边列表里点角色或在上方选。 2. 把整段台词粘贴进去,点 **分成句子**。每句话会变成一张卡片。 -3. 每张卡片点一下情绪(main / happy / angry……)。也可以直接改字,或者用 ✂ 在光标处拆开、合并、删除。 +3. 每张卡片点一下情绪(主声音 / happy / angry……),旁边的小喇叭能听这个情绪原本的参考声音。角色只有主声音时不显示情绪按钮。也可以直接改字;鼠标移到卡片上会出现 ✂(在光标处拆开)、↧(和下一句合并)、×(删除)。 ### 直接生成,不用画布 -页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和 **生成设置**。窗口窄或者浏览器停靠在侧边时,生成按钮和设置放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。 +页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和 **念法**。窗口窄或者浏览器停靠在侧边时,生成按钮和设置放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。 -1. **生成设置** 默认收起,只显示一行,例如“生成设置:自动 · 语速 1”。第一次用的是这个角色的默认值。点开能改语言、语速和 `top_k`、`top_p`、`temperature`、`repetition_penalty`,鼠标停在输入框上有说明,一般不用动。改过高级参数时,那一行后面会写“调过高级参数”。 -2. 某次效果满意,点 **把这套设置存为这个角色的默认**:语言、语速和高级参数都会记住,下次打开这个角色直接用。种子不存,它只对当时那段台词有意义。 +1. **念法** 一直显示:语言(自动 / 中 / 日 / 英)点一下就选,语速用滑块。第一次用的是这个角色的默认值。 + 下面的 **细调(一般不用动)** 收着:候选数量 `top_k`、候选范围 `top_p`、随机程度 `temperature`、防重复 `repetition_penalty`,鼠标停在输入框上有说明。调过时那一行后面会写“调过”。 +2. 某次效果满意,点 **把这套设置存为这个角色的默认**:语言、语速和细调都会记住,下次打开这个角色直接用。种子不存,它只对当时那段台词有意义。 3. 点 **生成语音**。插件直接交给 ComfyUI 生成,画布上的工作流一个不动;ComfyUI 正在出图时会先排队。 -4. 生成完自动播放,文件存在 `output/audio/角色名/`,音频库里也能找到。 +4. 生成完自动播放,写着“生成好了,用时几秒”。文件存在 `output/audio/角色名/`;点 **已存进音频库 ›** 直接去音频库。 5. 不满意: - 某一句不好,点那张卡片上的 **↻**,只把这一句换一版,其他句子不变(直接用缓存,很快)。 - 整段都不好,点 **整段换一版**,换一个种子全部重新生成。 @@ -101,7 +102,7 @@ ## 3. 找回结果 -- 生成的音频在顶部的 **音频库**: +- 生成的音频在图标栏的 **音频库**: - 每条会显示台词、声音和种子,可以试听、按台词搜索、删除。 - 只在预览里出现、还没保存的临时结果,列在“未保存的预览”里,点 **保存** 就能存到 `output/audio/`。 diff --git a/web/modules/browser.js b/web/modules/browser.js index 34ecde9..9519316 100644 --- a/web/modules/browser.js +++ b/web/modules/browser.js @@ -80,18 +80,22 @@ export class AnomalousBrowser { /** Single entry for audio-domain navigation: the rail, the audio list's entries and the domain switch. */ switchAudioTab(tabName, filter = null) { - this.hideAllPanels(); + if (!(tabName === 'presets' && filter?.type === 'group' && this.currentShellPage() === 'script')) this.hideAllPanels(); if (tabName === 'gallery') { setActiveAudioFilter({ type: 'gallery', value: null }); this.markShellPage?.('audio-gallery'); this.audioGalleryPanel.style.display = 'block'; - renderAudioGallery(this.audioGalleryPanel); + renderAudioGallery(this.audioGalleryPanel, { owner: this }); } else if (tabName === 'script') { this.markShellPage?.('script'); this.scriptPanel.style.display = 'flex'; - const character = this.pendingScriptCharacter; - this.pendingScriptCharacter = null; - renderScriptPage(this.scriptPanel, this, { character }); + const pending = this.pendingScript || {}; + this.pendingScript = null; + renderScriptPage(this.scriptPanel, this, pending); + } else if (filter?.type === 'group' && this.currentShellPage() === 'script') { + // A character picked in the list while on Voice-over: it speaks there. + this.openScript(filter.value); + return; } else { if (filter) setActiveAudioFilter(filter); else if (getActiveAudioFilter().type === 'gallery') setActiveAudioFilter(null); @@ -113,9 +117,12 @@ export class AnomalousBrowser { this.goTo('voices'); // keeps the filter just set } - /** The Voice-over page, with `group` (a voice group key) chosen when given: a character card's button. */ - openScript(group = null) { - this.pendingScriptCharacter = group; + /** + * The Voices page's Voice-over view. `group`: a voice group key to choose (a card, the list); + * `script`: `{ speech, subfolder }` of a generated file to voice again (the audio gallery). + */ + openScript(group = null, script = null) { + this.pendingScript = { character: group, script }; if (this.currentShellPage() === 'script') this.switchAudioTab('script'); else this.goTo('script'); } diff --git a/web/modules/locales.js b/web/modules/locales.js index 5551603..f3cd59c 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -84,7 +84,7 @@ export const i18n = { updateGuideActivityTitle: "操作记录:插件改了什么一目了然", updateGuideActivityBody: "图标栏的“记录”按天列出插件做过的改动:画布上哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流,以及改过的模型、封面、配方和素材文件。点“在画布上找到”可以跳到那个节点。画布上的改动,在没被再改过之前可以点“撤销”;其余只记录。", updateGuideVoicesTitle: "给角色配音", - updateGuideVoicesBody: "装上 Anomalous_TTS 节点包后,“角色语音”页导入 GPT-SoVITS 角色、设置情绪和读音;“配音”页粘贴台词、每句选情绪,直接生成;生成的音频在“音频库”。音频页右上角的 ! 有分步说明。", + updateGuideVoicesBody: "装上 Anomalous_TTS 节点包后,“角色语音”页有两个视图:“角色”导入 GPT-SoVITS 角色、设置情绪和读音;“配音”粘贴台词、每句选情绪,直接生成。生成的音频在“音频库”。音频页右上角的 ! 有分步说明。", updateGuideMemoryTitle: "更省内存", updateGuideMemoryBody: "图库改用小缩略图,翻几百张图也不会占满浏览器的内存和显存;拖到画布和看大图仍用原图。关闭插件一分半后,图片会释放。", promptFullTextEditHint: '修改全文会将当前方向已启用的词块合并为一个词块。', @@ -460,7 +460,7 @@ export const i18n = {

5. 🤖 当前节点
在画布上选中节点后,上面可以看图换模型,或在兼容的 MODEL + CLIP 链前后插入 LoRA;下面的“参数”列出这类节点存过的所有参数(素材库和配方里的合在一起),每条都写明会改哪几项,点“套用”只改这几项(种子和模型不跟着换);“存下当前参数”把这个节点现在的值存进素材库。

6. 🪡 工作流配方
把当前画布的完整工作流或其中一段,连同模型、封面、标签、备注和参数存成配方。卡片会显示本机模型是否齐全,拖到画布上即可载入;详情页可以匹配本地模型、查看与复制参数、比较历史出图,以及比较或恢复版本。

7. ✨ 素材
在图库或配方里打开一张图的参数详情,可以把原图、完整工作流和节点参数存成素材。拖到画布空白处打开工作流(提示词素材会生成提示词节点),拖到已有节点上则把参数填进去。

-

8. 🎙️ 角色语音
需要另外安装 Anomalous_TTS 节点包。在“角色语音”导入 GPT-SoVITS 角色、设置情绪和读音,在“配音”写台词、直接生成语音;音频页右上角的 ! 有分步说明和遮罩导览。

+

8. 🎙️ 角色语音
需要另外安装 Anomalous_TTS 节点包。在“角色语音”页的“角色”里导入 GPT-SoVITS 角色、设置情绪和读音,切到“配音”写台词、直接生成语音;音频页右上角的 ! 有分步说明和遮罩导览。

9. 🕘 操作记录
按天列出插件改过的东西:画布上节点设置的变化、加删的节点、打开的工作流,以及模型、封面、配方、素材等文件。只记录、不撤销;你自己在画布上的修改不会记进来。

10. 🧰 其他工具在哪
工作流分享码(AMB0/AMB1 导入导出)在“工作流”页右上角 ⇅;提示词工坊和提示词笔记在“素材”页右上角;模型来源在“模型”页类型标签的最右边,工作流里模型的来源在模型检查里;翻译在“当前节点”的提示词框上。

11. ⚙️ 设置
语言、字号、缩略图和视频封面。文件夹管理 可以隐藏不需要的模型文件夹、调整顺序,隐藏的文件夹不再读取和扫描。💬 提交反馈 / 报告问题 直达 GitHub Issues。

@@ -928,7 +928,7 @@ export const i18n = { shellHome: "主页", shellMaterials: "素材", shellVoices: "角色语音", - shellScript: "配音", + voiceTabCharacters: "角色", shellAudioGallery: "音频库", shellTitleModels: "模型库", homeTitle: "今天要做什么?", @@ -1458,9 +1458,8 @@ export const i18n = { recipeCardSpecsResolution: '尺寸', domainVisualBadge: "视觉", domainAudioBadge: "音频", - audioStudioTitle: "你的角色", - audioStudioSubtitle: "导入角色、设置情绪和读音;把角色拖到画布上的节点,或点“配音”写台词直接生成。", - audioVoicePresets: "角色音色预设", + audioVoiceClipCount: "{count} 条声音", + audioSearchCharacters: "搜索角色或情绪", audioPlay: "播放试听", audioPause: "暂停", audioCopyTagLabel: "复制 {tag}", @@ -1475,7 +1474,7 @@ export const i18n = { audioGuideTagsTitle: "情绪靠台词里的 {标签}", audioGuideTagsBody: "台词里的标签切换情绪:写 {开心} 或 {happy} 就换成这种情绪的参考音频,写 {main} 回到主声音。设了情绪的角色,每行右边有“复制 {标签}”按钮。还支持 [角色名] 换人说、[pause:1s] 插入停顿,直接写在台词里即可。", audioGuideScriptTitle: "配音:不用手写标签", - audioGuideScriptBody: "点图标栏的“配音”(或角色卡片上的“配音”),选角色、粘贴整段台词,点“分成句子”,每句会变成一张卡片,点一下就能选情绪,也可以拆分、合并、删除。然后点“生成语音”;想放进自己的工作流,下面“放进自己的工作流”里可以推送到节点、拖到节点或复制,角色和台词会一起填好。", + audioGuideScriptBody: "在“角色语音”页顶部切到“配音”(或点角色卡片上的“配音”),左边列表或上方选谁来念,粘贴整段台词,点“分成句子”,每句会变成一张卡片,点一下就能选情绪,也可以拆分、合并、删除。然后点“生成语音”;想放进自己的工作流,下面“放进自己的工作流”里可以推送到节点、拖到节点或复制,角色和台词会一起填好。", audioGuideGenerateTitle: "生成与音频库", audioTourImportTitle: "导入角色", audioTourImportBody: "点这里导入 GPT、SoVITS 模型和参考音频;也可以直接把文件或整个文件夹拖到这一页。", @@ -1819,10 +1818,8 @@ export const i18n = { audioSidebarHideClips: "收起声音", audioAllVoices: "全部音色", audioSectionCharacters: "角色预设", - audioSectionHistory: "历史归档", - audioGalleryTab: "音频历史库", - audioGalleryTitle: "已生成音频归档", - audioGallerySubtitle: "output 目录里的配音,附带生成时的台词和音色", + audioGalleryTitle: "生成的语音", + audioGallerySubtitle: "每条都带着当时的台词和角色;点 💬 可以拿回“配音”再配一版", audioGalleryEmpty: "音频库还是空的:在“配音”页点“生成语音”,或在节点后面接「保存音频」节点,生成的音频就会出现在这里", audioDeleteConfirm: "删除这个音频文件吗?会移到系统回收站。", audioDeleteSuccess: "已删除音频文件", @@ -1840,14 +1837,19 @@ export const i18n = { scriptRunLang_ja: '日语', scriptRunLang_en: '英语', scriptRunSpeed: '语速', + scriptRunHow: "念法", + scriptRunFineTune: "细调(一般不用动)", + scriptRunFineTuned: " · 调过", + scriptRunParamName_top_k: "候选数量", + scriptRunParamName_top_p: "候选范围", + scriptRunParamName_temperature: "随机程度", + scriptRunParamName_repetition_penalty: "防重复", scriptRunSaveDefaults: '把这套设置存为这个角色的默认', scriptRunDefaultsSaved: '已保存', scriptRunDefaultsFailed: '保存默认设置失败:{error}', scriptRunSettingsBroken: '这个角色的设置文件有问题,先修好再保存:{error}', scriptRunSaveDefaultsHint: '语言、语速和高级参数都会记住,下次打开这个角色直接用。种子不存:它只对这段台词有意义', - scriptRunSettingsSummary: "生成设置:{language} · 语速 {speed}", - scriptRunSettingsTuned: " · 调过高级参数", - scriptRunAdvancedNote: '一般不用动。这次效果满意,可以点“存为这个角色的默认”留下来。句子和参考音频语言不同时(比如日语角色说中文),节点会自动把 top_k 限制在 10、temperature 在 0.8 以内。', + scriptRunAdvancedNote: "效果满意,可以存为这个角色的默认。跨语言时(比如日语角色说中文),节点会自动收紧候选数量和随机程度。", scriptRunParamHint_speed: '语速,1 为正常', scriptRunParamHint_top_k: '每一步只在最可能的 k 个候选里挑。小:更稳、更平;大:更有变化,也更容易出错', scriptRunParamHint_top_p: '只在累计概率达到 p 的候选里挑。1 表示不限制', @@ -1860,18 +1862,20 @@ export const i18n = { scriptRunQueued: '排队中…', scriptRunRunning: '生成中…', scriptRunProgress: '生成中 {value}/{max}', - scriptRunDone: '完成:{summary}', + scriptRunDone: "生成好了,用时 {seconds} 秒", + scriptRunSavedLink: "已存进音频库 ›", scriptRunCancelled: '已取消', scriptRunFailed: '生成失败:{error}', scriptRunNoAudio: '没有拿到生成的音频', - scriptRunSavedTo: '已保存:{path}', scriptRunRetakeLine: '只把这句换一版(其他句子不变)', scriptDirectorTitle: "配音", + scriptDirectorPreviewEmotion: "听「{emotion}」原本的参考声音", + scriptDirectorOnlyMain: "这个角色只有主声音,每句都用它念。在“角色”里给它加情绪后,这里每句都能单独选。", scriptDirectorUseCharacter: "配音", scriptDirectorUseCharacterHint: "用{character}写台词、生成语音", scriptDirectorWorkflowTitle: "放进自己的工作流", - scriptPageNoCharacters: "还没有角色。先在“角色语音”里导入一个,再回来写台词。", - scriptPageGoVoices: "去角色语音", + scriptPageNoCharacters: "还没有角色。先在“角色”里导入一个,再回来写台词。", + scriptPageGoVoices: "去导入角色", scriptDirectorInputPlaceholder: "把台词贴在这里。每句一行,或者整段贴进来按标点自动分句。\n\n分好以后,每句都能单独选情绪(比如主声音、开心)。", scriptDirectorParse: "分成句子", scriptDirectorClear: "清空", @@ -1886,6 +1890,7 @@ export const i18n = { audioPreviewSaved: "已保存", audioSaveFailed: "保存失败:{error}", audioCopySpeech: "复制台词", + audioVoiceAgain: "在“配音”里打开,再配一版", audioSpeechCopied: "已复制台词", audioNoMatches: "没有找到匹配的音频", audioVoiceLabel: "音色 {name}", @@ -1897,13 +1902,12 @@ export const i18n = { audioDeleteFailed: "删除失败:{error}", scriptDirectorPickNode: "画布上有多个 {node} 节点,请先选中要写入的那一个", scriptDirectorOverwriteConfirm: "目标节点的台词栏已经有内容,要覆盖吗?", - scriptDirectorCharacter: "角色", + scriptDirectorCharacter: "谁来念", scriptDirectorPickCharacter: "请先选择角色", scriptDirectorEmotionsAvailable: "可用情绪:{emotions}", scriptDirectorMainEmotion: "主声音", scriptDirectorEdit: "改原文", scriptDirectorLinesCount: "{count} 句", - scriptDirectorPreview: "试听这句所选的情绪", scriptDirectorDeleteLine: "删除这句", scriptDirectorPackageSummary: "共 {count} 句 · 用到:{emotions}", scriptDirectorCopy: "复制剧本", @@ -2008,7 +2012,7 @@ export const i18n = { updateGuideActivityTitle: "Activity: see what the plugin changed", updateGuideActivityBody: "Activity on the rail lists, by day, what the plugin did: which node settings changed on the canvas and from what to what, nodes added or removed, workflows opened, and the model, cover, recipe and material files it changed. \"Find on canvas\" jumps to the node. Canvas changes can be undone here until something changes them again; the rest is only recorded.", updateGuideVoicesTitle: "Give your characters a voice", - updateGuideVoicesBody: "With the Anomalous_TTS node pack installed, Voices imports GPT-SoVITS characters and sets their emotions and pronunciations; Voice-over takes a pasted script, an emotion per line, and generates it right there; generated audio is in Audio. The ! at the top right of the audio pages has a step-by-step guide.", + updateGuideVoicesBody: "With the Anomalous_TTS node pack installed, Voices has two views: Characters imports GPT-SoVITS characters and sets their emotions and pronunciations; Voice-over takes a pasted script, an emotion per line, and generates it right there. Generated audio is in Audio. The ! at the top right of the audio pages has a step-by-step guide.", updateGuideMemoryTitle: "Lighter on memory", updateGuideMemoryBody: "The gallery shows small thumbnails, so scrolling through hundreds of outputs no longer fills the browser's memory and graphics memory; dragging to the canvas and the large viewer still use the original. A minute and a half after you close the browser, its pictures are released.", promptFullTextEditHint: 'Editing the full text merges the enabled blocks in this direction into one block.', @@ -2384,7 +2388,7 @@ export const i18n = {

5. 🤖 Current node
Select a node on the canvas. On top, swap its model from the covers or insert a LoRA before or after a compatible MODEL + CLIP chain; below, Parameters lists every set of values saved for this kind of node (material library and recipes together), each saying what it would change; Apply changes only those (seeds and models stay). Save these values keeps the node's current values in the material library.

6. 🪡 Workflow recipes
Save the whole canvas, or a part of it, with its models, cover, tags, notes and parameters. A card shows whether the models are on this computer; drag it onto the canvas to load it. The detail view matches local models, shows and copies parameters, compares past outputs and compares or restores versions.

7. ✨ Materials
Open an image's parameter details in the Gallery or a recipe to save the image, its full workflow and its node settings as a material. Drop it on an empty canvas to open the workflow (prompt materials create prompt nodes), or on a node to fill in its settings.

-

8. 🎙️ Character voices
Needs the Anomalous_TTS node pack. Import GPT-SoVITS characters and set emotions and pronunciations on Voices, and write lines and generate speech on Voice-over; the ! at the top right of the audio page has a step-by-step guide and a spotlight tour.

+

8. 🎙️ Character voices
Needs the Anomalous_TTS node pack. On Voices, import GPT-SoVITS characters and set emotions and pronunciations under Characters, and write lines and generate speech under Voice-over; the ! at the top right of the audio page has a step-by-step guide and a spotlight tour.

9. 🕘 Activity
What the plugin changed, by day: node settings on the canvas, nodes added or removed, workflows opened, and files such as models, covers, recipes and materials. It records only and undoes nothing; your own edits on the canvas are not listed.

10. 🧰 Where the other tools are
Workflow share codes (AMB0/AMB1 import / export): ⇅ at the top right of Workflows. Prompt Studio and Prompt Notes: top right of Materials. Model Sources: at the end of the type chips on Models; the sources of the open workflow's models: in Model Check. Translation: on the prompt boxes in Current node.

11. ⚙️ Settings
Language, font size, thumbnails and video covers. Folder manager hides model folders you do not need and orders them; hidden folders are not read or scanned. 💬 Feedback / report an issue opens GitHub Issues.

@@ -2852,7 +2856,7 @@ export const i18n = { shellHome: "Home", shellMaterials: "Materials", shellVoices: "Voices", - shellScript: "Voice-over", + voiceTabCharacters: "Characters", shellAudioGallery: "Audio", shellTitleModels: "Model library", homeTitle: "What would you like to do?", @@ -3382,9 +3386,8 @@ export const i18n = { recipeCardSpecsResolution: 'Size', domainVisualBadge: "Visual", domainAudioBadge: "Audio", - audioStudioTitle: "Your characters", - audioStudioSubtitle: "Import characters, set emotions and pronunciations; drag a character onto a node, or press \"Voice-over\" to write lines and generate.", - audioVoicePresets: "Voice Presets", + audioVoiceClipCount: "{count} clips", + audioSearchCharacters: "Search characters or emotions", audioPlay: "Play", audioPause: "Pause", audioCopyTagLabel: "Copy {tag}", @@ -3399,7 +3402,7 @@ export const i18n = { audioGuideTagsTitle: "Emotions come from {tags} in the text", audioGuideTagsBody: "Tags in the text switch emotion: {happy} (or {开心}) uses that emotion's reference, {main} goes back to the main voice. Characters with emotions have a “Copy {tag}” button on each row. [Character] changes speaker and [pause:1s] adds a pause, written straight into the text.", audioGuideScriptTitle: "Voice-over: no hand-written tags", - audioGuideScriptBody: "Open Voice-over on the rail (or a character card's \"Voice-over\"), pick a character, paste the script and press \"Split into lines\". Each sentence becomes a card where you click an emotion; you can split, merge or delete cards. Then press \"Generate\"; to use your own workflow instead, \"Into your own workflow\" below pushes to a node, drags onto one or copies, filling in both the character and the text.", + audioGuideScriptBody: "On Voices, switch to \"Voice-over\" at the top (or press a character card's \"Voice-over\"), pick who speaks in the list or above, paste the script and press \"Split into lines\". Each sentence becomes a card where you click an emotion; you can split, merge or delete cards. Then press \"Generate\"; to use your own workflow instead, \"Into your own workflow\" below pushes to a node, drags onto one or copies, filling in both the character and the text.", audioGuideGenerateTitle: "Generate and find it in the audio gallery", audioTourImportTitle: "Import characters", audioTourImportBody: "Import the GPT and SoVITS models and reference clips here, or drop files or a whole folder onto this page.", @@ -3743,10 +3746,8 @@ export const i18n = { audioSidebarHideClips: "Hide clips", audioAllVoices: "All Voices", audioSectionCharacters: "Characters", - audioSectionHistory: "History", - audioGalleryTab: "Audio Gallery", - audioGalleryTitle: "Generated Audio Vault", - audioGallerySubtitle: "Voice audio in the output folder, with the speech and voice used to make it", + audioGalleryTitle: "Generated speech", + audioGallerySubtitle: "Each keeps its lines and character; 💬 takes it back to Voice-over for another take", audioGalleryEmpty: "The gallery is empty: press \"Generate\" on the Voice-over page, or connect a Save Audio node after the node, and generated audio will appear here", audioDeleteConfirm: "Delete this audio file? It goes to the Recycle Bin.", audioDeleteSuccess: "Audio file deleted", @@ -3764,14 +3765,19 @@ export const i18n = { scriptRunLang_ja: 'Japanese', scriptRunLang_en: 'English', scriptRunSpeed: 'Speed', + scriptRunHow: "How it is read", + scriptRunFineTune: "Fine-tuning (usually left alone)", + scriptRunFineTuned: " · tuned", + scriptRunParamName_top_k: "Candidates", + scriptRunParamName_top_p: "Candidate share", + scriptRunParamName_temperature: "Randomness", + scriptRunParamName_repetition_penalty: "Repetition guard", scriptRunSaveDefaults: 'Save these settings as this character\'s default', scriptRunDefaultsSaved: 'Saved', scriptRunDefaultsFailed: 'Could not save the defaults: {error}', scriptRunSettingsBroken: 'This character\'s settings file has a problem; fix it before saving: {error}', scriptRunSaveDefaultsHint: 'Language, speed and the advanced parameters are kept for this character. The seed is not: it only means something for this script', - scriptRunSettingsSummary: "Generation settings: {language} · speed {speed}", - scriptRunSettingsTuned: " · sampling tuned", - scriptRunAdvancedNote: 'Usually left alone. When a take sounds right, save it as this character\'s default. For lines in another language than the reference (a Japanese voice speaking Chinese) the node caps top_k at 10 and temperature at 0.8 by itself.', + scriptRunAdvancedNote: "When a take sounds right, save it as this character's default. Across languages (a Japanese voice speaking Chinese) the node narrows the candidates and the randomness by itself.", scriptRunParamHint_speed: 'Speaking speed, 1 = normal', scriptRunParamHint_top_k: 'Pick among the k likeliest candidates at each step. Lower: steadier and flatter; higher: livelier and more error-prone', scriptRunParamHint_top_p: 'Pick among candidates up to cumulative probability p. 1 = no limit', @@ -3784,18 +3790,20 @@ export const i18n = { scriptRunQueued: 'Queued…', scriptRunRunning: 'Generating…', scriptRunProgress: 'Generating {value}/{max}', - scriptRunDone: 'Done: {summary}', + scriptRunDone: "Done in {seconds} s", + scriptRunSavedLink: "Saved to Audio ›", scriptRunCancelled: 'Cancelled', scriptRunFailed: 'Generation failed: {error}', scriptRunNoAudio: 'No audio came back', - scriptRunSavedTo: 'Saved: {path}', scriptRunRetakeLine: 'New take of this line only (the others stay)', scriptDirectorTitle: "Voice-over", + scriptDirectorPreviewEmotion: "Hear the reference clip of “{emotion}”", + scriptDirectorOnlyMain: "This character has only its main voice, so every line uses it. Add emotions under Characters and each line can pick one here.", scriptDirectorUseCharacter: "Voice-over", scriptDirectorUseCharacterHint: "Write lines for {character} and generate them", scriptDirectorWorkflowTitle: "Into your own workflow", - scriptPageNoCharacters: "No characters yet. Import one on Voices, then come back to write lines.", - scriptPageGoVoices: "Go to Voices", + scriptPageNoCharacters: "No characters yet. Import one under Characters, then come back to write lines.", + scriptPageGoVoices: "Import a character", scriptDirectorInputPlaceholder: "Paste the lines here, one per line, or a whole passage split by punctuation.\n\nOnce split, each line gets its own emotion (main voice, happy…).", scriptDirectorParse: "Split into lines", scriptDirectorClear: "Clear", @@ -3810,6 +3818,7 @@ export const i18n = { audioPreviewSaved: "Saved", audioSaveFailed: "Save failed: {error}", audioCopySpeech: "Copy speech", + audioVoiceAgain: "Open in Voice-over for another take", audioSpeechCopied: "Speech copied", audioNoMatches: "No matching audio", audioVoiceLabel: "voice {name}", @@ -3821,13 +3830,12 @@ export const i18n = { audioDeleteFailed: "Delete failed: {error}", scriptDirectorPickNode: "Several {node} nodes are on the canvas. Select the one to write to first.", scriptDirectorOverwriteConfirm: "The target node's text field already has text. Overwrite it?", - scriptDirectorCharacter: "Character", + scriptDirectorCharacter: "Who speaks", scriptDirectorPickCharacter: "Choose a character first", scriptDirectorEmotionsAvailable: "Available emotions: {emotions}", scriptDirectorMainEmotion: "main", scriptDirectorEdit: "Edit the text", scriptDirectorLinesCount: "{count} lines", - scriptDirectorPreview: "Preview the chosen emotion", scriptDirectorDeleteLine: "Delete line", scriptDirectorPackageSummary: "{count} lines · using {emotions}", scriptDirectorCopy: "Copy script", diff --git a/web/modules/ui_audio_gallery.js b/web/modules/ui_audio_gallery.js index 0f904fc..23e7706 100644 --- a/web/modules/ui_audio_gallery.js +++ b/web/modules/ui_audio_gallery.js @@ -23,6 +23,7 @@ const SVG = { PAUSE: ``, DOWNLOAD: ``, TRASH: ``, + AGAIN: ``, COPY: ``, SAVE: ``, AUDIO_WAVE: ``, @@ -211,7 +212,8 @@ function renderSpeech(generation) { return box; } -function createTrackRow(item, { mode, onDeleted, onSaved }) { +/** `onVoiceAgain(item)`: open the file's lines on Voice-over (saved files only). */ +function createTrackRow(item, { mode, onDeleted, onSaved, onVoiceAgain }) { const generation = item.generation || null; const row = el('div', 'anomalous-audio-track-item'); row.draggable = true; @@ -250,6 +252,7 @@ function createTrackRow(item, { mode, onDeleted, onSaved }) { const timeLabel = el('span', 'anomalous-track-time', '--:--'); const actions = el('div', 'anomalous-track-actions'); + if (generation?.speech && onVoiceAgain) actions.appendChild(iconButton('', SVG.AGAIN, t('audioVoiceAgain'), () => onVoiceAgain(item))); if (generation) actions.appendChild(iconButton('', SVG.COPY, t('audioCopySpeech'), btn => copySpeech(generation.speech, btn))); if (mode === 'preview') { const saveBtn = el('button', 'anomalous-audio-tool-btn anomalous-track-save', t('audioSavePreview')); @@ -344,7 +347,8 @@ async function renderPreviewSection(section, isCurrent, onSaved) { } /** Only the latest render for a container may write into it. */ -export async function renderAudioGallery(container) { +/** `owner`: the browser, for "voice it again" (Voice-over). */ +export async function renderAudioGallery(container, { owner = null } = {}) { const token = {}; renderTokens.set(container, token); const isCurrent = () => renderTokens.get(container) === token; @@ -355,7 +359,7 @@ export async function renderAudioGallery(container) { const listSection = el('div', 'anomalous-track-list-section'); let loadList = () => {}; const { bar, countBadge } = renderGalleryToolbar({ - onRefresh: () => renderAudioGallery(container), + onRefresh: () => renderAudioGallery(container, { owner }), onSearch: query => loadList(query), }); wrapper.append(bar, previewSection, listSection); @@ -402,7 +406,10 @@ export async function renderAudioGallery(container) { page = data.page; total = data.total; const audios = data.audios || []; - audios.forEach(item => trackList.appendChild(createTrackRow(item, { mode: 'output', onDeleted }))); + const onVoiceAgain = owner?.openScript + ? item => owner.openScript(null, { speech: item.generation.speech, subfolder: item.subfolder }) + : null; + audios.forEach(item => trackList.appendChild(createTrackRow(item, { mode: 'output', onDeleted, onVoiceAgain }))); shown += audios.length; updateCount(); diff --git a/web/modules/ui_audio_sidebar.js b/web/modules/ui_audio_sidebar.js index 2788c96..39427b7 100644 --- a/web/modules/ui_audio_sidebar.js +++ b/web/modules/ui_audio_sidebar.js @@ -7,8 +7,8 @@ import { openTtsSetup, setupAttention } from './ui_tts_setup.js'; /** * Audio sidebar: All Voices, the GPT-SoVITS characters (grouped by language; each - * group folds, each character unfolds into its clips), the generated-audio history, - * and a settings entry at the bottom. Owns the active audio filter. Characters drag + * group folds, each character unfolds into its clips) and a settings entry at the + * bottom. On Voice-over, picking a character makes it the one who speaks. Owns the active audio filter. Characters drag * onto canvas nodes like the studio cards; files dropped on a character open the * import form for it. */ @@ -33,7 +33,6 @@ const SIDEBAR_SVG = { MIC: ``, ALL_VOICES: ``, USER: ``, - DISC: ``, REFRESH: ``, CHEVRON: ``, PLAY: ``, @@ -173,7 +172,8 @@ async function playClip(owner, group, slice) { if (!btn) { activeFilter = { type: 'group', value: group.group, character: group.character }; syncAudioSidebarSelection(owner); - owner.switchAudioTab?.('presets', activeFilter); + // The filter is set already; without it as an argument, Voice-over does not take it as "who speaks". + owner.switchAudioTab?.('presets'); const until = performance.now() + 4000; while (!(btn = visible()) && performance.now() < until) await new Promise(requestAnimationFrame); } @@ -325,12 +325,6 @@ export async function renderAudioSidebar(owner) { renderCharacters(owner, listContainer, groups, { canImport, term: '' }); } - listContainer.appendChild(createSectionLabel(t('audioSectionHistory'))); - listContainer.appendChild(createNavItem(owner, { - label: t('audioGalleryTab'), iconSvg: SIDEBAR_SVG.DISC, count: null, - filter: { type: 'gallery', value: null }, tab: 'gallery', - })); - if (status) { const languages = groups.map(group => group.language); owner.sidebar.append(renderSettingsEntry(owner, status, languages)); diff --git a/web/modules/ui_audio_studio.js b/web/modules/ui_audio_studio.js index 4f6ae32..9d2cea4 100644 --- a/web/modules/ui_audio_studio.js +++ b/web/modules/ui_audio_studio.js @@ -8,12 +8,13 @@ import { openPronunciationEditor } from './ui_tts_pronunciation.js'; import { bindTtsFileDrop } from './ui_tts_file_drop.js'; import { openTtsImport } from './ui_tts_import.js'; import { stopScriptDirectorPreview } from './ui_script_director.js'; +import { renderVoiceTabs } from './ui_voice_tabs.js'; /** - * The Voices page: GPT-SoVITS character cards (Anomalous_TTS, detected at runtime), - * preview playback, tag copying, dropping a character onto a TTS node, the editors, - * and each card's way to the Voice-over page (ui_script_page.js), where scripts are - * written and generated. + * The Voices page's Characters view: GPT-SoVITS character cards (Anomalous_TTS, detected + * at runtime), preview playback, tag copying, dropping a character onto a TTS node, the + * editors, and each card's way to the Voice-over view (ui_script_page.js), where scripts + * are written and generated. */ const renderTokens = new WeakMap(); @@ -229,7 +230,7 @@ function renderCharacterCard(group, owner, { onChanged, canImport = false } = {} const countBadge = document.createElement('span'); countBadge.className = 'anomalous-character-voice-count'; - countBadge.textContent = `${group.total_slices} ${t('audioVoicePresets')}`; + countBadge.textContent = t('audioVoiceClipCount', { count: group.total_slices }); // Only a draggable header shows a grip; without a main voice there is nothing to drag. titleGroup.append(...(group.has_main ? [gripIcon()] : []), avatar, nameBox); @@ -294,31 +295,10 @@ function createRefreshButton(onRefresh) { return btn; } -function renderStudioToolbar({ onSearch, onRefresh }) { +function renderStudioToolbar({ onSearch, onRefresh, owner }) { const toolbar = document.createElement('div'); toolbar.className = 'anomalous-audio-toolbar'; - const titleGroup = document.createElement('div'); - titleGroup.className = 'anomalous-audio-title-group'; - - const iconBox = document.createElement('div'); - iconBox.className = 'anomalous-audio-icon-box'; - iconBox.innerHTML = SVG.MIC; - - const textGroup = document.createElement('div'); - textGroup.className = 'anomalous-audio-title-stack'; - - const title = document.createElement('span'); - title.className = 'anomalous-audio-title-text'; - title.textContent = t('audioStudioTitle'); - - const desc = document.createElement('span'); - desc.className = 'anomalous-audio-sub-text'; - desc.textContent = t('audioStudioSubtitle'); - - textGroup.append(title, desc); - titleGroup.append(iconBox, textGroup); - const rightActions = document.createElement('div'); rightActions.className = 'anomalous-audio-right-actions'; @@ -330,13 +310,13 @@ function renderStudioToolbar({ onSearch, onRefresh }) { const searchInput = document.createElement('input'); searchInput.type = 'search'; - searchInput.placeholder = t('audioVoicePresets'); + searchInput.placeholder = t('audioSearchCharacters'); searchInput.oninput = (e) => onSearch(e.target.value.trim().toLowerCase()); searchWrap.append(searchIcon, searchInput); rightActions.append(createRefreshButton(onRefresh), searchWrap); - toolbar.append(titleGroup, rightActions); + toolbar.append(renderVoiceTabs(owner, 'voices'), rightActions); return toolbar; } @@ -428,6 +408,7 @@ export async function renderAudioStudio(container, { filter = null, owner = null }); }, onRefresh: () => { invalidateEngineCache({ rescan: true }); rerender(); if (owner) renderAudioSidebar(owner); }, + owner, }); studioWrapper.append(toolbar, renderStatus('anomalous-audio-status', t('audioLoading'))); container.replaceChildren(studioWrapper); diff --git a/web/modules/ui_script_director.js b/web/modules/ui_script_director.js index 43b7941..6c3e2ca 100644 --- a/web/modules/ui_script_director.js +++ b/web/modules/ui_script_director.js @@ -9,8 +9,8 @@ import { createRunSection } from './ui_script_run.js'; import { recordCanvasStep } from './canvas_history.js'; /** - * The script director, the body of the Voice-over page (ui_script_page.js): pick one - * character, paste a script, choose an emotion per line on the line cards, then + * The script director, the body of the Voices page's Voice-over view (ui_script_page.js): + * pick one character, paste a script, choose an emotion per line on the line cards, then * generate it right here (ui_script_run.js), or put the tagged script into the * workflow: push it to a GPT-SoVITS node, drag it onto one (supported nodes: * audio_node_targets.js) or copy it. The panel and its lines live for the page @@ -21,7 +21,7 @@ import { recordCanvasStep } from './canvas_history.js'; const WIDE_FROM = 760; const ICONS = { - PLAY: ``, + SPEAKER: ``, STOP: ``, GRIP: ``, }; @@ -46,11 +46,28 @@ const dragOwner = { get modal() { return hooks.owner?.modal; } }; // ---------- public API ---------- -/** `{ owner, onPreviewStart() }` from the page. */ +/** `{ owner, onPreviewStart(), onCharacterChange(), openGallery() }` from the page. */ export function setScriptDirectorHooks(next) { hooks = next || {}; } +/** The voice group key of who speaks. */ +export function scriptCharacter() { + return state.groupKey; +} + +/** Lines to voice again (a generated file's `{ emotion, text }` segments): one card per line. */ +export function loadScriptLines(segments) { + const lines = segments.flatMap(segment => String(segment.text || '').split(/\n+/) + .map(text => ({ text: text.trim(), emotion: segment.emotion || 'main', take: 1 }))) + .filter(line => line.text); + if (!lines.length) return; + stopScriptDirectorPreview(); + state.lines = lines; + state.editing = false; + if (panel) renderAll(); +} + /** Puts the director into `parentContainer` (the Voice-over page). */ export function mountScriptDirector(parentContainer) { if (!panel) createPanel(); @@ -88,7 +105,7 @@ function stopLinePreview() { previewAudio = null; } if (previewButton) { - previewButton.innerHTML = ICONS.PLAY; + previewButton.innerHTML = ICONS.SPEAKER; previewButton.classList.remove('is-playing'); previewButton = null; } @@ -138,9 +155,8 @@ function createPanel() { panel = el('div', 'anomalous-script-director-panel'); const header = el('div', 'anomalous-sd-header'); - const title = el('h1', 'anomalous-sd-title'); const subtitle = el('p', 'anomalous-sd-subtitle'); - header.append(title, subtitle); + header.append(subtitle); const characterBar = el('label', 'anomalous-sd-character-bar'); const characterSelect = el('select', 'anomalous-sd-character-select'); @@ -148,6 +164,7 @@ function createPanel() { state.groupKey = characterSelect.value; stopScriptDirectorPreview(); renderAll(); + hooks.onCharacterChange?.(); }; const characterHint = el('div', 'anomalous-sd-character-hint'); characterBar.append(el('span', 'anomalous-sd-step', t('scriptDirectorCharacter')), characterSelect); @@ -184,6 +201,8 @@ function createPanel() { const linesContainer = el('div', 'anomalous-sd-lines-container'); const scroll = el('div', 'anomalous-sd-scroll'); + // One voice only: emotion chips would all say "main", so one line says how to get more. + const onlyMain = el('div', 'anomalous-sd-only-main'); const footer = el('div', 'anomalous-sd-footer'); const summary = el('div', 'anomalous-sd-package-summary'); @@ -207,6 +226,7 @@ function createPanel() { }, resetTakes: () => state.lines.forEach(line => { line.take = 1; }), onBusyChange: () => renderAll(), + openGallery: () => hooks.openGallery?.(), }); scroll.append(linesContainer); footer.append(summary, runSection.bar); @@ -214,7 +234,7 @@ function createPanel() { // Cards on one side, everything about generating on the other: below the cards // on a narrow page (docked browser), beside them once the page is wide. const main = el('div', 'anomalous-sd-main'); - main.append(linesBar, scroll); + main.append(linesBar, onlyMain, scroll); const side = el('div', 'anomalous-sd-side'); side.append(footer, runSection.settings, workflow); const body = el('div', 'anomalous-sd-body'); @@ -223,13 +243,12 @@ function createPanel() { new ResizeObserver(([entry]) => panel.classList.toggle('is-wide', entry.contentRect.width >= WIDE_FROM)).observe(panel); panel.append(header, characterBar, characterHint, inputArea, body); - refs = { title, subtitle, characterSelect, characterHint, inputArea, textarea, cancelEdit, body, linesCount, linesContainer, summary, workflowTitle, dragHandle, copyBtn, pushBtn }; + refs = { subtitle, characterSelect, characterHint, inputArea, textarea, cancelEdit, body, linesCount, onlyMain, linesContainer, summary, workflowTitle, dragHandle, copyBtn, pushBtn }; } function renderAll() { if (!panel) return; const node = TTS_ENGINE.label; - refs.title.textContent = t('scriptDirectorTitle'); refs.subtitle.textContent = t('scriptDirectorSubtitle'); refs.workflowTitle.textContent = t('scriptDirectorWorkflowTitle'); refs.dragHandle.title = t('scriptDirectorDragHint', { node }); @@ -268,29 +287,31 @@ function renderLines() { const group = selectedGroup(); const emotions = usableEmotions(group); const slices = new Map((group?.slices || []).map(slice => [slice.emotion, slice])); + // Chips only when there is a choice, or a line still asks for an emotion this character lacks. + const showChips = emotions.length > 1 || state.lines.some(line => !emotions.includes(line.emotion)); + refs.onlyMain.hidden = showChips; + refs.onlyMain.textContent = t('scriptDirectorOnlyMain'); const addLine = button('anomalous-sd-add-line', t('scriptDirectorAddLine'), () => { state.lines.push({ text: '', emotion: state.lines.at(-1)?.emotion || 'main', take: 1 }); state.focusIndex = state.lines.length - 1; renderAll(); }); - refs.linesContainer.replaceChildren(...state.lines.map((line, index) => renderLineCard(line, index, emotions, slices)), addLine); + refs.linesContainer.replaceChildren(...state.lines.map((line, index) => renderLineCard(line, index, emotions, slices, showChips)), addLine); if (state.focusIndex !== null) { refs.linesContainer.querySelectorAll('.anomalous-sd-line-text')[state.focusIndex]?.focus(); state.focusIndex = null; } } -function renderLineCard(line, index, emotions, slices) { +function renderLineCard(line, index, emotions, slices, showChips) { const card = el('div', 'anomalous-sd-line-card'); + // Editing buttons (.is-edit) show on hover; another take of this line stays visible. const head = el('div', 'anomalous-sd-line-head'); - const previewBtn = button('anomalous-sd-icon-btn', '', () => previewLine(line, slices, previewBtn), t('scriptDirectorPreview')); - previewBtn.innerHTML = ICONS.PLAY; head.append( el('span', 'anomalous-sd-line-no', `#${index + 1}`), - previewBtn, // Split at the text caret (the textarea keeps its caret after losing focus to this button). - button('anomalous-sd-icon-btn', '✂', async () => { + button('anomalous-sd-icon-btn is-edit', '✂', async () => { const parts = splitSegmentAt(line.text, text.selectionStart); if (!parts) { await anomalousAlert(t('scriptDirectorSplitHint')); @@ -306,7 +327,7 @@ function renderLineCard(line, index, emotions, slices) { ); if (index < state.lines.length - 1) { // Merge keeps this card's emotion: one card = one segment spoken with one voice. - head.append(button('anomalous-sd-icon-btn', '↧', () => { + head.append(button('anomalous-sd-icon-btn is-edit', '↧', () => { line.text = joinSegments(line.text, state.lines[index + 1].text); state.lines.splice(index + 1, 1); stopScriptDirectorPreview(); @@ -322,7 +343,7 @@ function renderLineCard(line, index, emotions, slices) { head.append(retake); } head.append( - button('anomalous-sd-icon-btn is-danger', '×', () => { + button('anomalous-sd-icon-btn is-edit is-danger', '×', () => { state.lines.splice(index, 1); stopScriptDirectorPreview(); renderAll(); @@ -337,14 +358,25 @@ function renderLineCard(line, index, emotions, slices) { renderPackage(); }; + if (!showChips) { + card.append(head, text); + return card; + } const chips = el('div', 'anomalous-sd-emotion-row'); + // The reference clip of the chosen emotion: how this voice sounds, not this line's result. + const reference = button('anomalous-sd-ref-btn', '', () => previewLine(line, slices, reference), + t('scriptDirectorPreviewEmotion', { emotion: emotionLabel(line.emotion) })); + reference.innerHTML = ICONS.SPEAKER; // A choice this character cannot voice stays visible (and blocks the bundle) instead of being silently reset. const choices = emotions.includes(line.emotion) ? emotions : [...emotions, line.emotion]; for (const emotion of choices) { const chip = button('anomalous-sd-emotion-chip', emotionLabel(emotion), () => { line.emotion = emotion; chips.querySelectorAll('.anomalous-sd-emotion-chip').forEach(item => item.classList.toggle('active', item === chip)); - if (previewButton === previewBtn) stopScriptDirectorPreview(); + if (previewButton === reference) stopScriptDirectorPreview(); + reference.title = t('scriptDirectorPreviewEmotion', { emotion: emotionLabel(emotion) }); + reference.setAttribute('aria-label', reference.title); + reference.disabled = !slices.get(emotion)?.audio_url; renderPackage(); }); chip.classList.toggle('active', line.emotion === emotion); @@ -357,6 +389,8 @@ function renderLineCard(line, index, emotions, slices) { if (sample?.text) chip.title = sample.text; chips.appendChild(chip); } + reference.disabled = !slices.get(line.emotion)?.audio_url; + chips.appendChild(reference); card.append(head, text, chips); return card; diff --git a/web/modules/ui_script_page.js b/web/modules/ui_script_page.js index 592f044..3900215 100644 --- a/web/modules/ui_script_page.js +++ b/web/modules/ui_script_page.js @@ -1,16 +1,21 @@ /** - * The Voice-over page (配音, rail page `script`): the script director as a page of its - * own, between Voices (preparing characters) and Audio (the results). It loads the - * GPT-SoVITS characters and mounts ui_script_director.js; without the node pack it shows - * how to install it, without characters the way to Voices. A character card's - * "Voice-over" opens it with that character chosen (`owner.openScript(group)`). + * The Voices page's Voice-over view (配音, shell page `script`, under the rail's Voices + * entry like the character cards): the switch to Characters, then the script director. + * It loads the GPT-SoVITS characters and mounts ui_script_director.js; without the node + * pack it shows how to install it, without characters the way to import one. The + * character list on the left chooses who speaks here; a character card's "Voice-over" + * and the audio gallery's "voice it again" open it with a character (and lines) chosen + * (`owner.openScript`). */ import { t } from './interface_settings.js'; import { isTtsInstalled, loadVoices } from './audio_engines.js'; -import { mountScriptDirector, setScriptDirectorHooks, updateScriptDirectorVoices } from './ui_script_director.js'; +import { parseTaggedSpeech, ttsOutputPrefix } from './audio_script.js'; +import { loadScriptLines, mountScriptDirector, scriptCharacter, setScriptDirectorHooks, updateScriptDirectorVoices } from './ui_script_director.js'; import { renderInstallCard, stopAudioStudioPlayback } from './ui_audio_studio.js'; import { stopGalleryAudio } from './ui_audio_gallery.js'; +import { setActiveAudioFilter, syncAudioSidebarSelection } from './ui_audio_sidebar.js'; +import { renderVoiceTabs } from './ui_voice_tabs.js'; const renderTokens = new WeakMap(); @@ -34,11 +39,30 @@ function note(text, action = null) { return box; } +/** The voice group whose output folder holds a generated file (`audio/`), or null. */ +function groupForFolder(groups, subfolder) { + const folder = String(subfolder || '').replace(/\\/g, '/').replace(/\/+$/, ''); + if (!folder) return null; + return groups.find(group => { + const prefix = ttsOutputPrefix(group.character); + return prefix.slice(0, prefix.lastIndexOf('/')) === folder; + })?.group || null; +} + +/** The left list marks who speaks, as it marks the character shown on Characters. */ +function markCharacter(owner, groups) { + const group = groups.find(item => item.group === scriptCharacter()); + if (!group) return; + setActiveAudioFilter({ type: 'group', value: group.group, character: group.character }); + syncAudioSidebarSelection(owner); +} + /** - * Shows the page in `panel`. `character`: a voice group key to choose (from a card). + * Shows the view in `panel`. `character`: a voice group key to choose (a card, the list); + * `script`: `{ speech, subfolder }` of a generated file to voice again. * Only the latest call for a panel may write to it. */ -export async function renderScriptPage(panel, owner, { character = null } = {}) { +export async function renderScriptPage(panel, owner, { character = null, script = null } = {}) { const token = {}; renderTokens.set(panel, token); setScriptDirectorHooks({ @@ -47,29 +71,41 @@ export async function renderScriptPage(panel, owner, { character = null } = {}) stopAudioStudioPlayback(); stopGalleryAudio(); }, + onCharacterChange: () => loadVoices().then(result => markCharacter(owner, result.groups || [])), + openGallery: () => owner.goTo('audio-gallery'), }); - const mounted = panel.querySelector('.anomalous-script-director-panel'); - if (!mounted) panel.replaceChildren(note(t('audioLoading'))); + if (!panel._scriptTop) { + panel._scriptTop = el('div', 'anomalous-audio-toolbar anomalous-script-topbar'); + panel._scriptHost = el('div', 'anomalous-script-host'); + panel.replaceChildren(panel._scriptTop, panel._scriptHost); + } + panel._scriptTop.replaceChildren(renderVoiceTabs(owner, 'script')); + const host = panel._scriptHost; + const mounted = host.querySelector('.anomalous-script-director-panel'); + if (!mounted) host.replaceChildren(note(t('audioLoading'))); const installed = await isTtsInstalled(); if (renderTokens.get(panel) !== token) return; if (!installed) { - panel.replaceChildren(el('div', 'anomalous-script-page-note', '')); - panel.firstChild.append(renderInstallCard()); + host.replaceChildren(el('div', 'anomalous-script-page-note')); + host.firstChild.append(renderInstallCard()); return; } const result = await loadVoices(); if (renderTokens.get(panel) !== token) return; if (result.error) { - panel.replaceChildren(note(t('audioLoadFailed', { error: result.error }))); + host.replaceChildren(note(t('audioLoadFailed', { error: result.error }))); return; } const groups = result.groups || []; if (!groups.length) { - panel.replaceChildren(note(t('scriptPageNoCharacters'), { label: t('scriptPageGoVoices'), onClick: () => owner.goTo('voices') })); + host.replaceChildren(note(t('scriptPageNoCharacters'), { label: t('scriptPageGoVoices'), onClick: () => owner.goTo('voices') })); return; } - updateScriptDirectorVoices(groups, character, { choose: Boolean(character) }); - if (!mounted) panel.replaceChildren(); - mountScriptDirector(panel); + const chosen = character || (script ? groupForFolder(groups, script.subfolder) : null); + updateScriptDirectorVoices(groups, chosen, { choose: Boolean(chosen) }); + if (script?.speech) loadScriptLines(parseTaggedSpeech(script.speech)); + if (!mounted) host.replaceChildren(); + mountScriptDirector(host); + markCharacter(owner, groups); } diff --git a/web/modules/ui_script_run.js b/web/modules/ui_script_run.js index c5fdb12..a5011e1 100644 --- a/web/modules/ui_script_run.js +++ b/web/modules/ui_script_run.js @@ -14,13 +14,14 @@ import { fetchGptSovitsCharacter, loadGptSovitsStatus, saveGptSovitsSettings } f * * The director owns the lines; this section owns the job, the options and the * result, which live for the page session like the director's panel. It has two - * parts that the director places together: `settings` (one folded line) and `bar` - * (generate / retake / cancel and the result). + * parts that the director places together: `bar` (generate / retake / cancel and the + * result, with a way to the audio gallery) and `settings` (language and speed in + * view, the sampling numbers folded under "fine-tuning"). */ const LANGUAGES = ['auto', 'zh', 'ja', 'en']; const MIN_FORMAT = 11; // Anomalous_TTS interface with [take:N] and `defaults` -// Folded under "Advanced"; ranges and defaults come from the node's own input spec. +// Folded under "fine-tuning"; ranges and defaults come from the node's own input spec. const SAMPLING = ['top_k', 'top_p', 'temperature', 'repetition_penalty']; const NUMBERS = ['speed', ...SAMPLING]; const OPEN_KEY = 'anomalous_script_run_settings_open'; @@ -66,16 +67,17 @@ async function loadNodeSpec() { * `getScript()` -> `{ group, pkg }` for the chosen character (pkg from buildScriptPackage * with takes); `onPlay()` when the result starts playing; * `resetTakes()` when a new take of the whole script starts; `onBusyChange()` when a job - * starts or ends (the director re-renders its line cards, whose retake buttons depend on it). + * starts or ends (the director re-renders its line cards, whose retake buttons depend on it); + * `openGallery()` for the result's "saved to the audio gallery". */ -export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange }) { +export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange, openGallery }) { const state = { seed: newSeed(), spec: null, // node input ranges/defaults; null until loaded options: new Map(), // character -> options being edited saved: new Map(), // character -> options saved in this session job: null, - status: null, // { state: 'queued' | 'running' | 'done' | 'cancelled', value?, max?, summary? } + status: null, // { state: 'queued' | 'running' | 'done' | 'cancelled', value?, max?, seconds? } result: null, // { audio, url, warnings, character } supported: null, // TTS interface >= MIN_FORMAT; null while unknown }; @@ -83,46 +85,69 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } const bar = el('div', 'anomalous-sd-run'); const hint = el('div', 'anomalous-sd-run-hint is-warning', t('scriptRunNeedsUpdate')); - const settings = el('details', 'anomalous-sd-run-settings'); - const settingsSummary = el('summary'); - try { settings.open = localStorage.getItem(OPEN_KEY) === '1'; } catch (_) { /* folded by default */ } - settings.addEventListener('toggle', () => { - try { localStorage.setItem(OPEN_KEY, settings.open ? '1' : '0'); } catch (_) { /* convenience only */ } - }); - const language = el('select', 'anomalous-sd-run-select'); - for (const code of LANGUAGES) { - const option = el('option', '', t(`scriptRunLang_${code}`)); - option.value = code; - language.appendChild(option); - } + // How it is read: language and speed, always in view, one per row. + const settings = el('div', 'anomalous-sd-run-settings'); const fields = {}; - const numberField = name => { + /** A number the user set: clamped to the node's range and kept for this character. */ + const commit = (name, raw) => { + const script = getScript(); + const range = state.spec?.[name]; + let value = Number(raw); + if (!Number.isFinite(value) || !range) value = range?.default ?? 1; + value = Math.min(range?.max ?? value, Math.max(range?.min ?? value, name === 'top_k' ? Math.round(value) : value)); + if (script?.group) currentOptions(script.group)[name] = value; + render(); + }; + + const languageRow = el('div', 'anomalous-sd-run-row'); + const languageChoices = el('div', 'anomalous-sd-run-segment'); + languageChoices.setAttribute('role', 'radiogroup'); + const languageButtons = new Map(); + for (const code of LANGUAGES) { + const choice = button('anomalous-sd-run-choice', t(`scriptRunLang_${code}`), () => { + const script = getScript(); + if (script?.group) currentOptions(script.group).language = code; + render(); + }); + choice.setAttribute('role', 'radio'); + languageButtons.set(code, choice); + languageChoices.append(choice); + } + languageRow.append(el('span', 'anomalous-sd-run-label', t('scriptRunLanguage')), languageChoices); + + const speedRow = el('label', 'anomalous-sd-run-row'); + const speed = el('input', 'anomalous-sd-run-slider'); + speed.type = 'range'; + speed.title = t('scriptRunParamHint_speed'); + const speedValue = el('span', 'anomalous-sd-run-value'); + speed.oninput = () => { speedValue.textContent = speed.value; }; + speed.onchange = () => commit('speed', speed.value); + fields.speed = speed; + speedRow.append(el('span', 'anomalous-sd-run-label', t('scriptRunSpeed')), speed, speedValue); + + // The sampling numbers, folded: a plain name with the node's own name under it. + const fine = el('details', 'anomalous-sd-run-fine'); + const fineSummary = el('summary'); + try { fine.open = localStorage.getItem(OPEN_KEY) === '1'; } catch (_) { /* folded by default */ } + fine.addEventListener('toggle', () => { + try { localStorage.setItem(OPEN_KEY, fine.open ? '1' : '0'); } catch (_) { /* convenience only */ } + }); + fine.append(fineSummary); + for (const name of SAMPLING) { + const row = el('label', 'anomalous-sd-run-row'); + const label = el('span', 'anomalous-sd-run-label'); + label.append(el('span', '', t(`scriptRunParamName_${name}`)), el('code', 'anomalous-sd-run-code', name)); const input = el('input', 'anomalous-sd-run-number'); input.type = 'number'; input.title = t(`scriptRunParamHint_${name}`); - input.onchange = () => { - const script = getScript(); - const range = state.spec?.[name]; - let value = Number(input.value); - if (!Number.isFinite(value) || !range) value = range?.default ?? 1; - value = Math.min(range?.max ?? value, Math.max(range?.min ?? value, name === 'top_k' ? Math.round(value) : value)); - if (script?.group) currentOptions(script.group)[name] = value; - render(); - }; + input.onchange = () => commit(name, input.value); fields[name] = input; - return input; - }; - const grid = el('div', 'anomalous-sd-run-grid'); - const param = (label, control) => { - const row = el('label', 'anomalous-sd-run-param'); - row.append(el('span', '', label), control); - grid.appendChild(row); - }; - param(t('scriptRunLanguage'), language); - param(t('scriptRunSpeed'), numberField('speed')); - for (const name of SAMPLING) param(name, numberField(name)); + row.append(label, input); + fine.append(row); + } + fine.append(el('div', 'anomalous-sd-run-note', t('scriptRunAdvancedNote'))); const saveDefaults = button('anomalous-sd-link-btn', t('scriptRunSaveDefaults'), () => storeDefaults(), t('scriptRunSaveDefaultsHint')); - settings.append(settingsSummary, grid, el('div', 'anomalous-sd-run-note', t('scriptRunAdvancedNote')), saveDefaults); + settings.append(el('div', 'anomalous-sd-run-heading', t('scriptRunHow')), languageRow, speedRow, fine, saveDefaults); const actions = el('div', 'anomalous-sd-run-actions'); const generateBtn = button('anomalous-sd-btn accent', t('scriptRunGenerate'), () => generate()); @@ -139,7 +164,7 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } player.controls = true; player.preload = 'auto'; player.addEventListener('play', () => onPlay?.()); - const savedTo = el('div', 'anomalous-sd-run-saved'); + const savedTo = button('anomalous-sd-link-btn anomalous-sd-run-saved', t('scriptRunSavedLink'), () => openGallery?.()); const warnings = el('div', 'anomalous-sd-run-warnings'); bar.append(hint, actions, player, savedTo, warnings); @@ -162,12 +187,6 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } const sameOptions = (a, b) => ['language', ...NUMBERS].every(name => a[name] === b[name]); - language.onchange = () => { - const script = getScript(); - if (script?.group) currentOptions(script.group).language = language.value; - render(); - }; - async function checkSupport() { try { const [status, spec] = await Promise.all([loadGptSovitsStatus(), loadNodeSpec()]); @@ -187,6 +206,7 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } const { language: lang, ...numbers } = currentOptions(script.group); const prompt = buildTtsPrompt({ character, speech: script.pkg.speech, seed: state.seed, language: lang, ...numbers }); state.status = { state: 'queued' }; + const started = Date.now(); state.job = startPromptJob(prompt, { onStatus: next => { state.status = next; @@ -198,9 +218,10 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } const { outputs } = await state.job.result; const audio = outputs['2']?.audio?.[0]; if (!audio) throw new Error(t('scriptRunNoAudio')); - const [summary = '', ...rest] = String(outputs['1']?.text?.[0] || '').split('\n'); + // The first line is the node's own count (its sentences, batches, cache), not the reader's lines. + const [, ...rest] = String(outputs['1']?.text?.[0] || '').split('\n'); state.result = { audio, url: `${audioUrl(audio)}&t=${Date.now()}`, warnings: rest.filter(Boolean), character }; - state.status = { state: 'done', summary }; + state.status = { state: 'done', seconds: Math.max(1, Math.round((Date.now() - started) / 1000)) }; state.job = null; onBusyChange?.(); player.play().catch(() => {}); @@ -247,7 +268,7 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } let text = ''; if (s?.state === 'queued') text = t('scriptRunQueued'); else if (s?.state === 'running') text = s.max ? t('scriptRunProgress', { value: s.value, max: s.max }) : t('scriptRunRunning'); - else if (s?.state === 'done') text = t('scriptRunDone', { summary: s.summary }); + else if (s?.state === 'done') text = t('scriptRunDone', { seconds: s.seconds }); else if (s?.state === 'cancelled') text = t('scriptRunCancelled'); status.textContent = text; status.hidden = !text; @@ -269,18 +290,18 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } if (ready) { const opts = currentOptions(group); const tuned = SAMPLING.some(name => opts[name] !== state.spec[name].default); - settingsSummary.textContent = t('scriptRunSettingsSummary', { - language: t(`scriptRunLang_${opts.language}`), - speed: opts.speed, - }) + (tuned ? t('scriptRunSettingsTuned') : ''); - language.value = opts.language; + fineSummary.textContent = t('scriptRunFineTune') + (tuned ? t('scriptRunFineTuned') : ''); + for (const [code, choice] of languageButtons) { + choice.setAttribute('aria-checked', String(opts.language === code)); + choice.disabled = busy; + } for (const name of NUMBERS) { const range = state.spec[name]; Object.assign(fields[name], { min: String(range.min), max: String(range.max), step: String(range.step ?? 1) }); fields[name].value = String(opts[name]); fields[name].disabled = busy; } - language.disabled = busy; + speedValue.textContent = String(opts.speed); saveDefaults.hidden = sameOptions(opts, storedOptions(group)); } else { saveDefaults.hidden = true; @@ -298,7 +319,7 @@ export function createRunSection({ getScript, onPlay, resetTakes, onBusyChange } savedTo.hidden = !hasResult; if (hasResult) { const folder = String(state.result.audio.subfolder || '').replace(/\\/g, '/'); - savedTo.textContent = t('scriptRunSavedTo', { path: `output/${folder}/${state.result.audio.filename}` }); + savedTo.title = `output/${folder}/${state.result.audio.filename}`; } warnings.hidden = !hasResult || !state.result.warnings.length; if (hasResult) warnings.textContent = state.result.warnings.join('\n'); diff --git a/web/modules/ui_shell_nav.js b/web/modules/ui_shell_nav.js index 5922d77..d425fa0 100644 --- a/web/modules/ui_shell_nav.js +++ b/web/modules/ui_shell_nav.js @@ -18,15 +18,19 @@ const REMEMBERED = new Set(['home', 'activity', 'models', 'gallery', 'voices', ' const AUDIO_PAGES = new Set(['voices', 'script', 'audio-gallery']); // The audio tab (browser.switchAudioTab) of each audio page. const AUDIO_TABS = { voices: 'presets', script: 'script', 'audio-gallery': 'gallery' }; +// Voice-over is a view of the Voices page: same rail entry, same list column. +const RAIL_OF = { script: 'voices' }; +const railOf = page => RAIL_OF[page] || page; // Pages with a list column, and where each remembers whether you closed it. const LIST_KEYS = { models: 'anomalous_user_sidebar_closed', voices: 'anomalous_audio_list_closed', + script: 'anomalous_audio_list_closed', 'audio-gallery': 'anomalous_audio_list_closed', }; const TITLE_KEYS = { home: 'shellHome', activity: 'activityTitle', models: 'shellTitleModels', gallery: 'gallery', recipes: 'recipeTitle', - materials: 'materialLibrary', voices: 'shellVoices', script: 'shellScript', 'audio-gallery': 'shellAudioGallery', + materials: 'materialLibrary', voices: 'shellVoices', script: 'shellVoices', 'audio-gallery': 'shellAudioGallery', doctor: 'sidebarDoctor', assistant: 'sidebarAssistant', scan: 'scanPageTitle', }; // Below this width the list covers the page instead of sitting beside it, and starts closed. @@ -51,6 +55,7 @@ export function startPage() { */ export function installShellNavigation(owner, { container, rail, listToggle, title }) { let current = null; + let lastVoiceView = 'voices'; // the rail's Voices entry reopens the view used last const narrow = () => container.clientWidth > 0 && container.clientWidth < NARROW_PX; const setTitle = (page) => { title.textContent = page ? t(TITLE_KEYS[page]) : ''; }; @@ -98,10 +103,15 @@ export function installShellNavigation(owner, { container, rail, listToggle, tit }).observe(container); } + // The side the list column was last drawn for. The stored side survives a reload, so it + // cannot tell whether the list was drawn yet (opening straight onto an audio page left it empty). + let listDomain = null; + /** Switches the domain the list column and the "!" guide follow; loads the model list once. */ const useDomain = (domain) => { - const changed = getActiveDomain() !== domain; - if (changed) setActiveDomain(domain); + if (getActiveDomain() !== domain) setActiveDomain(domain); + const changed = listDomain !== domain; + listDomain = domain; if (domain === 'audio') { if (changed) owner.renderSidebar(); return; @@ -115,8 +125,9 @@ export function installShellNavigation(owner, { container, rail, listToggle, tit const enter = (page) => { current = page; + if (railOf(page) === 'voices') lastVoiceView = page; if (REMEMBERED.has(page)) write(LAST_PAGE_KEY, page); - rail.setActive(page); + rail.setActive(railOf(page)); setTitle(page); }; @@ -162,10 +173,11 @@ export function installShellNavigation(owner, { container, rail, listToggle, tit // The rail entry of the page you are on opens or closes its list; from a model's // detail it goes back to the grid instead. const atRoot = page !== 'models' || owner.grid.style.display !== 'none'; - if (fromRail && page === current && !inWorkspace && atRoot && LIST_KEYS[page]) { + if (fromRail && railOf(page) === railOf(current) && !inWorkspace && atRoot && LIST_KEYS[page]) { toggleList(); return; } + if (fromRail && page === 'voices') page = lastVoiceView; if (page === 'recipes' || page === 'materials') { // Workspaces belong to the image side: from an audio page they open over the models. if (AUDIO_PAGES.has(current)) owner.goTo('models'); @@ -218,7 +230,7 @@ export function installShellNavigation(owner, { container, rail, listToggle, tit const closeWorkspace = owner.closeWorkspace; owner.closeWorkspace = function (...args) { const result = closeWorkspace.apply(this, args); - rail.setActive(REMEMBERED.has(current) ? current : null); + rail.setActive(REMEMBERED.has(current) ? railOf(current) : null); setTitle(current); return result; }; diff --git a/web/modules/ui_shell_rail.js b/web/modules/ui_shell_rail.js index 1c2083d..ea29ea0 100644 --- a/web/modules/ui_shell_rail.js +++ b/web/modules/ui_shell_rail.js @@ -19,6 +19,7 @@ export const PAGE_ICONS = { recipes: icon(''), materials: TOOL_ICONS.MATERIALS, voices: icon(''), + // Not a rail entry (Voice-over is a view of Voices); Home's voice-over card uses it. script: icon(''), 'audio-gallery': icon(''), }; @@ -32,7 +33,6 @@ export const RAIL_PAGES = Object.freeze([ { page: 'recipes', id: 'anomalous-notebook-btn', labelKey: 'recipeTitle' }, { page: 'materials', id: 'anomalous-materials-btn', labelKey: 'shellMaterials' }, { page: 'voices', id: 'anomalous-voices-btn', labelKey: 'shellVoices', audio: true }, - { page: 'script', id: 'anomalous-script-btn', labelKey: 'shellScript', audio: true }, { page: 'audio-gallery', id: 'anomalous-audio-gallery-btn', labelKey: 'shellAudioGallery', audio: true }, ]); diff --git a/web/modules/ui_voice_tabs.js b/web/modules/ui_voice_tabs.js new file mode 100644 index 0000000..f86d0e1 --- /dev/null +++ b/web/modules/ui_voice_tabs.js @@ -0,0 +1,30 @@ +/** + * The Voices page's two views as one switch at the top of each: Characters (the cards, + * ui_audio_studio.js, shell page `voices`) and Voice-over (ui_script_page.js, shell page + * `script`). Both sit under the rail's Voices entry and share the character list. + */ + +import { t } from './interface_settings.js'; + +const TABS = [['voices', 'voiceTabCharacters'], ['script', 'scriptDirectorTitle']]; + +/** The switch; `active`: 'voices' | 'script'. */ +export function renderVoiceTabs(owner, active) { + const bar = document.createElement('div'); + bar.className = 'anomalous-voice-tabs'; + bar.setAttribute('role', 'tablist'); + for (const [page, key] of TABS) { + const tab = document.createElement('button'); + tab.type = 'button'; + tab.className = 'anomalous-voice-tab'; + tab.textContent = t(key); + tab.dataset.tour = `voice-tab-${page}`; + tab.setAttribute('role', 'tab'); + tab.setAttribute('aria-selected', String(page === active)); + tab.onclick = () => { + if (page !== active) owner?.goTo(page); + }; + bar.append(tab); + } + return bar; +} diff --git a/web/modules/update_guide_data.js b/web/modules/update_guide_data.js index 69f6bde..d1b1c6b 100644 --- a/web/modules/update_guide_data.js +++ b/web/modules/update_guide_data.js @@ -18,7 +18,7 @@ export const AUDIO_TOUR_STEPS = Object.freeze([ { targetSelector: '.anomalous-character-voice-header.is-draggable', icon: '🧲', titleKey: 'audioTourDragTitle', bodyKey: 'audioTourDragBody', position: 'bottom' }, { targetSelector: '.anomalous-character-voice-actions', icon: '🎭', titleKey: 'audioTourEditTitle', bodyKey: 'audioTourEditBody', position: 'bottom' }, { targetSelector: '.anomalous-voice-copy-btn', icon: '🏷️', titleKey: 'audioTourTagTitle', bodyKey: 'audioTourTagBody', position: 'bottom' }, - { targetSelector: '#anomalous-script-btn', icon: '📜', titleKey: 'audioTourScriptTitle', bodyKey: 'audioTourScriptBody', position: 'right' }, + { targetSelector: '[data-tour="voice-tab-script"]', icon: '📜', titleKey: 'audioTourScriptTitle', bodyKey: 'audioTourScriptBody', position: 'bottom' }, { targetSelector: '#anomalous-audio-gallery-btn', icon: '🎧', titleKey: 'audioTourGalleryTitle', bodyKey: 'audioTourGalleryBody', position: 'right' }, { targetSelector: '.anomalous-audio-sidebar-settings', icon: '⚙️', titleKey: 'audioTourSettingsTitle', bodyKey: 'audioTourSettingsBody', position: 'top' }, ]); diff --git a/web/styles.css b/web/styles.css index 8bd9fcc..0d4cf1a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -9,9 +9,9 @@ @import url("./styles/08-theme-gallery-overrides.css?v=20261002-theme-1"); @import url("./styles/09-prompt-drawers.css?v=20261002-theme-1"); @import url("./styles/10-model-sources.css?v=20261002-sources-1"); -@import url("./styles/11-audio-studio.css?v=20261002-voiceover-1"); +@import url("./styles/11-audio-studio.css?v=20261002-voiceover-2"); @import url("./styles/12-tts-setup.css?v=20261002-theme-1"); -@import url("./styles/13-script-director.css?v=20261002-voiceover-1"); +@import url("./styles/13-script-director.css?v=20261002-voiceover-2"); @import url("./styles/14-version-manager.css?v=20261002-theme-1"); @import url("./styles/15-shell-rail.css?v=20261002-theme-1"); @import url("./styles/16-activity.css?v=20261002-theme-1"); diff --git a/web/styles/11-audio-studio.css b/web/styles/11-audio-studio.css index 38f97fd..067ef12 100644 --- a/web/styles/11-audio-studio.css +++ b/web/styles/11-audio-studio.css @@ -48,6 +48,38 @@ flex-shrink: 0; } +/* Characters | Voice-over: the Voices page's two views (ui_voice_tabs.js). */ +.anomalous-voice-tabs { + display: inline-flex; + gap: 2px; + padding: 3px; + border: 1px solid var(--amb-border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); +} + +.anomalous-voice-tab { + padding: 6px 18px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--amb-text-muted); + font: inherit; + font-size: 13px; + white-space: nowrap; + cursor: pointer; +} + +.anomalous-voice-tab:hover { + color: var(--amb-text-main); +} + +.anomalous-voice-tab[aria-selected="true"] { + background: color-mix(in srgb, var(--amb-link) 18%, transparent); + color: var(--amb-text-main); + font-weight: 600; +} + .anomalous-audio-title-group { display: flex; align-items: center; @@ -517,6 +549,11 @@ border: 1px solid var(--amb-border); } +/* Folded until the summary is clicked (display: flex above would show it anyway). */ +.anomalous-track-speech-full[hidden] { + display: none; +} + .anomalous-track-speech-line { display: flex; align-items: baseline; @@ -1160,11 +1197,14 @@ } /* ---------- GPT-SoVITS cards ---------- */ +/* Wraps in a narrow card instead of pushing the page sideways. */ .anomalous-character-voice-actions { display: inline-flex; - flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; align-items: center; - gap: 8px; + gap: 6px 8px; + min-width: 0; margin-left: auto; } diff --git a/web/styles/13-script-director.css b/web/styles/13-script-director.css index c054464..0ea483b 100644 --- a/web/styles/13-script-director.css +++ b/web/styles/13-script-director.css @@ -1,4 +1,4 @@ -/* The Voice-over page (ui_script_page.js) and the script director it holds. */ +/* The Voices page's Voice-over view (ui_script_page.js) and the script director it holds. */ #anomalous-script-panel { flex: 1; flex-direction: column; @@ -7,6 +7,18 @@ background: var(--amb-bg-page); } +/* Characters | Voice-over, where the Characters view has its toolbar. */ +.anomalous-script-topbar { + margin: 20px 20px 0; +} + +.anomalous-script-host { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + .anomalous-script-director-panel { display: flex; flex: 1; @@ -51,6 +63,7 @@ .anomalous-sd-side { flex-shrink: 0; max-height: 55%; + overflow-x: hidden; overflow-y: auto; display: flex; flex-direction: column; @@ -74,18 +87,11 @@ } .anomalous-sd-header { - padding: 20px 16px 6px; -} - -.anomalous-sd-title { - margin: 0; - color: var(--amb-text-main); - font-size: 1.35em; - font-weight: 600; + padding: 12px 16px 2px; } .anomalous-sd-subtitle { - margin: 6px 0 0; + margin: 0; color: var(--amb-text-muted); font-size: 13px; line-height: 1.6; @@ -268,6 +274,31 @@ gap: 4px; } +/* Split, merge and delete show on the card you are on; another take of a line stays in view. */ +.anomalous-sd-line-head .anomalous-sd-icon-btn.is-edit { + opacity: 0; + transition: opacity 0.15s ease; +} + +.anomalous-sd-line-card:hover .anomalous-sd-icon-btn.is-edit, +.anomalous-sd-line-card:focus-within .anomalous-sd-icon-btn.is-edit { + opacity: 1; +} + +@media (hover: none) { + .anomalous-sd-line-head .anomalous-sd-icon-btn.is-edit { + opacity: 1; + } +} + +/* One voice only: instead of a row of "main voice" chips on every card. */ +.anomalous-sd-only-main { + padding: 0 16px 8px; + color: var(--amb-text-dim); + font-size: 11.5px; + line-height: 1.5; +} + .anomalous-sd-line-no { flex: 1; font-size: 11px; @@ -327,9 +358,35 @@ .anomalous-sd-emotion-row { display: flex; flex-wrap: wrap; + align-items: center; gap: 5px; } +/* The chosen emotion's reference clip: how the voice sounds, beside the chips it belongs to. */ +.anomalous-sd-ref-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--amb-text-dim); + cursor: pointer; +} + +.anomalous-sd-ref-btn:hover:not(:disabled), +.anomalous-sd-ref-btn.is-playing { + color: var(--amb-link); + background: color-mix(in srgb, var(--amb-link) 12%, transparent); +} + +.anomalous-sd-ref-btn:disabled { + opacity: 0.35; + cursor: default; +} + .anomalous-sd-emotion-chip { padding: 2px 9px; border-radius: 999px; @@ -482,20 +539,6 @@ color: #fbbf24; } -.anomalous-sd-run-settings { - flex-shrink: 0; - padding: 6px 10px; - border-radius: 8px; - background: color-mix(in srgb, var(--amb-link) 6%, transparent); - border: 1px solid color-mix(in srgb, var(--amb-link) 18%, transparent); -} - -.anomalous-sd-run-settings summary { - cursor: pointer; - font-size: 12px; - color: var(--amb-text-soft); -} - .anomalous-sd-run-actions { display: flex; align-items: center; @@ -503,54 +546,150 @@ gap: 8px; } -.anomalous-sd-run-select, -.anomalous-sd-run-number { - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--amb-border-strong); - color: var(--amb-text-main); +/* How it is read: one setting per row; the sampling numbers folded under fine-tuning. */ +.anomalous-sd-run-settings { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; + border-top: 1px solid var(--amb-border); +} + +.anomalous-sd-run-heading { + color: var(--amb-text-dim); + font-size: 11.5px; +} + +.anomalous-sd-run-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.anomalous-sd-run-label { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 44px; + color: var(--amb-text-soft); + font-size: 12px; + line-height: 1.35; +} + +.anomalous-sd-run-code { + color: var(--amb-text-dim); + font-family: ui-monospace, monospace; + font-size: 10px; + overflow-wrap: anywhere; +} + +.anomalous-sd-run-segment { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + padding: 2px; + border: 1px solid var(--amb-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} + +.anomalous-sd-run-choice { + padding: 3px 10px; + border: none; border-radius: 6px; + background: transparent; + color: var(--amb-text-muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.anomalous-sd-run-choice:hover:not(:disabled) { + color: var(--amb-text-main); +} + +.anomalous-sd-run-choice[aria-checked="true"] { + background: color-mix(in srgb, var(--amb-link) 18%, transparent); + color: var(--amb-text-main); + font-weight: 600; +} + +.anomalous-sd-run-choice:disabled { + opacity: 0.6; + cursor: default; +} + +.anomalous-sd-run-slider { + flex: 1; + min-width: 0; + accent-color: var(--amb-link); +} + +.anomalous-sd-run-value { + width: 32px; + color: var(--amb-text-main); + font-size: 12px; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.anomalous-sd-run-fine > summary { + color: var(--amb-text-muted); + font-size: 12px; + cursor: pointer; +} + +.anomalous-sd-run-fine[open] > summary { + margin-bottom: 4px; +} + +.anomalous-sd-run-fine > .anomalous-sd-run-row { + padding: 4px 0 4px 14px; +} + +.anomalous-sd-run-fine .anomalous-sd-run-label { + flex: 1; + width: auto; + min-width: 0; +} + +/* The pronunciation editor's language picker (ui_tts_pronunciation.js). */ +.anomalous-sd-run-select { padding: 4px 6px; + border: 1px solid var(--amb-border-strong); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + color: var(--amb-text-main); font-size: 12px; } .anomalous-sd-run-number { + flex-shrink: 0; width: 64px; -} - -.anomalous-sd-run-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px 12px; - margin-top: 8px; -} - -.anomalous-sd-run-param { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - font-size: 11px; - color: var(--amb-text-soft); - font-family: ui-monospace, monospace; + padding: 4px 6px; + border: 1px solid var(--amb-border-strong); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + color: var(--amb-text-main); + font-size: 12px; } .anomalous-sd-run-note { - margin: 6px 0 4px; + margin: 6px 0 4px 14px; font-size: 11px; line-height: 1.5; color: var(--amb-text-muted); } -.anomalous-sd-run-status, -.anomalous-sd-run-saved { +.anomalous-sd-run-status { font-size: 12px; color: var(--amb-text-soft); - word-break: break-all; } +/* "Saved to the audio gallery ›": the file's path is its tooltip. */ .anomalous-sd-run-saved { - color: var(--amb-text-muted); - font-size: 11px; + align-self: flex-start; } .anomalous-sd-run-warnings {