diff --git a/CHANGELOG.md b/CHANGELOG.md index 202bd01..1e09dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,13 @@ - **Edit emotions: every clip in the list, and the line follows the clip (编辑情绪:下拉列出全部音频,换音频台词跟着换)**: a box that already held a file listed only the files whose names contain it (the browser filters by the box's text), so a character's other clips seemed missing. Pressing the box now lists all of them, with the current file shown greyed; leaving without a pick keeps it. Picking another clip no longer keeps the old clip's line beside it: the line the node already reads for that clip is filled in, otherwise the line the node finds for the clip (its .txt / .lab or the annotation list) is filled in, with Anomalous_TTS from interface 14 on (older versions leave the box blank and the node reads the line when it speaks); going back to the first clip brings its line back. A blank line box shows, greyed, the line the node uses for its clip. - **Search a character's clips by line (按台词找参考音频)**: Edit emotions has a search box above the clips: type part of a line or a file name and the matches list with their lines, to hear (▶), **Use as main voice** or **Add as emotion** (a new emotion row with that clip and its line, ready for its name). Searching lines needs Anomalous_TTS interface 14; with an older version it searches file names. +## v1.59.1 Beta (GPT-SoVITS v3 / v4 voices) — unreleased + +### 🎙️ Character voices: v3 and v4 models (角色语音支持 v3 / v4 模型) +- **Anomalous_TTS now runs GPT-SoVITS v3 and v4 models** (update it in ComfyUI Manager), and this version of the plugin goes with it: v3 / v4 files are imported instead of refused, and a file whose version cannot be read is refused with a clearer reason. With an Anomalous_TTS too old for v3 / v4, the import says to update it. +- **Each character card shows its GPT-SoVITS model version** (v2, v2ProPlus, v4…) after the name (角色卡片显示模型版本). +- **v3 and v4 need their own official base model and vocoder** (v4 about 0.8 GB, v3 about 1.2 GB). **GPT-SoVITS Settings** lists them, with their own labels, only when one of your characters uses that version, and the import card says what is still missing for the model you dropped in. They download on first use too; a GPT-SoVITS package already on the computer is used as it is. + ## v1.59.0 Beta (Model Downloads, Backup, Share Codes, Node Combos, MCP, Light Theme) — 2026-10-17 ### ⬇️ Download missing models from Model Check (模型检查一键下载缺失模型) diff --git a/tests/audio_engines.mjs b/tests/audio_engines.mjs index 34a4170..475cba9 100644 --- a/tests/audio_engines.mjs +++ b/tests/audio_engines.mjs @@ -7,7 +7,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const calls = []; const character = { name: '阿罗娜/日配', aliases: ['阿罗娜'], language: 'ja', settings: { format: 1, keep: 1 }, - counts: { gpt: 1, sovits: 1, audio: 2 }, + counts: { gpt: 1, sovits: 1, audio: 2 }, version: 'v4', needs: ['v4'], reference: { audio: 'ref/a.wav', text: 'こんにちは', language: 'ja', source: 'auto' }, emotions: { 开心: { audio: 'ref/a.开心.wav', text: '', language: 'ja', source: 'filename' } }, }; @@ -34,6 +34,9 @@ assert.equal(first.error, ''); const arona = first.groups.find(g => g.character === '阿罗娜/日配'); assert.deepEqual(arona.slices.map(s => s.emotion), ['main', '开心']); assert.equal(arona.has_main, true); +assert.equal(arona.model_version, 'v4'); // interface 15; older nodes give '' +assert.deepEqual(arona.needs, ['v4']); +assert.deepEqual(first.groups.find(g => g.character === 'broken').needs, []); assert.equal(first.groups.find(g => g.character === 'broken').has_main, false); // Re-rendering (sidebar click, studio + sidebar together) does not hit the server again. diff --git a/tests/tts_setup_api.mjs b/tests/tts_setup_api.mjs index 1955d98..093536e 100644 --- a/tests/tts_setup_api.mjs +++ b/tests/tts_setup_api.mjs @@ -125,7 +125,9 @@ assert.deepEqual(api.importProblem({ character: '普拉娜', rows: weights, conf ['ttsImportNameTaken', { name: '普拉娜' }]); assert.equal(api.importProblem({ character: 'X', rows: weights }), null); assert.deepEqual(api.importProblem({ character: 'X', rows: [weights[0], { kind: 'sovits', name: 'v4.pth', supported: false, version: 'v4' }] }), - ['ttsImportUnsupportedBlock', { file: 'v4.pth', version: 'v4' }]); + ['ttsImportUnsupportedBlock', { file: 'v4.pth', version: 'v4' }]); // an Anomalous_TTS before v4 support +assert.deepEqual(api.importProblem({ character: 'X', rows: [weights[0], { kind: 'sovits', name: 'odd.pth', supported: false }] }), + ['ttsImportUnknownBlock', { file: 'odd.pth' }]); assert.deepEqual([undefined, 2.9, 3, 10, 10.1].map(api.usableAsReference), [true, false, true, true, false]); // A text file dropped on a clip's text box: a plain file is the line; an annotation file is searched. @@ -163,6 +165,19 @@ assert.deepEqual(api.pretrainedReminder(pre, ['']).items.map(i => i.id), ['huber assert.deepEqual(api.missingForLanguage(pre, 'zh').map(i => i.id), ['hubert', 'roberta']); assert.deepEqual(api.missingForLanguage(pre, 'ja').map(i => i.id), ['hubert']); +// v3 / v4 / v5 files: listed, counted and reminded of only for characters (or dropped files) whose model needs them. +const versions = { pretrained: [...pre.pretrained, + { id: 'v4', needed_for: 'v4', state: 'missing', size: 800, required: true }, + { id: 'v5dev', needed_for: 'v5dev', state: 'downloading', size: 800, required: true }, + { id: 'v5turbo', needed_for: 'v5turbo', state: 'missing', size: 800, required: true }, +] }; +assert.deepEqual(api.shownPretrained(versions).map(i => i.id), ['hubert', 'roberta', 'ja_userdic', 'english', 'v5dev']); +assert.deepEqual(api.shownPretrained(versions, ['v4']).map(i => i.id).slice(-2), ['v4', 'v5dev']); +assert.equal(api.setupSummary(versions).missing, 4); // v4 and v5turbo not counted while nobody uses them +assert.deepEqual(api.pretrainedReminder(versions, ['']).items.map(i => i.id), ['hubert', 'roberta', 'ja_userdic']); +assert.deepEqual(api.pretrainedReminder(versions, ['ja'], [], ['v4']).items.map(i => i.id), ['hubert', 'ja_userdic', 'v4']); +assert.deepEqual(api.missingForLanguage(versions, '', ['v4']).map(i => i.id), ['hubert', 'v4']); + // Row tones and section marks in the import form. assert.equal(api.rowState({ progress: 0.42 }).tone, 'busy'); assert.deepEqual(api.rowState({ progress: 0.42 }).params, { percent: 42 }); diff --git a/web/modules/audio_engines.js b/web/modules/audio_engines.js index b2aaed4..d030b70 100644 --- a/web/modules/audio_engines.js +++ b/web/modules/audio_engines.js @@ -59,6 +59,8 @@ export function gptSovitsGroups(payload) { group: voiceGroupKey(name), character: name, language: item.language || '', + model_version: item.version || '', // GPT-SoVITS v2 / v4 / v5dev… (interface 15) + needs: Array.isArray(item.needs) ? item.needs : [], // pretrained ids its model needs aliases: Array.isArray(item.aliases) ? item.aliases : [], error: item.error || item.settings_error || '', has_main: Boolean(item.reference?.audio) && !item.error, diff --git a/web/modules/locales.js b/web/modules/locales.js index 987acc1..50d68ae 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -2248,6 +2248,11 @@ export const i18n = { ttsNeededFor_en: "英语", ttsNeededFor_ja: "日语", ttsNeededFor_v2pro: "v2Pro 模型", + ttsNeededFor_v3: "v3 模型", + ttsNeededFor_v4: "v4 模型", + ttsNeededFor_v5dev: "v5dev 模型", + ttsNeededFor_v5turbo: "v5turbo 模型", + ttsModelVersion: "GPT-SoVITS {version} 模型", ttsPretrainedOk: "已就绪", ttsPretrainedMissing: "缺少", ttsPretrainedOptional: "缺少(可选)", @@ -2356,7 +2361,8 @@ export const i18n = { ttsCheckGptDone: "GPT 模型", ttsCheckSovits: "放 SoVITS 模型(.pth,在整合包的 SoVITS_weights 文件夹里)", ttsCheckSovitsDone: "SoVITS 模型", - ttsCheckSovitsUnsupported: "这个 SoVITS 是 {version} 模型,暂不支持,换一个 v1 / v2 / v2Pro 的", + ttsCheckSovitsUnsupported: "这个 SoVITS 是 {version} 模型,装的 Anomalous_TTS 太旧,更新后就能用", + ttsCheckSovitsUnknown: "认不出这个 SoVITS 的版本,换一个 GPT-SoVITS 训练出的 .pth", ttsCheckAudio: "放几段参考音频(3~10 秒、说得清楚的)", ttsCheckAudioDone: "参考音频 {count} 段", ttsCheckMain: "在下面的音频里点 ☆,选一段当主声音", @@ -2402,7 +2408,8 @@ export const i18n = { ttsImportNameTakenVariant: "“{name}”已经有这些版本:{variants}。请换一个名字。", ttsImportSwitchToAdd: "改成添加到这个角色", ttsImportKeptBest: "{kind}:用了轮数最大的 {file}(另外 {count} 个没用)。", - ttsImportUnsupportedBlock: "{file} 是 {version} 模型,暂不支持。请换一个 v1、v2、v2Pro 或 v2ProPlus 的 SoVITS 权重。", + ttsImportUnsupportedBlock: "{file} 是 {version} 模型,现在装的 Anomalous_TTS 还不支持。在 ComfyUI Manager 里更新 Anomalous_TTS 后再导入。", + ttsImportUnknownBlock: "认不出 {file} 是哪个版本的 SoVITS 权重。请换一个 GPT-SoVITS 训练出的 .pth。", ttsImportOutOfRange: "不在 3~10 秒,节点用不了,不会导入", ttsTextSource_txt: "台词来自同名 .txt", ttsTextSource_lab: "台词来自同名 .lab", @@ -4824,6 +4831,11 @@ export const i18n = { ttsNeededFor_en: "English", ttsNeededFor_ja: "Japanese", ttsNeededFor_v2pro: "v2Pro models", + ttsNeededFor_v3: "v3 models", + ttsNeededFor_v4: "v4 models", + ttsNeededFor_v5dev: "v5dev models", + ttsNeededFor_v5turbo: "v5turbo models", + ttsModelVersion: "GPT-SoVITS {version} model", ttsPretrainedOk: "ready", ttsPretrainedMissing: "missing", ttsPretrainedOptional: "missing (optional)", @@ -4932,7 +4944,8 @@ export const i18n = { ttsCheckGptDone: "GPT model", ttsCheckSovits: "Add the SoVITS model (.pth, in the package's SoVITS_weights folder)", ttsCheckSovitsDone: "SoVITS model", - ttsCheckSovitsUnsupported: "This SoVITS is a {version} model, not supported yet; use a v1 / v2 / v2Pro one", + ttsCheckSovitsUnsupported: "This SoVITS is a {version} model; the installed Anomalous_TTS is too old for it, update it", + ttsCheckSovitsUnknown: "This SoVITS file's version is unknown; use a .pth trained with GPT-SoVITS", ttsCheckAudio: "Add a few reference clips (clear, 3–10 s)", ttsCheckAudioDone: "{count} reference clips", ttsCheckMain: "Press ☆ on a clip below to make it the main voice", @@ -4978,7 +4991,8 @@ export const i18n = { ttsImportNameTakenVariant: "\"{name}\" already has these versions: {variants}. Please pick another name.", ttsImportSwitchToAdd: "Add to that character instead", ttsImportKeptBest: "{kind}: using the latest epoch, {file} ({count} {count|other|others} left out).", - ttsImportUnsupportedBlock: "{file} is a {version} model, which is not supported yet. Use a v1, v2, v2Pro or v2ProPlus SoVITS weight.", + ttsImportUnsupportedBlock: "{file} is a {version} model, which the installed Anomalous_TTS cannot use yet. Update Anomalous_TTS in ComfyUI Manager, then import it.", + ttsImportUnknownBlock: "The version of the SoVITS weight {file} is unknown. Use a .pth trained with GPT-SoVITS.", ttsImportOutOfRange: "Not 3–10 s long: the node cannot use it, so it is left out", ttsTextSource_txt: "Line from the .txt of the same name", ttsTextSource_lab: "Line from the .lab of the same name", diff --git a/web/modules/tts_import_groups.js b/web/modules/tts_import_groups.js index 4ec83fb..f688cc3 100644 --- a/web/modules/tts_import_groups.js +++ b/web/modules/tts_import_groups.js @@ -345,7 +345,8 @@ export function draftChecklist({ target = null, name = '', rows: all, main = nul return [ { id: 'gpt', done: Boolean(weight('gpt')), key: weight('gpt') ? 'ttsCheckGptDone' : 'ttsCheckGpt', params: {} }, { id: 'sovits', done: Boolean(sovits) && sovits.supported !== false, - key: !sovits ? 'ttsCheckSovits' : sovits.supported === false ? 'ttsCheckSovitsUnsupported' : 'ttsCheckSovitsDone', params: { version: sovits?.version || '?' } }, + key: !sovits ? 'ttsCheckSovits' : sovits.supported !== false ? 'ttsCheckSovitsDone' : sovits.version ? 'ttsCheckSovitsUnsupported' : 'ttsCheckSovitsUnknown', + params: { version: sovits?.version || '?' } }, { id: 'audio', done: clips.length > 0, key: clips.length ? 'ttsCheckAudioDone' : skipped ? 'ttsCheckAudioNoneUsable' : 'ttsCheckAudio', params: { count: clips.length } }, { id: 'main', done: Boolean(main), key: main ? 'ttsCheckMainDone' : 'ttsCheckMain', params: { file: main || '' } }, diff --git a/web/modules/tts_setup_api.js b/web/modules/tts_setup_api.js index 76f6db9..3b850fe 100644 --- a/web/modules/tts_setup_api.js +++ b/web/modules/tts_setup_api.js @@ -182,7 +182,10 @@ export function importProblem({ target = null, character = '', rows, conflict = const clash = rows.find(row => row.existing === 'different'); if (clash) return ['ttsImportExistingDifferent', { file: clash.name }]; const unsupported = rows.find(row => row.kind === 'sovits' && row.supported === false); - if (unsupported) return ['ttsImportUnsupportedBlock', { file: unsupported.name, version: unsupported.version || '?' }]; + if (unsupported) { + return unsupported.version ? ['ttsImportUnsupportedBlock', { file: unsupported.name, version: unsupported.version }] + : ['ttsImportUnknownBlock', { file: unsupported.name }]; + } const seen = new Set(); for (const row of rows) { if (row.kind !== 'audio' || !row.emotion) continue; @@ -214,27 +217,48 @@ export function textFromFile(content, clipName) { return table.get(name) ?? table.get(`${stem.split('.')[0]}${ext}`) ?? null; } +// `needed_for` of the pretrained items only v3 / v4 / v5 models use (their base model and vocoder). +const MODEL_VERSIONS = new Set(['v3', 'v4', 'v5dev', 'v5turbo']); + +/** + * Pretrained items worth listing: the shared ones, and a model version's only when `needs` + * (ids from the characters' or the dropped files' SoVITS models) asks for it, or while it + * downloads or failed. A library without v5 characters never sees the v5 files. + */ +export function shownPretrained(status, needs = []) { + const wanted = new Set(needs); + return (status?.pretrained || []).filter(item => !MODEL_VERSIONS.has(item.needed_for) || wanted.has(item.id) + || ['queued', 'downloading', 'error'].includes(item.state)); +} + /** * Pretrained files worth a reminder dot: missing ones that the characters in the * studio will actually load (the shared ones, English for mixed-in words, v2Pro's * speaker model, plus their languages; a character with no known language counts - * as every language). No characters, no reminder: an empty studio is only about - * importing. `due` is false once every such file was dismissed with "don't remind - * me"; a newly needed one (say, the first Chinese character) brings the dot back. + * as every language; a v3 / v4 / v5 model's own files when `needs` lists them). No + * characters, no reminder: an empty studio is only about importing. `due` is false + * once every such file was dismissed with "don't remind me"; a newly needed one + * (say, the first Chinese character) brings the dot back. */ -export function pretrainedReminder(status, languages, dismissed = []) { +export function pretrainedReminder(status, languages, dismissed = [], needs = []) { const langs = new Set(languages || []); if (!langs.size) return { items: [], size: 0, due: false }; - const needed = (item) => ['all', 'en', 'v2pro'].includes(item.needed_for) || langs.has(item.needed_for) || langs.has(''); + const wanted = new Set(needs); + const needed = (item) => (MODEL_VERSIONS.has(item.needed_for) ? wanted.has(item.id) + : ['all', 'en', 'v2pro'].includes(item.needed_for) || langs.has(item.needed_for) || langs.has('')); const items = (status?.pretrained || []).filter(item => (item.state === 'missing' || item.state === 'error') && needed(item)); const seen = new Set(dismissed); return { items, size: items.reduce((sum, item) => sum + (item.size || 0), 0), due: items.some(item => !seen.has(item.id)) }; } -/** Required pretrained files still missing for a character in `language` ('' = not chosen yet). */ -export function missingForLanguage(status, language) { +/** + * Required pretrained files still missing for a character in `language` ('' = not chosen yet) + * whose SoVITS model needs the pretrained ids `needs` (a v4 model: its base model and vocoder). + */ +export function missingForLanguage(status, language, needs = []) { + const wanted = new Set(needs); return (status?.pretrained || []).filter(item => item.required && item.state !== 'ok' - && (item.needed_for === 'all' || (language && item.needed_for === language))); + && (item.needed_for === 'all' || (language && item.needed_for === language) || wanted.has(item.id))); } /** @@ -253,10 +277,10 @@ export function rowState({ error = '', uploaded = false, queued = true, progress return { tone: 'ready', key: 'ttsImportRowReady', params: {} }; } -/** Summary for the setup card: what still needs doing. */ -export function setupSummary(status) { +/** Summary for the setup card: what still needs doing (`needs`: see shownPretrained). */ +export function setupSummary(status, needs = []) { const characters = (status?.libraries || []).reduce((sum, lib) => sum + (lib.characters || 0), 0); - const missing = (status?.pretrained || []).filter(item => item.state !== 'ok'); + const missing = shownPretrained(status, needs).filter(item => item.state !== 'ok'); const packages = Object.values(status?.dependencies || {}).reduce((sum, dep) => sum + (dep.missing?.length || 0), 0); const downloading = missing.some(item => item.state === 'queued' || item.state === 'downloading'); const requiredMissing = missing.filter(item => item.required).length; diff --git a/web/modules/ui_audio_sidebar.js b/web/modules/ui_audio_sidebar.js index 39427b7..d9af77d 100644 --- a/web/modules/ui_audio_sidebar.js +++ b/web/modules/ui_audio_sidebar.js @@ -268,8 +268,8 @@ function renderCharacters(owner, container, groups, { canImport, term }) { } /** Bottom entry for GPT-SoVITS settings; the dot says when something wants a look. */ -function renderSettingsEntry(owner, status, languages) { - const attention = setupAttention(status, languages); +function renderSettingsEntry(owner, status, languages, needs) { + const attention = setupAttention(status, languages, needs); const entry = el('button', `anomalous-audio-sidebar-settings${attention.level ? ` is-${attention.level}` : ''}`); entry.type = 'button'; entry.title = attention.title; @@ -278,7 +278,7 @@ function renderSettingsEntry(owner, status, languages) { if (attention.level) label.append(el('span', 'anomalous-audio-sidebar-settings-dot')); entry.append(label); if (attention.busy) entry.append(el('span', 'anomalous-audio-sidebar-settings-busy', attention.busy)); - entry.onclick = () => openTtsSetup({ languages, onChanged: (options) => redraw(owner, options) }); + entry.onclick = () => openTtsSetup({ languages, needs, onChanged: (options) => redraw(owner, options) }); return entry; } @@ -327,7 +327,8 @@ export async function renderAudioSidebar(owner) { if (status) { const languages = groups.map(group => group.language); - owner.sidebar.append(renderSettingsEntry(owner, status, languages)); + const needs = groups.flatMap(group => group.needs); + owner.sidebar.append(renderSettingsEntry(owner, status, languages, needs)); if (canImport) { bindTtsFileDrop(listContainer, (files, target) => openTtsImport({ files, target, onDone: () => redraw(owner) })); } diff --git a/web/modules/ui_audio_studio.js b/web/modules/ui_audio_studio.js index d91eff4..cbdccf0 100644 --- a/web/modules/ui_audio_studio.js +++ b/web/modules/ui_audio_studio.js @@ -220,6 +220,13 @@ function renderCharacterCard(group, owner, { onChanged, canImport = false } = {} name.append(' ', version); } name.title = group.character; + if (group.model_version) { + const model = document.createElement('span'); + model.className = 'anomalous-character-voice-model'; + model.textContent = group.model_version; + model.title = t('ttsModelVersion', { version: group.model_version }); + name.append(model); + } const sub = document.createElement('span'); sub.className = 'anomalous-character-voice-hint'; diff --git a/web/modules/ui_tts_import_draft.js b/web/modules/ui_tts_import_draft.js index 5d97809..c334f86 100644 --- a/web/modules/ui_tts_import_draft.js +++ b/web/modules/ui_tts_import_draft.js @@ -348,8 +348,11 @@ export function renderDraftCard(draft, view, ctx, { solo = false } = {}) { extras.append(language); } body.append(extras); - const missing = missingForLanguage(ctx.status, draft.language || draft.detectedLanguage || ''); - if (missing.length && !draft.target) { + // A new character's language decides the language files; any draft's SoVITS model its own files (v4: base + vocoder). + const needs = draft.rows.flatMap(row => (row.kind === 'sovits' && row.info?.needs) || []); + const missing = missingForLanguage(ctx.status, draft.target ? '' : draft.language || draft.detectedLanguage || '', needs) + .filter(item => !draft.target || needs.includes(item.id)); + if (missing.length) { body.append(el('div', 'anomalous-tts-card-note', t('ttsImportPretrainedNote', { files: missing.map(item => item.label).join('、'), size: formatSize(missing.reduce((sum, item) => sum + (item.size || 0), 0)), diff --git a/web/modules/ui_tts_setup.js b/web/modules/ui_tts_setup.js index 4182294..8bd03c0 100644 --- a/web/modules/ui_tts_setup.js +++ b/web/modules/ui_tts_setup.js @@ -2,7 +2,7 @@ import { t } from './interface_settings.js'; import { createViewScope } from './ui_lifecycle.js'; import { anomalousAlert } from './ui_dialog.js'; import { loadGptSovitsStatus } from './audio_engines.js'; -import { formatSize, pretrainedReminder, setupSummary, startPretrainedDownload } from './tts_setup_api.js'; +import { formatSize, pretrainedReminder, setupSummary, shownPretrained, startPretrainedDownload } from './tts_setup_api.js'; /** * GPT-SoVITS settings, opened from the audio sidebar's footer: the storage place, other @@ -115,11 +115,12 @@ function pretrainedState(item) { return state; } -function renderPretrained(status, local, onChanged) { +function renderPretrained(status, local, onChanged, needs) { const group = el('div', 'anomalous-tts-setup-group'); group.append(el('div', 'anomalous-tts-section', t('ttsSetupPretrained'))); - const missing = status.pretrained.filter(item => item.state === 'missing' || item.state === 'error'); - for (const item of status.pretrained) { + const items = shownPretrained(status, needs); + const missing = items.filter(item => item.state === 'missing' || item.state === 'error'); + for (const item of items) { const row = el('div', 'anomalous-tts-setup-row'); const label = el('span', 'anomalous-tts-setup-path', item.label); label.title = item.path || item.error || ''; @@ -190,9 +191,9 @@ function renderReminder(reminder, local, onChanged, onDismiss) { } /** What the sidebar entry shows: `level` is 'blocked' (packages), 'reminder' (pretrained) or ''. */ -export function setupAttention(status, languages = []) { - const summary = setupSummary(status); - const reminder = pretrainedReminder(status, languages, storedDismissed()); +export function setupAttention(status, languages = [], needs = []) { + const summary = setupSummary(status, needs); + const reminder = pretrainedReminder(status, languages, storedDismissed(), needs); return { level: summary.packages ? 'blocked' : reminder.due ? 'reminder' : '', title: t(summary.packages ? 'ttsSetupDotPackages' : reminder.due ? 'ttsSetupDotPretrained' : 'ttsSetupTitle'), @@ -203,13 +204,14 @@ export function setupAttention(status, languages = []) { /** * The card for one status payload. `onChanged()` redraws the studio (a download * changes what is ready); `onDismissed()` runs after "don't remind me"; - * `languages` are the languages of the characters in the studio (for the reminder). + * `languages` are the languages of the characters in the studio (for the reminder), `needs` + * the pretrained ids their SoVITS models need (v3 / v4 / v5 files are listed only then). */ export function renderTtsSetup(status, options) { - const { onChanged, onDismissed, languages = [] } = options; - const summary = setupSummary(status); + const { onChanged, onDismissed, languages = [], needs = [] } = options; + const summary = setupSummary(status, needs); const local = status.local !== false; - const reminder = pretrainedReminder(status, languages, storedDismissed()); + const reminder = pretrainedReminder(status, languages, storedDismissed(), needs); const card = el('section', `anomalous-tts-setup${summary.packages ? ' is-blocked' : ''}`); if (!local) card.append(el('div', 'anomalous-tts-setup-notice', t('ttsSetupRemote'))); @@ -222,7 +224,7 @@ export function renderTtsSetup(status, options) { } const packages = renderPackages(status); if (packages) card.append(packages); - card.append(renderStorage(status), renderPretrained(status, local, onChanged)); + card.append(renderStorage(status), renderPretrained(status, local, onChanged, needs)); const settingsFile = renderSettingsFile(status); if (settingsFile) card.append(settingsFile); if (summary.downloading) pollWhileBusy(card, options); @@ -235,7 +237,7 @@ let activeScope = null; * The settings dialog. `onChanged()` redraws the studio and sidebar after a change; * the dialog then reloads the status and redraws itself. */ -export async function openTtsSetup({ onChanged, languages = [] }) { +export async function openTtsSetup({ onChanged, languages = [], needs = [] }) { activeScope?.dispose(); const scope = createViewScope(); activeScope = scope; @@ -273,6 +275,7 @@ export async function openTtsSetup({ onChanged, languages = [] }) { const options = { languages, + needs, onDismissed: () => onChanged?.({ sidebarOnly: true }), onChanged: async () => { onChanged?.(); @@ -281,7 +284,7 @@ export async function openTtsSetup({ onChanged, languages = [] }) { }, }; const draw = () => { - subtitle.textContent = summaryText(status, setupSummary(status)); + subtitle.textContent = summaryText(status, setupSummary(status, needs)); body.replaceChildren(renderTtsSetup(status, options)); }; draw(); diff --git a/web/styles.css b/web/styles.css index 6d01d01..eb0c2a7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -9,7 +9,7 @@ @import url("./styles/08-theme-gallery-overrides.css?v=20261010-wfd-1"); @import url("./styles/09-prompt-drawers.css?v=20261007-theme-2"); @import url("./styles/10-model-sources.css?v=20261007-theme-2"); -@import url("./styles/11-audio-studio.css?v=20261010-search-1"); +@import url("./styles/11-audio-studio.css?v=20261017-tts-versions"); @import url("./styles/12-tts-setup.css?v=20261007-theme-2"); @import url("./styles/13-script-director.css?v=20261007-theme-2"); @import url("./styles/14-version-manager.css?v=20261007-theme-2"); diff --git a/web/styles/11-audio-studio.css b/web/styles/11-audio-studio.css index 6094259..b5f95d6 100644 --- a/web/styles/11-audio-studio.css +++ b/web/styles/11-audio-studio.css @@ -305,6 +305,18 @@ color: var(--amb-link); } +/* GPT-SoVITS model version (v2, v4, v5dev…) after the name */ +.anomalous-character-voice-model { + margin-left: 6px; + padding: 1px 6px; + border: 1px solid var(--amb-border); + border-radius: 999px; + font-size: 10px; + font-weight: 600; + color: var(--amb-text-muted); + vertical-align: 1px; +} + .anomalous-character-voice-hint { overflow: hidden; font-size: 11px;