diff --git a/CHANGELOG.md b/CHANGELOG.md index ede93eb..498e0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//`. **↻** 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/`. diff --git a/docs/architecture/update-guide.md b/docs/architecture/update-guide.md index cbfa51f..9f67826 100644 --- a/docs/architecture/update-guide.md +++ b/docs/architecture/update-guide.md @@ -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`,两边要一致。 ## 生命周期与记录 diff --git a/web/modules/locales.js b/web/modules/locales.js index 135fab0..78e2cfa 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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//, 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", diff --git a/web/modules/ui_audio_studio.js b/web/modules/ui_audio_studio.js index 00c9175..7865aab 100644 --- a/web/modules/ui_audio_studio.js +++ b/web/modules/ui_audio_studio.js @@ -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'); diff --git a/web/modules/ui_spotlight_tour.js b/web/modules/ui_spotlight_tour.js index f070f8c..5c48d4b 100644 --- a/web/modules/ui_spotlight_tour.js +++ b/web/modules/ui_spotlight_tour.js @@ -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) { diff --git a/web/modules/ui_tts_import_screens.js b/web/modules/ui_tts_import_screens.js index d366997..a90c153 100644 --- a/web/modules/ui_tts_import_screens.js +++ b/web/modules/ui_tts_import_screens.js @@ -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 diff --git a/web/modules/ui_update_guide.js b/web/modules/ui_update_guide.js index 88709d3..021f8cc 100644 --- a/web/modules/ui_update_guide.js +++ b/web/modules/ui_update_guide.js @@ -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'); diff --git a/web/modules/update_guide_data.js b/web/modules/update_guide_data.js index e5925f6..62de101 100644 --- a/web/modules/update_guide_data.js +++ b/web/modules/update_guide_data.js @@ -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; diff --git a/web/styles/12-tts-setup.css b/web/styles/12-tts-setup.css index 4e87e74..6fdd075 100644 --- a/web/styles/12-tts-setup.css +++ b/web/styles/12-tts-setup.css @@ -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;