fix(audio): the audio guide's tour walks the audio page; suggested folder layout

The "!" guide on the audio page hid its tour banner with the hidden attribute,
but the banner's display:flex overrode it, so the banner showed and started the
image-side tour, whose buttons are not on the audio page. The audio guide now
has its own spotlight steps, and tour steps whose target is not on screen are
skipped. The batch import screen shows a folded example of a folder layout that
sorts cleanly: one folder per character, named after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-09-29 16:32:46 +08:00
co-authored by Claude Opus 5.5
parent 805b297a80
commit 7da4e1d91e
9 changed files with 146 additions and 7 deletions
+2
View File
@@ -5,6 +5,8 @@
### 🎙️ Audio & Voice Studio (音频与声音工作台)
- **Generate right in the Script Director (剧本台直接生成)**: for GPT-SoVITS characters, **Generate** runs the script in ComfyUI without touching the canvas (it waits in the queue while ComfyUI is busy), plays the result and saves it to `output/audio/<character>/`. **↻** on a line card gives just that line another take while the others come from cache; **New take of all** starts over with a new seed. Language, speed and the sampling parameters (one folded **Generation settings** line, with a hint on each) start from the character's saved defaults; when a take sounds right, **Save these settings as this character's default** keeps them for next time. The drawer is wider: the line cards get the full height with generating in a column beside them, and in a narrow or docked browser the drawer covers the studio with generating below the cards; either way only the cards scroll, however long the script is. Needs Anomalous_TTS interface 11.
- **Pronunciation table (读音表)**: **Pronunciation** on a GPT-SoVITS character card lists what the script says and what the character should read instead (`C站 → 西站`); each line can be heard as the character reads it now and as replaced, without filling the audio gallery.
- **Audio page tour (音频页遮罩导览)**: the tour offered in the audio page's "!" guide now walks through the audio page itself (import, dragging a character onto a node, emotions and pronunciation, emotion tags, Script Director, audio gallery, GPT-SoVITS settings) instead of the image-side tour, whose buttons are not there. Tour steps whose button is not on screen are skipped.
- **Suggested folder layout (推荐的文件夹放法)**: the "many characters" import screen shows, folded, how to arrange a folder so it sorts cleanly: one folder per character, named after it, with its GPT and SoVITS models and 3–10 s clips (and same-name line files) inside.
- **Audio page tabs (音频页顶部标签)**: the "Audio Workflows" tab is gone from the audio page; generating now happens in the Script Director, and saved workflows stay under **Workflows** on the image side.
- **Script Director (剧本台)**: paste a script for one character; each sentence becomes a card with emotion chips, inline editing, split at cursor, merge and delete. Generate it right there (below), or push the tagged script to the selected GPT-SoVITS node or drag it onto one.
- **Audio gallery (音频库)**: lists generated audio with its speech text, voice and seed read from the file itself, supports search, and can save unsaved previews into `output/audio/`.
+1 -1
View File
@@ -27,7 +27,7 @@
## 音频页使用说明
- `AUDIO_USAGE_GUIDE`(同在 `update_guide_data.js`)是音频页的使用说明,不是更新公告。音频域激活时,右上角 **!** 打开它;图像域照旧打开 `CURRENT_UPDATE_GUIDE`。
- 可选字段:`titleKey` 替换对话框标题,`tour: false` 隐藏图像界面的遮罩导览入口。
- 可选字段:`titleKey` 替换对话框标题;`tour: false` 不显示遮罩导览入口,`tour: [...]` 用这份说明自己的导览步骤(音频页用 `AUDIO_TOUR_STEPS`),不写则是图像界面的导览。导览步骤的目标不在屏幕上时自动跳过。
- 内容跟着功能改,ID 保持 `audio-studio-usage`。完整说明在 `docs/guides/audio-studio.md`,两边要一致。
## 生命周期与记录
+50
View File
@@ -1303,6 +1303,20 @@ export const i18n = {
audioGuideScriptTitle: "剧本台:不用手写标签",
audioGuideScriptBody: "点“打开剧本台”,选角色、粘贴整段台词,每句会变成一张卡片,点一下就能选情绪,也可以拆分、合并、删除。然后直接点“生成语音”;想放进自己的工作流,也可以点“推送到节点”或按住“拖到节点”,角色和台词会一起填好。",
audioGuideGenerateTitle: "生成与音频库",
audioTourImportTitle: "导入角色",
audioTourImportBody: "点这里导入 GPT、SoVITS 模型和参考音频;也可以直接把文件或整个文件夹拖到这一页。",
audioTourDragTitle: "拖到节点上",
audioTourDragBody: "按住角色卡片的标题栏,拖到画布上的“角色语音 (GPT-SoVITS)”节点,就换成这个角色。",
audioTourEditTitle: "情绪和读音",
audioTourEditBody: "“编辑情绪”给每种情绪挑一条参考音频;“读音”纠正总念错的词;“添加文件”往这个角色里补音频。",
audioTourTagTitle: "情绪标签",
audioTourTagBody: "点一下复制这个 {标签},写进台词里,后面的句子就换成这种情绪的声音。",
audioTourScriptTitle: "剧本台",
audioTourScriptBody: "粘贴整段台词,每句点选情绪,直接生成语音;也可以推送到节点,用你自己的工作流。",
audioTourGalleryTitle: "音频库",
audioTourGalleryBody: "生成的音频都在这里,可以试听、按台词搜索。",
audioTourSettingsTitle: "GPT-SoVITS 设置",
audioTourSettingsBody: "角色存放在哪、底模下载、整合包,都在这里设置。",
audioGuideGenerateBody: "在剧本台里点“生成语音”,不用碰画布,结果存到 output/audio/角色名/;某一句不满意可以只重来这一句。之后到“音频库”试听、按台词搜索;只在预览里出现的临时结果,可以点保存留下来。",
audioEngineInstallTitle: "{engine} 需要先安装节点包 {pack}",
audioEngineDesc: "GPT-SoVITS(Anomalous TTS):使用你训练好的 GPT-SoVITS 角色模型,音色还原度高;支持多句一起生成、多角色对话和停顿标签。",
@@ -1447,6 +1461,17 @@ export const i18n = {
ttsHeroLocalFiles: "选文件",
ttsHeroLocalFolder: "选文件夹",
ttsHeroLocal: "文件就在这台电脑上?不用上传:",
ttsLayoutTitle: "📁 推荐的文件夹放法",
ttsLayoutRoot: "我的声音",
ttsLayoutRootNote: "选这个文件夹",
ttsLayoutCharA: "阿罗娜",
ttsLayoutCharB: "普拉娜",
ttsLayoutCharNote: "一个角色一个文件夹,文件夹名就是角色名",
ttsLayoutClip: "早上好",
ttsLayoutClipNote: "参考音频,3~10 秒,可以放好几段",
ttsLayoutLineNote: "这段音频的台词,同名(可选)",
ttsLayoutMore: "其他角色一样放",
ttsLayoutFoot: "GPT-SoVITS 整合包也可以直接选整个包:GPT_weights 和 SoVITS_weights 里的模型按文件名分成角色。分不出来的文件会单独列出来,让你选它属于谁。",
ttsMenuUploadHint: "从浏览器上传",
ttsMenuLocalFiles: "从这台电脑选文件…",
ttsMenuLocalHint: "直接读取,不用上传,更快",
@@ -3028,6 +3053,20 @@ export const i18n = {
audioGuideScriptTitle: "Script Director: no hand-written tags",
audioGuideScriptBody: "Open the Script Director, pick a character and paste the script. 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, press \"Push to node\" or drag \"Drag to node\" to fill 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.",
audioTourDragTitle: "Drag onto a node",
audioTourDragBody: "Hold a character card's header and drop it on a \"角色语音 (GPT-SoVITS)\" node on the canvas to switch it to that character.",
audioTourEditTitle: "Emotions and pronunciation",
audioTourEditBody: "\"Edit emotions\" picks a reference clip per emotion, \"Pronunciation\" fixes words it keeps misreading, \"Add files\" adds clips to this character.",
audioTourTagTitle: "Emotion tags",
audioTourTagBody: "Click to copy this {tag}; written in the text, the lines after it use that emotion's voice.",
audioTourScriptTitle: "Script Director",
audioTourScriptBody: "Paste a whole script, pick an emotion per line and generate right away, or push it to a node to use your own workflow.",
audioTourGalleryTitle: "Audio gallery",
audioTourGalleryBody: "Everything generated is here, to play and search by text.",
audioTourSettingsTitle: "GPT-SoVITS settings",
audioTourSettingsBody: "Where characters are kept, pretrained downloads and packages are set here.",
audioGuideGenerateBody: "Press \"Generate\" in the Script Director, without touching the canvas; results go to output/audio/<character>/, and a single line can be redone on its own. Afterwards the audio gallery lets you play results and search by text; preview-only results can be saved there.",
audioEngineInstallTitle: "{engine} needs the {pack} node pack",
audioEngineDesc: "GPT-SoVITS (Anomalous TTS) uses your trained GPT-SoVITS character models for a faithful voice, with batched lines, multi-character dialogue and pause tags.",
@@ -3172,6 +3211,17 @@ export const i18n = {
ttsHeroLocalFiles: "choose files",
ttsHeroLocalFolder: "choose a folder",
ttsHeroLocal: "Files on this PC? No upload needed:",
ttsLayoutTitle: "📁 Suggested folder layout",
ttsLayoutRoot: "My voices",
ttsLayoutRootNote: "choose this folder",
ttsLayoutCharA: "Arona",
ttsLayoutCharB: "Plana",
ttsLayoutCharNote: "one folder per character, named after it",
ttsLayoutClip: "good_morning",
ttsLayoutClipNote: "reference clip, 3–10 s, several are fine",
ttsLayoutLineNote: "its line, same name (optional)",
ttsLayoutMore: "other characters the same way",
ttsLayoutFoot: "A GPT-SoVITS package works too, choose the whole package: the models in GPT_weights and SoVITS_weights are sorted into characters by file name. Files that cannot be sorted are listed for you to assign.",
ttsMenuUploadHint: "upload from the browser",
ttsMenuLocalFiles: "Choose files on this PC…",
ttsMenuLocalHint: "read directly, no upload, faster",
+2
View File
@@ -292,6 +292,7 @@ function createRefreshButton(onRefresh) {
function createScriptDirectorButton(container, owner) {
const btn = createToolButton(SVG.SCRIPT, t('scriptDirectorOpen'));
btn.dataset.tour = 'audio-script';
btn.classList.toggle('active', isScriptDirectorActive());
setScriptDirectorHooks({
owner,
@@ -486,6 +487,7 @@ export async function renderAudioStudio(container, { filter = null, owner = null
onImport = (options = {}) => openTtsImport({ ...options, onDone });
// Settings (storage, pretrained files, packages) live behind the sidebar's footer entry.
const importBtn = createToolButton(SVG.PLUS, t('ttsImportOpenShort'));
importBtn.dataset.tour = 'audio-import';
importBtn.onclick = () => onImport();
importBtn.disabled = !canImport;
if (!canImport) importBtn.title = t('ttsSetupRemote');
+6 -2
View File
@@ -295,6 +295,9 @@ export function ensureTourStyles() {
user-select: none;
letter-spacing: 0.2px;
}
.anomalous-update-guide-tour-banner[hidden] {
display: none;
}
.anomalous-update-guide-tour-banner:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(96, 165, 250, 0.4);
@@ -312,11 +315,12 @@ export function ensureTourStyles() {
function resolveStepTarget(step) {
if (typeof document === 'undefined') return null;
const shown = el => Boolean(el?.isConnected && el.getClientRects().length); // hidden targets are skipped
let el = document.querySelector(step.targetSelector);
if ((!el || !el.isConnected) && step.fallbackSelector) {
if (!shown(el) && step.fallbackSelector) {
el = document.querySelector(step.fallbackSelector);
}
return el && el.isConnected ? el : null;
return shown(el) ? el : null;
}
function computeCardPosition(rect, position, cardWidth = 330, cardHeight = 220) {
+25 -1
View File
@@ -106,10 +106,34 @@ export function renderBatchHero(on) {
local.append(el('span', '', t('ttsHeroLocal')), button('anomalous-tts-link', t('ttsHeroLocalFolder'), on.localFolder),
el('span', 'anomalous-tts-hero-dot', '·'), button('anomalous-tts-link', t('ttsHeroLocalFiles'), on.localFiles));
hero.append(heroIcon, el('div', 'anomalous-tts-hero-title', t('ttsHeroTitle')), needs,
el('div', 'anomalous-tts-hero-desc', t('ttsHeroDesc')), buttons, local, button('anomalous-tts-link', t('ttsChooseAgain'), on.back));
el('div', 'anomalous-tts-hero-desc', t('ttsHeroDesc')), buttons, local, renderFolderLayout(),
button('anomalous-tts-link', t('ttsChooseAgain'), on.back));
return hero;
}
/** A folder that sorts cleanly: one folder per character, named after it. Folded by default. */
function renderFolderLayout() {
const box = el('details', 'anomalous-tts-layout');
box.append(el('summary', '', t('ttsLayoutTitle')));
const tree = el('div', 'anomalous-tts-layout-tree');
const [a, b, clip] = [t('ttsLayoutCharA'), t('ttsLayoutCharB'), t('ttsLayoutClip')];
for (const [depth, name, note] of [
[0, `${t('ttsLayoutRoot')}/`, t('ttsLayoutRootNote')],
[1, `${a}/`, t('ttsLayoutCharNote')],
[2, `${a}-e15.ckpt`, t('ttsCardGpt')],
[2, `${a}_e8_s200.pth`, t('ttsCardSovits')],
[2, `${clip}.wav`, t('ttsLayoutClipNote')],
[2, `${clip}.lab`, t('ttsLayoutLineNote')],
[1, `${b}/`, t('ttsLayoutMore')],
]) {
const file = el('span', 'anomalous-tts-layout-name', name);
file.style.paddingLeft = `${depth * 16}px`;
tree.append(file, el('span', 'anomalous-tts-layout-note', note));
}
box.append(tree, el('p', 'anomalous-tts-layout-foot', t('ttsLayoutFoot')));
return box;
}
/**
* "Add more": one button with a small menu, so the list stays the main thing.
* `on`: `{ files, folder, localFiles, localFolder }`. Returns `{ root, menu }`; the
+1 -1
View File
@@ -72,7 +72,7 @@ export function showUpdateGuide(owner, { force = false, guide = CURRENT_UPDATE_G
tourBanner.hidden = guide.tour === false;
tourBanner.onclick = () => {
closeUpdateGuide(owner, true);
startSpotlightTour(owner);
startSpotlightTour(owner, Array.isArray(guide.tour) ? { steps: guide.tour } : {});
};
const progress = text(dialog, 'p', '', 'anomalous-update-guide-progress');
progress.setAttribute('aria-live', 'polite');
+17 -2
View File
@@ -10,12 +10,25 @@ export const CURRENT_UPDATE_GUIDE = Object.freeze({
]),
});
// Spotlight tour of the audio studio. Steps whose target is not on screen (no characters
// yet, the audio gallery open, sidebar collapsed) are skipped.
export const AUDIO_TOUR_STEPS = Object.freeze([
{ targetSelector: '[data-tour="audio-import"]', icon: '📥', titleKey: 'audioTourImportTitle', bodyKey: 'audioTourImportBody', position: 'bottom' },
{ 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: '[data-tour="audio-script"]', icon: '📜', titleKey: 'audioTourScriptTitle', bodyKey: 'audioTourScriptBody', position: 'bottom' },
{ targetSelector: '#anomalous-gallery-btn', icon: '🎧', titleKey: 'audioTourGalleryTitle', bodyKey: 'audioTourGalleryBody', position: 'bottom' },
{ targetSelector: '.anomalous-audio-sidebar-settings', icon: '⚙️', titleKey: 'audioTourSettingsTitle', bodyKey: 'audioTourSettingsBody', position: 'top' },
]);
// How-to for the audio studio; the "!" button opens it while the audio domain is active.
// Optional fields: titleKey (dialog title), tour: false (no spotlight tour banner).
// Optional fields: titleKey (dialog title), tour: false (no spotlight tour banner) or the
// guide's own spotlight steps (default: the browser's tour).
export const AUDIO_USAGE_GUIDE = Object.freeze({
id: 'audio-studio-usage',
titleKey: 'audioGuideTitle',
tour: false,
tour: AUDIO_TOUR_STEPS,
steps: Object.freeze([
{ id: 'files', icon: '🎙️', titleKey: 'audioGuideFilesTitle', bodyKey: 'audioGuideFilesBody' },
{ id: 'drag', icon: '🧲', titleKey: 'audioGuideDragTitle', bodyKey: 'audioGuideDragBody' },
@@ -29,6 +42,8 @@ export function validateUpdateGuide(guide, locales) {
if (!guide || typeof guide.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,79}$/.test(guide.id)) return false;
if (!Array.isArray(guide.steps) || guide.steps.length < 1 || guide.steps.length > 5) return false;
if (guide.titleKey !== undefined && !['zh', 'en'].every(locale => typeof locales[locale]?.[guide.titleKey] === 'string')) return false;
if (Array.isArray(guide.tour) && !guide.tour.every(step => typeof step?.targetSelector === 'string'
&& ['titleKey', 'bodyKey'].every(field => ['zh', 'en'].every(locale => typeof locales[locale]?.[step[field]] === 'string')))) return false;
const ids = new Set();
return guide.steps.every(step => {
if (!step || typeof step.id !== 'string' || !step.id || ids.has(step.id)) return false;
+42
View File
@@ -485,6 +485,48 @@
color: #475569;
}
/* Suggested folder layout under the batch drop area. */
.anomalous-tts-layout {
width: 100%;
max-width: 540px;
color: #94a3b8;
font-size: 12px;
text-align: left;
}
.anomalous-tts-layout summary {
width: fit-content;
margin: 0 auto;
color: #a5b4fc;
cursor: pointer;
}
.anomalous-tts-layout-tree {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 18px;
margin-top: 10px;
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(0, 0, 0, 0.25);
}
.anomalous-tts-layout-name {
color: #e2e8f0;
font-family: ui-monospace, Consolas, monospace;
white-space: nowrap;
}
.anomalous-tts-layout-note {
color: #64748b;
}
.anomalous-tts-layout-foot {
margin: 8px 2px 0;
line-height: 1.6;
}
/* Dragging files over the window. */
.anomalous-tts-drop-cover {
position: absolute;