feat(audio): engine switch with runtime detection; GPT-SoVITS via Anomalous_TTS

- audio_engines.js: F5-TTS and GPT-SoVITS (the separate Anomalous_TTS node
  pack), detected through /object_info/<node class>. Both engines' voices are
  normalised into one voice-group shape so cards, sidebar and Script Director
  share one implementation.
- The audio page shows an engine switch; a missing engine stays listed as
  "not installed" with install steps (no popups). The F5-TTS library still
  works without the F5-TTS node; GPT-SoVITS is never scanned when missing.
- GPT-SoVITS cards come only from the node's HTTP contract: preview reference
  clips, drag a character onto AnomalousTTS_CharacterSpeech, and edit the
  emotion references ("Edit emotions"), saved through /anomalous_tts/settings
  with unknown fields preserved and the node's validation messages shown.
- Drop targets and the Script Director are per engine: voices of one engine
  are refused on the other engine's node with the reason.
- Audio gallery reads the character of GPT-SoVITS generations.
- Usage guide (in-app and docs/guides/audio-studio.md) covers both engines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
This commit is contained in:
DemonGatanjieu
2026-09-24 13:13:49 +00:00
co-authored by Claude Opus 5.5
parent 9f45fef85b
commit 634b2384cf
13 changed files with 1071 additions and 114 deletions
+11
View File
@@ -128,6 +128,17 @@ DOM or live LiteGraph state.
- `ui_domain_switcher.js` owns the visual/audio domain toggle and its stored choice;
`browser.switchAudioTab` is the single entry for audio navigation (header tabs,
sidebar entries, domain switch) and `hideAllPanels` stops audio playback.
- `audio_engines.js` lists the speech engines the audio page can manage (F5-TTS,
GPT-SoVITS via the separate Anomalous_TTS node pack) and detects them at runtime
through `/object_info/<node class>`. Every engine's voices are normalised into one
voice-group shape (`node_value` = what the node's voice widget takes), so cards,
sidebar and Script Director share one implementation. A missing engine stays in
the switch marked "not installed" with install steps; nothing else depends on it.
GPT-SoVITS data comes only from the node's HTTP contract
(`/anomalous_tts/characters`, `/audio`, `/settings`; see the project doc
`anomalous-tts-interface.md`); Anomalous never reads or writes its model folders.
`ui_audio_tts_editor.js` edits a character's emotion references through that API,
keeping settings fields it does not know.
- `audio_node_targets.js` is the single table of canvas nodes the audio studio
writes into and what each takes: F5-TTS (`F5TTSAudio`, `F5TTSAudioAdvanced`,
`F5TTSAudioFromModel`) takes a whole character (main voice in `sample`,
+1
View File
@@ -9,6 +9,7 @@
- **Arona multi-voice template (阿罗娜多情绪模板)**: loads a ready-made F5-TTS workflow that ends in Save Audio.
- **Clear drag rules (拖动规则明确)**: drag a character card's header onto an F5-TTS node (sets the main voice), or one row onto a Load Audio node (uses that exact clip). The hint says what releasing will do; unsupported nodes are refused with the reason instead of being guessed. A single emotion clip is never written into F5-TTS's `sample`, which would break `{emotion}` tags.
- **Usage guide (使用说明)**: in the audio page, the header **!** opens a five-step how-to; the full guide is `docs/guides/audio-studio.md`.
- **Engine switch: F5-TTS / GPT-SoVITS (引擎切换)**: the audio page now manages voices per engine. Installed engines are detected at runtime; a missing one stays visible as "not installed" with install steps instead of disappearing, and nothing else depends on it. GPT-SoVITS characters come from the separate Anomalous_TTS node pack: preview reference clips, pick a reference clip per emotion ("Edit emotions", saved through the node's API), drag a character onto the node, and push scripts with the Script Director.
### Fixes
- All-in-one loader recipes and cross-node prompt injection use one verified adapter table; prompt text never crosses between positive and negative.
+2 -1
View File
@@ -10,7 +10,8 @@ import os
MAX_TAG_BYTES = 16 * 1024 * 1024
MAX_OGG_SCAN_BYTES = 32 * 1024 * 1024
SPEECH_INPUTS = ("speech", "text", "gen_text")
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt")
# "character": Anomalous_TTS (GPT-SoVITS) names the voice by character instead of a file.
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt", "character")
def _parse_vorbis_comment(data, offset=0):
+51 -2
View File
@@ -1,6 +1,25 @@
# 音频工作台使用说明
适用于 ComfyUI-F5-TTS。插件里也能看简版:进入音频页后点右上角的 **!**。
插件里也能看简版:进入音频页后,点右上角的 **!**。
---
## 0. 先选引擎
音频页顶部有一个切换,可以选 **F5-TTS** 或 **GPT-SoVITS**。下面的角色、拖动规则和剧本台都会跟着变。
切换下面会写“适用节点:…”,告诉你这一页的东西能放到哪些节点上。
| 引擎 | 需要的节点包 | 适合什么 |
|---|---|---|
| F5-TTS | ComfyUI-F5-TTS | 一段 5~10 秒的录音就能模仿声音,不用训练 |
| GPT-SoVITS | Anomalous_TTS | 用你训练好的 GPT-SoVITS 角色模型,音色更还原;支持多角色对话和停顿 |
- **没装的引擎不会藏起来**,会标“未安装”,点进去能看到它能做什么、怎么装。页面不会弹窗打扰你。
- **F5-TTS 没装时**,声音库照样能管理和试听,只是生成不了。
- **装好节点包后**,重启 ComfyUI,再点“刷新音色”就能看到。
- **任何引擎没装,插件的其他功能都不受影响。**
下面第 1~5 节讲 F5-TTS,第 6 节讲 GPT-SoVITS。
---
@@ -89,7 +108,35 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行
- 每条会显示台词、声音和种子,可以试听、按台词搜索、删除。
- 只在预览里出现、还没保存的临时结果,列在“未保存的预览”里,点 **保存** 就能存到 `output/audio/`。
## 5. 常见问题
## 6. GPT-SoVITS(Anomalous TTS)
### 角色放在哪
- 一个角色就是一个文件夹,里面至少有一个 `.ckpt`(GPT 权重)和一个 `.pth`(SoVITS 权重)。
- 文件夹放在 `ComfyUI/models/gpt_sovits/` 下,或者在 `extra_model_paths.yaml` 里加一个 `gpt_sovits` 目录,直接用你原来的模型文件夹,不用复制。
- 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。
### 编辑情绪
点角色卡片上的 **编辑情绪**:
1. **主声音 `{main}`**:选一条 3~10 秒的参考音频。留空的话,节点会自己挑一条。
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。输入框可以直接打字搜索文件名。
3. **参考台词**:可以不填,节点会自己去找同名 `.txt` 或 GPT-SoVITS 的标注文件。
4. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。选了不存在的文件时,会提示原因,什么都不会改。
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。
### 用起来
- **拖卡片标题栏** → 放到 **角色语音 (GPT-SoVITS)** 节点:节点的“角色”设为这个角色。
- **剧本台**:切到 GPT-SoVITS 后,剧本台列出的就是这些角色。推送时,节点的“角色”和“台词”会一起填好。
- 台词里还可以直接写:
- `[普拉娜]`:换人说。要写角色名或别名。
- `[pause:1s]`:停顿。
- GPT-SoVITS 的参考音频在模型文件夹里,不在 `input/` 里,所以不能拖到 Load Audio。
## 7. 常见问题
| 现象 | 原因和解决 |
|---|---|
@@ -98,3 +145,5 @@ Load Audio 正好相反:它只用一条具体的音频,所以要拖某一行
| `{标签}` 按钮是灰的 | 这条情绪文件和主声音不在一起,或者格式不一样,F5-TTS 找不到它 |
| 标题栏拖不动 | 这个角色没有主声音 |
| 拖到别的语音节点被拒绝 | 这个节点还不在支持名单里。可以先放 Load Audio,再把它的输出接过去 |
| 提示“这是 GPT-SoVITS 的声音,F5-TTS 节点用不了” | 两种引擎的声音不能混用。切到对应的引擎,拖到它自己的节点上 |
| GPT-SoVITS 角色卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“编辑情绪”手动选一条 |
+236
View File
@@ -0,0 +1,236 @@
import { AUDIO_NODE_TARGETS } from './audio_node_targets.js';
/**
* Speech engines the audio studio can manage. Anomalous never bundles an engine:
* each one is a separate ComfyUI node pack, detected at runtime. A missing engine
* only changes what the audio page shows; nothing else depends on it.
*
* Every engine's voices are normalised into the same "voice group" shape so the
* studio cards, sidebar and Script Director share one implementation:
* { engine, group, character, folder, has_main, node_value, total_slices,
* slices: [{ engine, emotion, is_main, text, audio_url, syntax_tag, tag_usable, relative_path? }] }
* `node_value` is what goes into the node's voice widget (F5-TTS: the main voice
* file; GPT-SoVITS: the character name).
*/
export const AUDIO_ENGINES = Object.freeze([
Object.freeze({
id: 'f5',
label: 'F5-TTS',
pack: 'ComfyUI-F5-TTS',
probeNode: 'F5TTSAudio',
repoUrl: 'https://github.com/niknah/ComfyUI-F5-TTS',
managerSearch: 'F5-TTS',
descKey: 'audioEngineF5Desc',
}),
Object.freeze({
id: 'gpt_sovits',
label: 'GPT-SoVITS',
pack: 'Anomalous_TTS',
probeNode: 'AnomalousTTS_CharacterSpeech',
// Not published yet: the install card explains instead of linking.
repoUrl: '',
managerSearch: 'Anomalous TTS',
descKey: 'audioEngineGptSovitsDesc',
}),
]);
const STORAGE_KEY = 'anomalous_audio_engine';
export function engineById(id) {
return AUDIO_ENGINES.find(engine => engine.id === id) || null;
}
/** Node labels an engine writes into, for "works with …" lines. */
export function engineTargetLabels(id) {
return AUDIO_NODE_TARGETS.filter(target => target.engine === id && target.takes === 'character')
.flatMap(target => target.typeLabels || target.types);
}
export function getStoredEngine() {
try { return localStorage.getItem(STORAGE_KEY) || null; } catch (_) { return null; }
}
export function setStoredEngine(id) {
try { localStorage.setItem(STORAGE_KEY, id); } catch (_) { /* session-only choice */ }
}
/** Stored choice if it exists, else the first installed engine, else the first engine. */
export function pickEngine(statuses, stored = getStoredEngine()) {
if (stored && engineById(stored)) return stored;
return AUDIO_ENGINES.find(engine => statuses?.[engine.id]?.installed)?.id || AUDIO_ENGINES[0].id;
}
// ---------- normalisation ----------
function f5Groups(characters) {
return (characters || []).map(group => ({
...group,
engine: 'f5',
node_value: group.main_relative_path || null,
slices: (group.slices || []).map(slice => ({ ...slice, engine: 'f5' })),
}));
}
export function ttsAudioUrl(name, path) {
return `/anomalous_tts/audio?character=${encodeURIComponent(name)}&path=${encodeURIComponent(path)}`;
}
function ttsSlice(name, emotion, ref, isMain) {
return {
engine: 'gpt_sovits',
id: `${name}:${emotion}`,
character: name,
emotion,
is_main: isMain,
filename: String(ref.audio || '').split('/').pop(),
relative_path: ref.audio || '',
text: ref.text || '',
synthesis_text: ref.text || '',
language: ref.language || '',
source: ref.source || '',
syntax_tag: `{${emotion}}`,
tag_usable: true,
audio_url: ref.audio ? ttsAudioUrl(name, ref.audio) : '',
};
}
/** Anomalous_TTS `GET /anomalous_tts/characters` → voice groups (docs: claude/anomalous-tts-interface.md). */
export function gptSovitsGroups(payload) {
return (payload?.characters || []).filter(item => item && typeof item.name === 'string').map(item => {
const name = item.name;
const slices = [];
if (item.reference?.audio) slices.push(ttsSlice(name, 'main', item.reference, true));
for (const [emotion, ref] of Object.entries(item.emotions || {})) {
if (emotion !== 'main' && ref?.audio) slices.push(ttsSlice(name, emotion, ref, false));
}
return {
engine: 'gpt_sovits',
group: `gpt_sovits:${name}`,
character: name,
folder: 'GPT-SoVITS',
language: item.language || '',
aliases: Array.isArray(item.aliases) ? item.aliases : [],
error: item.error || item.settings_error || '',
has_main: Boolean(item.reference?.audio) && !item.error,
node_value: name,
total_slices: slices.length,
slices,
raw: item,
};
}).sort((a, b) => (b.has_main - a.has_main) || a.character.localeCompare(b.character));
}
// ---------- detection + loading ----------
async function getJson(url) {
const resp = await fetch(url);
const data = await resp.json().catch(() => null);
return { ok: resp.ok, status: resp.status, data };
}
/** Installed = ComfyUI knows the engine's node class (`/object_info/<class>` is `{}` otherwise). */
async function isNodeInstalled(nodeClass) {
try {
const { ok, data } = await getJson(`/object_info/${encodeURIComponent(nodeClass)}`);
return ok && Boolean(data && data[nodeClass]);
} catch (_) {
return false;
}
}
async function loadGroups(engineId) {
if (engineId === 'f5') {
const { ok, status, data } = await getJson('/anomalous/audio_voices');
if (!ok || !data?.success) throw new Error(data?.error || `HTTP ${status}`);
return f5Groups(data.characters);
}
const { ok, status, data } = await getJson('/anomalous_tts/characters');
if (!ok || !data) throw new Error(`HTTP ${status}`);
return gptSovitsGroups(data);
}
// The studio and the sidebar render together; share one request per engine for a moment.
const CACHE_MS = 1500;
const loadCache = new Map();
/** Forget cached engine data (after a refresh or a settings save). */
export function invalidateEngineCache() {
loadCache.clear();
}
/**
* { installed, groups, error } for one engine. The F5-TTS library is the plugin's own
* input/F5-TTS folder, so it is listed even when the F5-TTS node is not installed.
*/
export function loadEngine(engineId) {
const hit = loadCache.get(engineId);
if (hit && Date.now() - hit.at < CACHE_MS) return hit.promise;
const promise = loadEngineNow(engineId);
loadCache.set(engineId, { at: Date.now(), promise });
return promise;
}
async function loadEngineNow(engineId) {
const engine = engineById(engineId);
if (!engine) return { installed: false, groups: [], error: 'unknown engine' };
const installed = await isNodeInstalled(engine.probeNode);
if (!installed && engineId !== 'f5') return { installed, groups: [], error: '' };
try {
return { installed, groups: await loadGroups(engineId), error: '' };
} catch (e) {
return { installed, groups: [], error: e.message || String(e) };
}
}
/** Status of every engine (installed only), for the switcher badges. */
export async function detectEngines() {
const entries = await Promise.all(AUDIO_ENGINES.map(async engine => [engine.id, { installed: await isNodeInstalled(engine.probeNode) }]));
return Object.fromEntries(entries);
}
// ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ----------
/**
* Replace a character's anomalous_tts.json through the node's API. `settings` must be the
* full object (unknown fields preserved by the caller). Returns the refreshed group.
*/
export async function saveGptSovitsSettings(name, settings) {
const resp = await fetch('/anomalous_tts/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ character: name, settings }),
});
if (!resp.ok) {
const message = await resp.text().catch(() => '');
throw new Error(message || `HTTP ${resp.status}`);
}
const data = await resp.json().catch(() => ({}));
invalidateEngineCache();
return data.character ? gptSovitsGroups({ characters: [data.character] })[0] : null;
}
/**
* Build the new settings for an emotion editor result without dropping fields
* Anomalous does not know. `main` = { audio, text } | null; `emotions` = [{ name, audio, text }].
*/
export function mergeGptSovitsSettings(previous, { main, emotions }) {
const next = { ...(previous || {}), format: 1 };
if (main?.audio) {
const kept = previous?.reference && typeof previous.reference === 'object' ? previous.reference : {};
next.reference = { ...kept, audio: main.audio };
if (main.text) next.reference.text = main.text; else delete next.reference.text;
} else {
delete next.reference;
}
const oldEmotions = previous?.emotions && typeof previous.emotions === 'object' ? previous.emotions : {};
const out = {};
for (const row of emotions || []) {
const name = String(row?.name || '').trim();
if (!name || name === 'main' || !row.audio) continue;
const kept = oldEmotions[name] && typeof oldEmotions[name] === 'object' ? oldEmotions[name] : {};
out[name] = { ...kept, audio: row.audio };
if (row.text) out[name].text = row.text; else delete out[name].text;
}
if (Object.keys(out).length) next.emotions = out; else delete next.emotions;
return next;
}
+28 -7
View File
@@ -4,10 +4,12 @@ import { comboValueForPath } from './audio_script.js';
* The only canvas nodes the audio studio writes into, and what each accepts.
* No guessing by widget name: a node that is not listed here is refused.
*
* engine: whose voices the node takes (audio_engines.js); voices of another engine are refused.
* takes:
* 'character' — the node picks a character's main voice and switches emotions itself
* through `{emotion}` tags in its text (ComfyUI-F5-TTS multi-voice).
* 'clip' — the node uses exactly one audio file (ComfyUI's Load Audio).
* 'character' — the node picks a character and switches emotions itself through
* `{emotion}` tags in its text (F5-TTS multi-voice, Anomalous_TTS).
* 'clip' — the node uses exactly one audio file (ComfyUI's Load Audio; files
* from the input folder, i.e. the F5-TTS voice library).
* listedOnly: write only values the node's own dropdown offers (in its spelling).
* overriddenBy: an input that, when linked, makes the node ignore voiceWidget.
* speechWidget: where the Script Director writes the tagged script.
@@ -18,17 +20,32 @@ export const AUDIO_NODE_TARGETS = Object.freeze([
Object.freeze({
id: 'f5-tts',
label: 'F5-TTS',
engine: 'f5',
types: Object.freeze(['F5TTSAudio', 'F5TTSAudioAdvanced', 'F5TTSAudioFromModel']),
typeLabels: Object.freeze(['F5-TTS Audio', 'F5-TTS Audio Advanced', 'F5-TTS Audio From Model']),
takes: 'character',
voiceWidget: 'sample',
listedOnly: true,
overriddenBy: 'sample_audio',
speechWidget: 'speech',
}),
Object.freeze({
id: 'anomalous-tts',
label: 'GPT-SoVITS',
engine: 'gpt_sovits',
types: Object.freeze(['AnomalousTTS_CharacterSpeech']),
typeLabels: Object.freeze(['角色语音 (GPT-SoVITS)']),
takes: 'character',
voiceWidget: 'character',
listedOnly: true,
speechWidget: 'text',
}),
Object.freeze({
id: 'load-audio',
label: 'Load Audio',
engine: 'f5',
types: Object.freeze(['LoadAudio']),
typeLabels: Object.freeze(['Load Audio']),
takes: 'clip',
voiceWidget: 'audio',
// Load Audio lists only the top of input/, but validates by file existence, so subfolders work.
@@ -43,10 +60,10 @@ export function targetForNode(node) {
return AUDIO_NODE_TARGETS.find(target => target.types.includes(type)) || null;
}
/** Nodes the Script Director can write a tagged script into. */
export function isScriptTarget(node) {
/** Nodes the Script Director can write a tagged script into; `engine` narrows to one engine's nodes. */
export function isScriptTarget(node, engine = null) {
const target = targetForNode(node);
return Boolean(target?.speechWidget && target.takes === 'character');
return Boolean(target?.speechWidget && target.takes === 'character' && (!engine || target.engine === engine));
}
function widgetOf(node, name) {
@@ -67,10 +84,14 @@ const forwardSlashes = value => String(value ?? '').replace(/\\/g, '/');
export function planVoiceDrop(node, item) {
const target = targetForNode(node);
if (!target) return { ok: false, reason: 'unsupported' };
const engine = item?.kind === 'character' ? item.group?.engine : item?.slice?.engine;
if ((engine || 'f5') !== target.engine) return { ok: false, reason: 'otherEngine', target };
if (item?.kind !== target.takes) {
return { ok: false, reason: target.takes === 'character' ? 'needsCharacter' : 'needsClip', target };
}
const path = item.kind === 'character' ? item.group?.main_relative_path : item.slice?.relative_path;
const path = item.kind === 'character'
? (item.group?.has_main === false ? null : item.group?.node_value ?? item.group?.main_relative_path)
: item.slice?.relative_path;
if (!path) return { ok: false, reason: 'noMainVoice', target };
if (inputLinked(node, target.overriddenBy)) return { ok: false, reason: 'overridden', target };
const widget = widgetOf(node, target.voiceWidget);
+7 -5
View File
@@ -104,14 +104,16 @@ export function usableEmotions(group) {
}
/**
* Bundle script lines for one F5TTSAudio node. `lines` are `{ text, emotion }`;
* the node's sample becomes the group's main voice and every emotion must be a
* usable variant of that group. Returns `{ sample, speech, lineCount }` or `{ error: localeKey }`.
* Bundle script lines for one TTS node. `lines` are `{ text, emotion }`; the node's
* voice widget gets the group's `node_value` (F5-TTS: main voice file; GPT-SoVITS:
* character name) and every emotion must be usable for that group.
* Returns `{ sample, speech, lineCount }` or `{ error: localeKey }`.
*/
export function buildScriptPackage(lines, group) {
if (!group) return { error: 'scriptDirectorPickCharacter' };
const usable = new Set(usableEmotions(group));
if (!group.main_relative_path || !usable.has('main')) return { error: 'scriptDirectorMainMissing' };
const voice = group.node_value ?? group.main_relative_path;
if (!voice || group.has_main === false || !usable.has('main')) return { error: 'scriptDirectorMainMissing' };
const voiced = [];
for (const line of lines || []) {
const text = String(line?.text || '').trim();
@@ -121,7 +123,7 @@ export function buildScriptPackage(lines, group) {
voiced.push({ text, voice: emotion === 'main' ? null : { tag: `{${emotion}}` } });
}
if (!voiced.length) return { error: 'scriptDirectorEmpty' };
return { sample: group.main_relative_path, speech: composeScript(voiced), lineCount: voiced.length };
return { sample: voice, speech: composeScript(voiced), lineCount: voiced.length };
}
/**
+106 -28
View File
@@ -1319,20 +1319,59 @@ export const i18n = {
audioCopyTag: "复制语法",
audioCopied: "已复制语法",
audioGuideTitle: "音频工作台使用说明",
audioGuideFilesTitle: "第一步:放好声音文件",
audioGuideFilesBody: "声音放在 ComfyUI/input/F5-TTS 里。主声音叫“角色.wav”(例如 Arona.wav),情绪叫“角色.情绪.wav”(例如 Arona.happy.wav),每个都配一个同名 .txt,写上这段录音说的原话。也可以点“新增音色”直接上传,文件名会自动按这个规则起好。",
audioGuideFilesTitle: "第一步:选引擎,放好声音",
audioGuideFilesBody: "页面顶部可以切换 F5-TTS 和 GPT-SoVITS,没装的会标“未安装”并告诉你怎么装。F5-TTS:声音放在 ComfyUI/input/F5-TTS,主声音叫“角色.wav”,情绪叫“角色.情绪.wav”,各配一个写着原话的 .txt,也可以点“新增音色”上传。GPT-SoVITS:角色就是 models/gpt_sovits 里的模型文件夹,点卡片上的“编辑情绪”给每种情绪挑一条参考音频。",
audioGuideDragTitle: "拖动:标题栏给 F5-TTS,单条给 Load Audio",
audioGuideDragBody: "拖角色卡片的标题栏 → 放到 F5-TTS 节点:它会用这个角色的主声音。拖卡片里的某一行 → 放到 Load Audio 节点:它只用这一条。拖动时跟着鼠标的提示会写明“松手会发生什么”;放到不支持的节点上,提示会说明原因和正确做法,不会乱写。",
audioGuideDragBody: "拖角色卡片的标题栏 → 放到当前引擎的节点上(F5-TTS 节点,或“角色语音 (GPT-SoVITS)”节点):设为这个角色。F5-TTS 的某一行还可以拖到 Load Audio 节点,只用这一条。拖动时鼠标旁的提示会写明“松手会发生什么”;放错节点会说明原因,不会乱写。",
audioGuideTagsTitle: "情绪靠台词里的 {标签}",
audioGuideTagsBody: "F5-TTS 不靠换声音文件切换情绪,而是看台词里的标签:写 {happy} 就用 Arona.happy.wav,写 {main} 回到主声音。每行右边的 {标签} 按钮点一下就能复制。",
audioGuideTagsBody: "两种引擎都靠台词里的标签切换情绪:写 {开心} 或 {happy} 就换成这种情绪的参考音频,写 {main} 回到主声音。每行右边的 {标签} 按钮点一下就能复制。GPT-SoVITS 还支持 [角色名] 换人说、[pause:1s] 插入停顿,直接写在台词里即可。",
audioGuideScriptTitle: "剧本台:不用手写标签",
audioGuideScriptBody: "点“打开剧本台”,选角色、粘贴整段台词,每句会变成一张卡片,点一下就能选情绪,也可以拆分、合并、删除。完成后点“推送到节点”,或者按住“拖到节点”把整段剧本拖到 F5-TTS 节点上,声音和台词会一起填好。",
audioGuideScriptBody: "点“打开剧本台”,选角色、粘贴整段台词,每句会变成一张卡片,点一下就能选情绪,也可以拆分、合并、删除。完成后点“推送到节点”,或者按住“拖到节点”把整段剧本拖到当前引擎的节点上,角色和台词会一起填好。",
audioGuideGenerateTitle: "生成与音频库",
audioGuideGenerateBody: "画布上还没有工作流时,点“载入阿罗娜工作流到画布”可以得到一个现成模板。运行后到“音频库”试听、按台词搜索;只在预览里出现的临时结果,可以点保存留下来。",
audioEngineMissing: "未安装",
audioEngineWorksWith: "适用节点:{nodes}",
audioEngineInstallTitle: "{engine} 需要先安装节点包 {pack}",
audioEngineF5Desc: "F5-TTS:用一段 5~10 秒的录音就能模仿声音,不用训练。情绪靠同名的情绪录音切换。",
audioEngineGptSovitsDesc: "GPT-SoVITS(Anomalous TTS):使用你训练好的 GPT-SoVITS 角色模型,音色还原度高;支持多句一起生成、多角色对话和停顿标签。",
audioEngineLibraryOnly: "还没安装 {engine} 节点:这里的声音照样可以管理和试听,但要生成语音需要先安装。",
audioEngineInstallHow: "安装方法:打开 ComfyUI Manager,搜索“{search}”并安装,然后重启 ComfyUI。",
audioEngineNotPublished: "{pack} 还没有公开发布。拿到后放进 ComfyUI/custom_nodes/ 并重启 ComfyUI,这里就会列出角色。",
audioEngineOpenRepo: "打开项目主页",
audioSectionCharactersOf: "{engine} 角色",
audioDragCardHintGptSovits: "拖标题栏 → GPT-SoVITS 节点 · 点 {标签} 复制",
audioDragCharacterTitleGptSovits: "拖到 GPT-SoVITS 节点:角色设为 {character}。情绪在台词里用 {标签} 切换。",
audioDropReject_otherEngine: "这是 {voiceEngine} 的声音,{node} 节点用不了,请拖到 {voiceEngine} 的节点上",
ttsEditorOpen: "编辑情绪",
ttsEditorTitle: "编辑情绪 · {name}",
ttsEditorSubtitle: "为主声音和每种情绪各挑一条参考音频(3~10 秒、有台词最好)。保存后写进角色文件夹里的 anomalous_tts.json。",
ttsEditorAudioPlaceholder: "输入或选择音频文件",
ttsEditorTextPlaceholder: "参考台词(留空自动查找)",
ttsEditorMainSection: "主声音",
ttsEditorEmotionSection: "情绪",
ttsEditorMainAuto: "不设置时,节点会自动使用:{file}",
ttsEditorMainHint: "留空:由节点自动挑一条 3~10 秒的音频",
ttsEditorEmotionPlaceholder: "情绪名,例如 开心",
ttsEditorRemove: "删除这一行",
ttsEditorAdd: "+ 添加情绪",
ttsEditorFilenameEmotions: "另外,文件名里带情绪的音频会被自动识别:{emotions}",
ttsEditorSave: "保存",
ttsEditorSaveFailed: "保存失败:{error}",
ttsEditorUnknownAudio: "{file} 不在这个角色的文件夹里",
ttsEditorNameMissing: "{file} 还没填情绪名",
ttsEditorNameInvalid: "情绪名“{name}”不能用:不能叫 main,也不能带 {} [] 这些符号",
ttsEditorNameDuplicate: "情绪名“{name}”重复了",
ttsEditorAudioMissing: "情绪“{name}”还没选音频",
ttsCharacterError: "这个角色读取失败:{error}",
ttsMainMissing: "没有找到合适的主声音(3~10 秒的参考音频)。点“编辑情绪”选一条。",
ttsEmptyTitle: "还没有 GPT-SoVITS 角色",
ttsEmptyDesc: "把角色文件夹(里面有 .ckpt 和 .pth)放进 ComfyUI/models/gpt_sovits/,或在 extra_model_paths.yaml 里加上 gpt_sovits 目录,然后点“刷新音色”。",
scriptDirectorMainMissingGptSovits: "这个角色还没有主声音,请先在卡片上点“编辑情绪”选一条参考音频",
scriptDirectorNoCharactersGptSovits: "还没有 GPT-SoVITS 角色。",
audioDragCardHint: "拖标题栏 → F5-TTS 节点 · 拖单条 → Load Audio · 点 {标签} 复制",
audioDragCharacterTitle: "拖到 F5-TTS 节点:设为 {character} 的主声音。情绪在台词里用 {标签} 切换。",
audioDragClipTitle: "拖到 Load Audio 节点:使用这一条音频",
audioDragCharacterHint: "{character}(整个角色)→ 放到 F5-TTS 节点上",
audioDragCharacterHint: "{character}(整个角色)→ 放到 {node} 节点上",
audioDragClipHint: "{clip}(单条音频)→ 放到 Load Audio 节点上",
audioDropCharacterTarget: "松手:{node} 使用 {character} 的主声音",
audioDropClipTarget: "松手:{node} 使用 {clip}",
@@ -1369,14 +1408,14 @@ export const i18n = {
audioLoadAronaWorkflow: "载入阿罗娜工作流到画布",
audioWorkflowLoaded: "工作流已载入",
scriptDirectorTitle: "剧本台",
scriptDirectorSubtitle: "粘贴台词 → 每句选情绪 → 推送或拖到 F5-TTS 节点",
scriptDirectorSubtitle: "粘贴台词 → 每句选情绪 → 推送或拖到 {node} 节点",
scriptDirectorOpen: "打开剧本台",
scriptDirectorInputPlaceholder: "在此粘贴剧本,每句一行或按标点自动分句…\n\n解析后可以在每句台词卡片上直接选择 main / happy 等情绪。",
scriptDirectorParse: "解析分句",
scriptDirectorClear: "清空",
scriptDirectorInject: "推送到节点",
scriptDirectorSuccess: "已写入节点",
scriptDirectorNoNode: "画布上没有 F5-TTS 节点",
scriptDirectorNoNode: "画布上没有 {node} 节点",
audioAddVoice: "新增音色",
audioUploadModalTitle: "录入音色资产",
audioUploadModalSubtitle: "拖入音频并输入参考台词,系统将自动标准命名并落盘至 F5-TTS",
@@ -1423,8 +1462,8 @@ export const i18n = {
audioRomanizeUnchanged: "未检测到日文假名或韩文,文本保持不变",
audioRomanizeMissingDep: "缺少转写依赖:{error}。请在 ComfyUI 的 Python 环境中安装 pykakasi 和 hangul-romanize。",
audioOriginalKeptHint: "原文会另存为 .orig.txt,卡片上显示原文",
scriptDirectorPickNode: "画布上有多个 F5-TTS 节点,请先选中要写入的那一个",
scriptDirectorOverwriteConfirm: "目标节点的 speech 已有内容,是否覆盖?",
scriptDirectorPickNode: "画布上有多个 {node} 节点,请先选中要写入的那一个",
scriptDirectorOverwriteConfirm: "目标节点的台词栏已经有内容,要覆盖吗?",
scriptDirectorClose: "关闭剧本台",
scriptDirectorCharacter: "角色",
scriptDirectorPickCharacter: "请先选择角色",
@@ -1439,9 +1478,9 @@ export const i18n = {
scriptDirectorCopy: "复制剧本",
scriptDirectorCopied: "已复制",
scriptDirectorDragHandle: "拖到节点",
scriptDirectorDragHint: "拖到画布上的 F5-TTS 节点:会同时设置参考音频和台词",
scriptDirectorPushHint: "写入选中的(或画布上唯一的)F5-TTS 节点",
scriptDirectorDropNotTts: "只能放到 F5-TTS 节点上",
scriptDirectorDragHint: "拖到画布上的 {node} 节点:会同时设置声音和台词",
scriptDirectorPushHint: "写入选中的(或画布上唯一的){node} 节点",
scriptDirectorDropNotTts: "只能放到 {node} 节点上",
scriptDirectorEmotionMissing: "当前角色没有这个情绪(或缺少 .txt),请换一个",
scriptDirectorSplitSentence: "按句自动分段",
scriptDirectorSplitLine: "按换行分段(每行一张卡片)",
@@ -1455,7 +1494,7 @@ export const i18n = {
audioEmotionMainHint: "填 main 表示主音色(保存为 角色.wav);其它情绪保存为 角色.情绪.wav,在剧本里用 {情绪} 调用",
scriptDirectorVoiceUnusable: "有台词用了不能作为标签的音色,请检查该角色的主音色和 .txt 台词文件",
scriptDirectorMainMissing: "该角色缺少主音色(角色.wav),无法推送多音色剧本",
scriptDirectorSampleNotListed: "F5-TTS 节点的音频列表里还没有 {file}。请先在 ComfyUI 中刷新节点定义(按 R)后再试。"
scriptDirectorSampleNotListed: "{node} 节点的列表里还没有 {file}。请先在 ComfyUI 中刷新节点定义(按 R)后再试。"
},
en: {
actionTools: "Tools",
@@ -2777,20 +2816,59 @@ export const i18n = {
audioCopyTag: "Copy Tag",
audioCopied: "Tag Copied!",
audioGuideTitle: "Audio studio guide",
audioGuideFilesTitle: "1. Put your voice files in place",
audioGuideFilesBody: "Voices live in ComfyUI/input/F5-TTS. The main voice is \"Character.wav\" (e.g. Arona.wav); emotions are \"Character.emotion.wav\" (e.g. Arona.happy.wav). Each needs a .txt with the same name containing what the recording says. \"Add Voice\" uploads files and names them this way for you.",
audioGuideFilesTitle: "1. Pick an engine and add voices",
audioGuideFilesBody: "Switch between F5-TTS and GPT-SoVITS at the top; a missing engine is marked \"not installed\" with install steps. F5-TTS: voices live in ComfyUI/input/F5-TTS as \"Character.wav\" (main) and \"Character.emotion.wav\", each with a .txt of its line; \"Add Voice\" uploads them for you. GPT-SoVITS: characters are model folders in models/gpt_sovits; use \"Edit emotions\" on a card to pick a reference clip per emotion.",
audioGuideDragTitle: "Drag: header to F5-TTS, a row to Load Audio",
audioGuideDragBody: "Drag a character card's header onto an F5-TTS node to use that character's main voice. Drag one row onto a Load Audio node to use exactly that clip. While dragging, the hint says what releasing will do; over an unsupported node it explains why and what to do instead, and nothing is written.",
audioGuideDragBody: "Drag a character card's header onto the current engine's node (an F5-TTS node or \"角色语音 (GPT-SoVITS)\") to select that character. An F5-TTS row can also go onto a Load Audio node to use exactly that clip. While dragging, the hint says what releasing will do; a wrong node is refused with the reason, and nothing is written.",
audioGuideTagsTitle: "Emotions come from {tags} in the text",
audioGuideTagsBody: "F5-TTS switches emotion by tags in the text, not by swapping the voice file: {happy} uses Arona.happy.wav and {main} goes back to the main voice. Click the {tag} button on a row to copy it.",
audioGuideTagsBody: "Both engines switch emotion by tags in the text: {happy} (or {开心}) uses that emotion's reference, {main} goes back to the main voice. Click the {tag} button on a row to copy it. GPT-SoVITS also understands [Character] to change speaker and [pause:1s] for a pause, written straight into the text.",
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 \"Push to node\" or drag \"Drag to node\" onto an F5-TTS node to fill in both the voice and the text.",
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 \"Push to node\" or drag \"Drag to node\" onto the current engine's node to fill in both the character and the text.",
audioGuideGenerateTitle: "Generate and find it in the audio gallery",
audioGuideGenerateBody: "With an empty canvas, \"Load Arona Workflow to Canvas\" gives you a ready-made template. After a run, the audio gallery lets you play results and search by text; preview-only results can be saved there.",
audioEngineMissing: "not installed",
audioEngineWorksWith: "Works with: {nodes}",
audioEngineInstallTitle: "{engine} needs the {pack} node pack",
audioEngineF5Desc: "F5-TTS clones a voice from a 5–10 second recording, no training needed. Emotions switch between recordings named after the character.",
audioEngineGptSovitsDesc: "GPT-SoVITS (Anomalous TTS) uses your trained GPT-SoVITS character models for a faithful voice, with batched lines, multi-character dialogue and pause tags.",
audioEngineLibraryOnly: "The {engine} node is not installed: you can still manage and preview these voices, but generating speech needs it.",
audioEngineInstallHow: "To install: open ComfyUI Manager, search for \"{search}\", install it and restart ComfyUI.",
audioEngineNotPublished: "{pack} is not published yet. Once you have it, put it in ComfyUI/custom_nodes/ and restart ComfyUI; its characters will appear here.",
audioEngineOpenRepo: "Open project page",
audioSectionCharactersOf: "{engine} characters",
audioDragCardHintGptSovits: "Drag the header → GPT-SoVITS node · click a {tag} to copy",
audioDragCharacterTitleGptSovits: "Drop onto a GPT-SoVITS node to set its character to {character}. Switch emotions with {tags} in the text.",
audioDropReject_otherEngine: "This is a {voiceEngine} voice; the {node} node cannot use it. Drop it on the {voiceEngine} node",
ttsEditorOpen: "Edit emotions",
ttsEditorTitle: "Edit emotions · {name}",
ttsEditorSubtitle: "Pick one reference clip (3–10 s, with its line) for the main voice and each emotion. Saved to anomalous_tts.json in the character folder.",
ttsEditorAudioPlaceholder: "Type or pick an audio file",
ttsEditorTextPlaceholder: "Reference line (blank: found automatically)",
ttsEditorMainSection: "Main voice",
ttsEditorEmotionSection: "Emotions",
ttsEditorMainAuto: "When unset, the node uses: {file}",
ttsEditorMainHint: "Blank: the node picks a 3–10 s clip itself",
ttsEditorEmotionPlaceholder: "Emotion name, e.g. happy",
ttsEditorRemove: "Remove this row",
ttsEditorAdd: "+ Add emotion",
ttsEditorFilenameEmotions: "Clips with an emotion in their file name are also found automatically: {emotions}",
ttsEditorSave: "Save",
ttsEditorSaveFailed: "Could not save: {error}",
ttsEditorUnknownAudio: "{file} is not in this character's folder",
ttsEditorNameMissing: "{file} has no emotion name yet",
ttsEditorNameInvalid: "The emotion name \"{name}\" cannot be used: not main, and no {} [] characters",
ttsEditorNameDuplicate: "The emotion name \"{name}\" is used twice",
ttsEditorAudioMissing: "Emotion \"{name}\" has no audio yet",
ttsCharacterError: "This character could not be read: {error}",
ttsMainMissing: "No suitable main voice (a 3–10 s reference clip) was found. Use \"Edit emotions\" to pick one.",
ttsEmptyTitle: "No GPT-SoVITS characters yet",
ttsEmptyDesc: "Put character folders (with .ckpt and .pth) into ComfyUI/models/gpt_sovits/, or add a gpt_sovits folder in extra_model_paths.yaml, then press Refresh Voices.",
scriptDirectorMainMissingGptSovits: "This character has no main voice yet. Pick a reference clip with \"Edit emotions\" on its card first",
scriptDirectorNoCharactersGptSovits: "No GPT-SoVITS characters yet.",
audioDragCardHint: "Drag the header → F5-TTS node · drag a row → Load Audio · click a {tag} to copy",
audioDragCharacterTitle: "Drop onto an F5-TTS node to use {character}'s main voice. Switch emotions with {tags} in the text.",
audioDragClipTitle: "Drop onto a Load Audio node to use this exact clip",
audioDragCharacterHint: "{character} (whole character) → drop on an F5-TTS node",
audioDragCharacterHint: "{character} (whole character) → drop onto the {node} node",
audioDragClipHint: "{clip} (single clip) → drop on a Load Audio node",
audioDropCharacterTarget: "Release: {node} uses {character}'s main voice",
audioDropClipTarget: "Release: {node} uses {clip}",
@@ -2827,14 +2905,14 @@ export const i18n = {
audioLoadAronaWorkflow: "Load Arona Workflow to Canvas",
audioWorkflowLoaded: "Workflow loaded",
scriptDirectorTitle: "Script Director",
scriptDirectorSubtitle: "Paste lines → pick an emotion per line → push or drag onto an F5-TTS node",
scriptDirectorSubtitle: "Paste lines → pick an emotion per line → push or drag onto the {node} node",
scriptDirectorOpen: "Open Script Director",
scriptDirectorInputPlaceholder: "Paste your script here, one line each or split by punctuation…\n\nAfter parsing, pick main / happy / … right on each line card.",
scriptDirectorParse: "Parse Lines",
scriptDirectorClear: "Clear",
scriptDirectorInject: "Push to node",
scriptDirectorSuccess: "Written to node",
scriptDirectorNoNode: "No F5-TTS node on the canvas",
scriptDirectorNoNode: "No {node} node on the canvas",
audioAddVoice: "Add Voice",
audioUploadModalTitle: "Ingest Voice Asset",
audioUploadModalSubtitle: "Drop audio and enter reference transcript; files are auto-named and saved to F5-TTS",
@@ -2881,8 +2959,8 @@ export const i18n = {
audioRomanizeUnchanged: "No Japanese kana or Korean found; text left unchanged",
audioRomanizeMissingDep: "Missing transliteration dependency: {error}. Install pykakasi and hangul-romanize in ComfyUI's Python.",
audioOriginalKeptHint: "The original script is saved as .orig.txt and shown on the card",
scriptDirectorPickNode: "Several F5-TTS nodes are on the canvas. Select the one to write to first.",
scriptDirectorOverwriteConfirm: "The target node's speech field already has text. Overwrite it?",
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?",
scriptDirectorClose: "Close Script Director",
scriptDirectorCharacter: "Character",
scriptDirectorPickCharacter: "Choose a character first",
@@ -2897,9 +2975,9 @@ export const i18n = {
scriptDirectorCopy: "Copy script",
scriptDirectorCopied: "Copied",
scriptDirectorDragHandle: "Drag to node",
scriptDirectorDragHint: "Drop onto an F5-TTS node to set its sample and speech",
scriptDirectorPushHint: "Write into the selected (or only) F5-TTS node",
scriptDirectorDropNotTts: "Drop onto an F5-TTS node",
scriptDirectorDragHint: "Drop onto the {node} node to set its voice and text",
scriptDirectorPushHint: "Write into the selected (or only) {node} node",
scriptDirectorDropNotTts: "Drop onto the {node} node",
scriptDirectorEmotionMissing: "This character has no such emotion (or its .txt is missing); pick another",
scriptDirectorSplitSentence: "Split by sentence",
scriptDirectorSplitLine: "Split by line (one card per line)",
@@ -2913,7 +2991,7 @@ export const i18n = {
audioEmotionMainHint: "Use main for the main voice (saved as Character.wav); other emotions are saved as Character.emotion.wav and called with {emotion} in the script",
scriptDirectorVoiceUnusable: "Some lines use a voice that cannot be a tag. Check the character's main voice and .txt transcripts.",
scriptDirectorMainMissing: "This character has no main voice (Character.wav), so the multi-voice script cannot be pushed",
scriptDirectorSampleNotListed: "The F5-TTS node does not list {file} yet. Refresh node definitions in ComfyUI (press R) and try again."
scriptDirectorSampleNotListed: "The {node} node does not list {file} yet. Refresh node definitions in ComfyUI (press R) and try again."
}
};
+15 -15
View File
@@ -1,8 +1,10 @@
import { t } from './interface_settings.js';
import { engineById, getStoredEngine, loadEngine } from './audio_engines.js';
/**
* Audio sidebar: All Voices, one entry per voice group (main sample + variants),
* and the generated-audio history. Owns the active audio filter.
* Audio sidebar: All Voices, one entry per voice group of the active engine
* (F5-TTS voices or GPT-SoVITS characters), and the generated-audio history.
* Owns the active audio filter.
*/
let activeFilter = { type: 'all', value: null };
@@ -112,33 +114,31 @@ export async function renderAudioSidebar(owner) {
listContainer.className = 'anomalous-audio-nav-list';
owner.sidebar.replaceChildren(renderSidebarHeader(owner), listContainer);
let data;
try {
const resp = await fetch('/anomalous/audio_voices');
data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
} catch (e) {
if (renderTokens.get(owner.sidebar) !== token) return;
const error = createSectionLabel(t('audioLoadFailed', { error: e.message }));
const engineId = engineById(getStoredEngine()) ? getStoredEngine() : 'f5';
const result = await loadEngine(engineId);
if (renderTokens.get(owner.sidebar) !== token) return;
if (result.error) {
const error = createSectionLabel(t('audioLoadFailed', { error: result.error }));
error.classList.add('is-error');
listContainer.appendChild(error);
return;
}
if (renderTokens.get(owner.sidebar) !== token) return;
const groups = data.characters || [];
const groups = result.groups;
const totalSlices = groups.reduce((sum, group) => sum + (group.total_slices || 0), 0);
if (activeFilter.type === 'group' && !groups.some(group => group.group === activeFilter.value)) {
activeFilter = { type: 'all', value: null };
}
listContainer.appendChild(createNavItem(owner, {
label: t('audioAllVoices'), iconSvg: SIDEBAR_SVG.ALL_VOICES, count: data.total_slices || 0,
label: t('audioAllVoices'), iconSvg: SIDEBAR_SVG.ALL_VOICES, count: totalSlices,
filter: { type: 'all', value: null }, tab: 'presets',
}));
listContainer.appendChild(createSectionLabel(t('audioSectionCharacters')));
listContainer.appendChild(createSectionLabel(t('audioSectionCharactersOf', { engine: engineById(engineId).label })));
for (const group of groups) {
const label = group.folder && group.folder !== 'F5-TTS' ? `${group.character} · ${group.folder}` : group.character;
const showFolder = group.engine === 'f5' && group.folder && group.folder !== 'F5-TTS';
const label = showFolder ? `${group.character} · ${group.folder}` : group.character;
listContainer.appendChild(createNavItem(owner, {
label, iconSvg: SIDEBAR_SVG.USER, count: group.total_slices,
filter: { type: 'group', value: group.group, character: group.character }, tab: 'presets',
+159 -39
View File
@@ -3,8 +3,10 @@ import { t } from './interface_settings.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { stopGalleryAudio } from './ui_audio_gallery.js';
import { openAudioUploaderModal } from './ui_audio_uploader.js';
import { getActiveAudioFilter } from './ui_audio_sidebar.js';
import { SUPPORTED_TARGET_LABELS, alignedVoiceValue, planVoiceDrop } from './audio_node_targets.js';
import { getActiveAudioFilter, renderAudioSidebar, setActiveAudioFilter } from './ui_audio_sidebar.js';
import { AUDIO_NODE_TARGETS, alignedVoiceValue, planVoiceDrop } from './audio_node_targets.js';
import { AUDIO_ENGINES, detectEngines, engineById, engineTargetLabels, getStoredEngine, invalidateEngineCache, loadEngine, pickEngine, setStoredEngine } from './audio_engines.js';
import { openGptSovitsEditor } from './ui_audio_tts_editor.js';
import { bindMaterialDrag } from './material_drag.js';
import {
openScriptDirector,
@@ -16,8 +18,9 @@ import {
} from './ui_script_director.js';
/**
* Audio & Voice Studio workspace: character voice cards, preview playback,
* tag copying, canvas drop into TTS nodes, and toolbar entry points.
* Audio & Voice Studio workspace: engine switch (F5-TTS / GPT-SoVITS, detected at
* runtime), character voice cards, preview playback, tag copying, canvas drop into
* TTS nodes, and toolbar entry points.
*/
const WORKFLOW_TEMPLATE = 'arona';
@@ -37,6 +40,8 @@ const SVG = {
SEARCH: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
SCRIPT: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
PLUS: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
EDIT: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`,
REFRESH: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg>`,
WORKFLOW: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
};
@@ -132,6 +137,14 @@ function renderEqIndicator() {
return barWrap;
}
function itemEngine(item) {
return (item.kind === 'character' ? item.group?.engine : item.slice?.engine) || 'f5';
}
function engineLabel(id) {
return engineById(id)?.label || id;
}
function itemLabel(item) {
if (item.kind === 'character') return item.group.character;
return `${item.slice.character} · ${String(item.slice.emotion || '').toUpperCase()}`;
@@ -152,7 +165,8 @@ function dropRejectHint(node, item) {
if (plan.ok) return '';
const params = {
node: plan.target?.label || '',
supported: SUPPORTED_TARGET_LABELS.join(t('audioListSeparator')),
supported: AUDIO_NODE_TARGETS.filter(target => target.engine === itemEngine(item)).map(target => target.label).join(t('audioListSeparator')),
voiceEngine: engineLabel(itemEngine(item)),
character: item.kind === 'character' ? item.group.character : item.slice.character,
emotion: item.kind === 'clip' && !item.slice.is_main ? `{${item.slice.emotion}}` : t('audioEmotionTagExample'),
file: plan.path || '',
@@ -169,7 +183,7 @@ function bindVoiceDrag(element, item, owner) {
payload: () => ({
...item,
dragHint: item.kind === 'character'
? t('audioDragCharacterHint', { character: item.group.character })
? t('audioDragCharacterHint', { character: item.group.character, node: engineLabel(itemEngine(item)) })
: t('audioDragClipHint', { clip: itemLabel(item) }),
}),
accepts: node => planVoiceDrop(node, item).ok,
@@ -192,11 +206,18 @@ function gripIcon() {
return grip;
}
/** One clip. Only F5-TTS library clips (input folder) can be dragged, onto Load Audio. */
function renderSliceRow(slice, owner) {
const row = document.createElement('div');
row.className = 'anomalous-voice-slice-row';
row.title = t('audioDragClipTitle');
bindVoiceDrag(row, { kind: 'clip', slice }, owner);
const draggable = (slice.engine || 'f5') === 'f5';
row.classList.toggle('is-static', !draggable);
if (draggable) {
row.title = t('audioDragClipTitle');
bindVoiceDrag(row, { kind: 'clip', slice }, owner);
} else {
row.title = slice.relative_path || '';
}
const playBtn = document.createElement('button');
playBtn.type = 'button';
@@ -238,11 +259,12 @@ function renderSliceRow(slice, owner) {
playAudio(slice.audio_url, playBtn, eqBars);
};
row.append(gripIcon(), playBtn, eqBars, emoTag, textSpan, copyBtn);
row.append(...(draggable ? [gripIcon()] : []), playBtn, eqBars, emoTag, textSpan, copyBtn);
return row;
}
function renderCharacterCard(group, owner) {
function renderCharacterCard(group, owner, { onChanged } = {}) {
const isTts = group.engine === 'gpt_sovits';
const card = document.createElement('div');
card.className = 'anomalous-character-voice-card';
@@ -262,7 +284,7 @@ function renderCharacterCard(group, owner) {
const name = document.createElement('span');
name.className = 'anomalous-character-voice-name';
name.textContent = group.character;
if (group.folder && group.folder !== 'F5-TTS') {
if (!isTts && group.folder && group.folder !== 'F5-TTS') {
const folder = document.createElement('span');
folder.className = 'anomalous-character-voice-folder';
folder.textContent = group.folder;
@@ -271,7 +293,7 @@ function renderCharacterCard(group, owner) {
const sub = document.createElement('span');
sub.className = 'anomalous-character-voice-hint';
sub.textContent = t('audioDragCardHint');
sub.textContent = t(isTts ? 'audioDragCardHintGptSovits' : 'audioDragCardHint');
nameBox.append(name, sub);
@@ -281,10 +303,18 @@ function renderCharacterCard(group, owner) {
// Only a draggable header shows a grip; without a main voice there is nothing to drag.
titleGroup.append(...(group.has_main ? [gripIcon()] : []), avatar, nameBox);
header.append(titleGroup, countBadge);
const headerRight = document.createElement('div');
headerRight.className = 'anomalous-character-voice-actions';
if (isTts && group.raw && !group.raw.error) {
const edit = createToolButton(SVG.EDIT, t('ttsEditorOpen'), 'is-compact');
edit.onclick = () => openGptSovitsEditor(group, { onSaved: () => onChanged?.() });
headerRight.append(edit);
}
headerRight.append(countBadge);
header.append(titleGroup, headerRight);
if (group.has_main) {
header.classList.add('is-draggable');
header.title = t('audioDragCharacterTitle', { character: group.character });
header.title = t(isTts ? 'audioDragCharacterTitleGptSovits' : 'audioDragCharacterTitle', { character: group.character });
bindVoiceDrag(header, { kind: 'character', group }, owner);
}
card.appendChild(header);
@@ -292,7 +322,9 @@ function renderCharacterCard(group, owner) {
if (!group.has_main) {
const warning = document.createElement('div');
warning.className = 'anomalous-character-voice-warning';
warning.textContent = t('audioMainMissing', { file: `${group.character}.wav` });
warning.textContent = isTts
? (group.error ? t('ttsCharacterError', { error: group.error }) : t('ttsMainMissing'))
: t('audioMainMissing', { file: `${group.character}.wav` });
card.appendChild(warning);
}
@@ -356,6 +388,12 @@ function createAddVoiceButton(onVoiceAdded, defaultCharacter) {
return btn;
}
function createRefreshButton(onRefresh) {
const btn = createToolButton(SVG.REFRESH, t('audioRefresh'));
btn.onclick = onRefresh;
return btn;
}
function createScriptDirectorButton(container, owner) {
const btn = createToolButton(SVG.SCRIPT, t('scriptDirectorOpen'));
btn.classList.toggle('active', isScriptDirectorActive());
@@ -374,7 +412,7 @@ function createScriptDirectorButton(container, owner) {
return btn;
}
function renderStudioToolbar({ onSearch, onVoiceAdded, container, owner, defaultCharacter }) {
function renderStudioToolbar({ engine, onSearch, onVoiceAdded, onRefresh, container, owner, defaultCharacter }) {
const toolbar = document.createElement('div');
toolbar.className = 'anomalous-audio-toolbar';
@@ -414,12 +452,11 @@ function renderStudioToolbar({ onSearch, onVoiceAdded, container, owner, default
searchInput.oninput = (e) => onSearch(e.target.value.trim().toLowerCase());
searchWrap.append(searchIcon, searchInput);
rightActions.append(
createScriptDirectorButton(container, owner),
createAddVoiceButton(onVoiceAdded, defaultCharacter),
createLoadWorkflowButton(),
searchWrap,
);
// F5-TTS voices are files Anomalous manages; GPT-SoVITS characters are model folders the node scans.
const engineActions = engine === 'f5'
? [createAddVoiceButton(onVoiceAdded, defaultCharacter), createLoadWorkflowButton()]
: [createRefreshButton(onRefresh)];
rightActions.append(createScriptDirectorButton(container, owner), ...engineActions, searchWrap);
toolbar.append(titleGroup, rightActions);
return toolbar;
@@ -432,7 +469,7 @@ function renderStatus(className, message) {
return box;
}
function renderEmptyGuide() {
function renderEmptyGuide(engine = 'f5') {
const emptyGuide = document.createElement('div');
emptyGuide.className = 'anomalous-audio-empty';
const icon = document.createElement('div');
@@ -440,10 +477,10 @@ function renderEmptyGuide() {
icon.innerHTML = SVG.MIC;
const title = document.createElement('div');
title.className = 'anomalous-audio-empty-title';
title.textContent = t('audioEmptyTitle');
title.textContent = t(engine === 'f5' ? 'audioEmptyTitle' : 'ttsEmptyTitle');
const desc = document.createElement('div');
desc.className = 'anomalous-audio-empty-desc';
desc.textContent = t('audioEmptyDesc');
desc.textContent = t(engine === 'f5' ? 'audioEmptyDesc' : 'ttsEmptyDesc');
emptyGuide.append(icon, title, desc);
return emptyGuide;
}
@@ -453,6 +490,70 @@ function resolveFilter(filter) {
return active?.type === 'group' && active.value ? active : null;
}
/** Engine switch: every engine is listed; missing ones stay visible, marked "not installed". */
function renderEngineBar(activeId, statuses, onPick) {
const bar = document.createElement('div');
bar.className = 'anomalous-audio-engine-bar';
const tabs = document.createElement('div');
tabs.className = 'anomalous-audio-engine-tabs';
tabs.setAttribute('role', 'tablist');
for (const engine of AUDIO_ENGINES) {
const installed = Boolean(statuses[engine.id]?.installed);
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'anomalous-audio-engine-tab';
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', String(engine.id === activeId));
tab.classList.toggle('active', engine.id === activeId);
tab.classList.toggle('is-missing', !installed);
tab.dataset.engine = engine.id;
const label = document.createElement('span');
label.textContent = engine.label;
tab.append(label);
if (!installed) {
const badge = document.createElement('span');
badge.className = 'anomalous-audio-engine-badge';
badge.textContent = t('audioEngineMissing');
tab.append(badge);
}
tab.onclick = () => { if (engine.id !== activeId) onPick(engine.id); };
tabs.append(tab);
}
const targets = document.createElement('div');
targets.className = 'anomalous-audio-engine-targets';
targets.textContent = t('audioEngineWorksWith', { nodes: engineTargetLabels(activeId).join(' / ') });
bar.append(tabs, targets);
return bar;
}
/** Shown instead of (GPT-SoVITS) or above (F5-TTS library) the cards when the engine's node pack is missing. */
function renderInstallCard(engine, { compact = false } = {}) {
const card = document.createElement('div');
card.className = `anomalous-audio-install-card${compact ? ' is-compact' : ''}`;
const title = document.createElement('div');
title.className = 'anomalous-audio-install-title';
title.textContent = t('audioEngineInstallTitle', { engine: engine.label, pack: engine.pack });
const desc = document.createElement('div');
desc.className = 'anomalous-audio-install-desc';
desc.textContent = compact ? t('audioEngineLibraryOnly', { engine: engine.label }) : t(engine.descKey);
const how = document.createElement('div');
how.className = 'anomalous-audio-install-how';
how.textContent = engine.repoUrl
? t('audioEngineInstallHow', { search: engine.managerSearch })
: t('audioEngineNotPublished', { pack: engine.pack });
card.append(title, desc, how);
if (engine.repoUrl) {
const link = document.createElement('a');
link.className = 'anomalous-audio-install-link';
link.href = engine.repoUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = t('audioEngineOpenRepo');
card.append(link);
}
return card;
}
/**
* Render the studio into `container`. Only the latest call for a container may
* write to it, so fast filter clicks cannot stack duplicate content.
@@ -464,50 +565,69 @@ export async function renderAudioStudio(container, { filter = null, owner = null
const directorWasOpen = isScriptDirectorActive();
stopAudioStudioPlayback();
const statuses = await detectEngines();
if (renderTokens.get(container) !== token) return;
const engineId = pickEngine(statuses);
if (!getStoredEngine()) setStoredEngine(engineId);
const engine = engineById(engineId);
const rerender = () => renderAudioStudio(container, { filter, owner });
const characterFilter = resolveFilter(filter);
const studioWrapper = document.createElement('div');
studioWrapper.className = 'anomalous-audio-studio-wrapper';
studioWrapper.dataset.engine = engineId;
const grid = document.createElement('div');
grid.className = 'anomalous-voice-card-grid';
const toolbar = renderStudioToolbar({
engine: engineId,
onSearch: (searchTerm) => {
grid.querySelectorAll('.anomalous-character-voice-card').forEach(card => {
card.style.display = card.textContent.toLowerCase().includes(searchTerm) ? '' : 'none';
});
},
onVoiceAdded: () => renderAudioStudio(container, { filter, owner }),
onVoiceAdded: () => { invalidateEngineCache(); rerender(); },
onRefresh: () => { invalidateEngineCache(); rerender(); if (owner) renderAudioSidebar(owner); },
container,
owner,
defaultCharacter: characterFilter?.character || '',
});
studioWrapper.append(toolbar, renderStatus('anomalous-audio-status', t('audioLoading')));
const engineBar = renderEngineBar(engineId, statuses, (next) => {
setStoredEngine(next);
setActiveAudioFilter(null);
if (owner) renderAudioSidebar(owner);
renderAudioStudio(container, { owner });
});
studioWrapper.append(toolbar, engineBar, renderStatus('anomalous-audio-status', t('audioLoading')));
container.replaceChildren(studioWrapper);
if (directorWasOpen) openScriptDirector(container);
let characters;
try {
const resp = await fetch('/anomalous/audio_voices');
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) throw new Error(data.error || `HTTP ${resp.status}`);
characters = data.characters || [];
} catch (e) {
if (renderTokens.get(container) !== token) return;
studioWrapper.lastChild.replaceWith(renderStatus('anomalous-audio-status is-error', t('audioLoadFailed', { error: e.message })));
const installed = Boolean(statuses[engineId]?.installed);
if (!installed && engineId !== 'f5') {
updateScriptDirectorVoices([], null, engineId);
studioWrapper.lastChild.replaceWith(renderInstallCard(engine));
return;
}
const result = await loadEngine(engineId);
if (renderTokens.get(container) !== token) return;
updateScriptDirectorVoices(characters, characterFilter?.value || null);
if (result.error) {
studioWrapper.lastChild.replaceWith(renderStatus('anomalous-audio-status is-error', t('audioLoadFailed', { error: result.error })));
return;
}
let characters = result.groups;
updateScriptDirectorVoices(characters, characterFilter?.value || null, engineId);
if (!installed) studioWrapper.insertBefore(renderInstallCard(engine, { compact: true }), studioWrapper.lastChild);
if (characterFilter) {
characters = characters.filter(group => group.group === characterFilter.value);
}
if (characters.length === 0) {
studioWrapper.lastChild.replaceWith(renderEmptyGuide());
studioWrapper.lastChild.replaceWith(renderEmptyGuide(engineId));
return;
}
characters.forEach(group => grid.appendChild(renderCharacterCard(group, owner)));
const onChanged = () => { rerender(); if (owner) renderAudioSidebar(owner); };
characters.forEach(group => grid.appendChild(renderCharacterCard(group, owner, { onChanged })));
studioWrapper.lastChild.replaceWith(grid);
}
+201
View File
@@ -0,0 +1,201 @@
import { t } from './interface_settings.js';
import { createViewScope } from './ui_lifecycle.js';
import { anomalousAlert } from './ui_dialog.js';
import { mergeGptSovitsSettings, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js';
/**
* GPT-SoVITS (Anomalous_TTS) emotion editor: pick one reference audio from the
* character's folder for the main voice and for each emotion. The node owns the
* file; this modal only sends the new settings through its API, keeping every
* field it does not edit.
*/
let activeScope = null;
const FORBIDDEN_IN_NAME = /[{}[\]]/;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick, title) {
const btn = el('button', className, label);
btn.type = 'button';
if (title) { btn.title = title; btn.setAttribute('aria-label', title); }
btn.onclick = onClick;
return btn;
}
/** Audio path input with a searchable list of the character's files (folders can hold thousands). */
function audioInput(listId, value) {
const input = el('input', 'anomalous-tts-audio-input');
input.type = 'text';
input.setAttribute('list', listId);
input.placeholder = t('ttsEditorAudioPlaceholder');
input.value = value || '';
return input;
}
function textInput(value) {
const input = el('input', 'anomalous-tts-text-input');
input.type = 'text';
input.placeholder = t('ttsEditorTextPlaceholder');
input.value = value || '';
return input;
}
/**
* Open the editor for one GPT-SoVITS voice group (audio_engines.gptSovitsGroups shape).
* `onSaved(group)` receives the refreshed group after the node accepted the settings.
*/
export function openGptSovitsEditor(group, { onSaved } = {}) {
activeScope?.dispose();
const scope = createViewScope();
activeScope = scope;
const raw = group.raw || {};
const name = raw.name || group.character;
const audioList = Array.isArray(raw.audio) ? raw.audio : [];
const settings = raw.settings && typeof raw.settings === 'object' ? raw.settings : {};
let player = null;
const overlay = el('div', 'anomalous-voice-modal-overlay');
const modal = el('div', 'anomalous-voice-modal anomalous-tts-editor');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => {
player?.pause();
overlay.remove();
if (activeScope === scope) activeScope = null;
});
overlay.onclick = (e) => { if (e.target === overlay) close(); };
scope.listen(window, 'keydown', (e) => {
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
e.stopPropagation();
close();
}, true);
const play = (input) => {
player?.pause();
const path = input.value.trim();
if (!audioList.includes(path)) return;
player = new Audio(ttsAudioUrl(name, path));
player.play().catch(() => {});
};
// Header
const header = el('div', 'anomalous-voice-modal-header');
const heading = el('div', 'anomalous-voice-modal-heading');
heading.append(
el('div', 'anomalous-voice-modal-title', t('ttsEditorTitle', { name })),
el('div', 'anomalous-voice-modal-subtitle', t('ttsEditorSubtitle')),
);
header.append(heading, button('anomalous-voice-modal-close', '×', close, t('close')));
const listId = `anomalous-tts-audio-${Math.random().toString(36).slice(2)}`;
const datalist = el('datalist');
datalist.id = listId;
datalist.append(...audioList.map(path => { const option = el('option'); option.value = path; return option; }));
// Main reference
const mainRow = el('div', 'anomalous-tts-row is-main');
const mainAudio = audioInput(listId, settings.reference?.audio);
const mainText = textInput(settings.reference?.text);
mainRow.append(
el('span', 'anomalous-tts-row-name', '{main}'),
mainAudio,
button('anomalous-audio-play-btn', '▶', () => play(mainAudio), t('audioPlay')),
mainText,
);
const mainHint = el('div', 'anomalous-tts-hint', raw.reference?.audio && !settings.reference?.audio
? t('ttsEditorMainAuto', { file: raw.reference.audio })
: t('ttsEditorMainHint'));
// Emotion rows
const rowsBox = el('div', 'anomalous-tts-rows');
const addRow = (row = {}) => {
const line = el('div', 'anomalous-tts-row');
const nameInput = el('input', 'anomalous-tts-name-input');
nameInput.type = 'text';
nameInput.placeholder = t('ttsEditorEmotionPlaceholder');
nameInput.value = row.name || '';
const audio = audioInput(listId, row.audio);
const text = textInput(row.text);
line.append(
nameInput,
audio,
button('anomalous-audio-play-btn', '▶', () => play(audio), t('audioPlay')),
text,
button('anomalous-tts-remove', '×', () => line.remove(), t('ttsEditorRemove')),
);
rowsBox.appendChild(line);
return nameInput;
};
for (const [emotion, ref] of Object.entries(settings.emotions || {})) {
addRow({ name: emotion, audio: ref?.audio, text: ref?.text });
}
const addBtn = button('anomalous-tts-add', t('ttsEditorAdd'), () => addRow().focus());
const autoEmotions = Object.entries(raw.emotions || {}).filter(([, ref]) => ref?.source === 'filename').map(([emotion]) => `{${emotion}}`);
const autoNote = el('div', 'anomalous-tts-hint', autoEmotions.length ? t('ttsEditorFilenameEmotions', { emotions: autoEmotions.join(' ') }) : '');
autoNote.hidden = !autoEmotions.length;
// Footer
const footer = el('div', 'anomalous-voice-modal-footer');
const saveBtn = button('anomalous-voice-modal-submit', t('ttsEditorSave'), async () => {
const rows = [...rowsBox.querySelectorAll('.anomalous-tts-row')].map(line => {
const [nameInput, audio, , text] = line.querySelectorAll('input, button');
return { name: nameInput.value.trim(), audio: audio.value.trim(), text: text.value.trim() };
}).filter(row => row.name || row.audio);
const problem = validateRows(rows, mainAudio.value.trim(), audioList);
if (problem) {
await anomalousAlert(problem);
return;
}
const next = mergeGptSovitsSettings(settings, {
main: mainAudio.value.trim() ? { audio: mainAudio.value.trim(), text: mainText.value.trim() } : null,
emotions: rows,
});
saveBtn.disabled = true;
try {
const updated = await saveGptSovitsSettings(name, next);
onSaved?.(updated);
close();
} catch (e) {
if (!scope.signal.aborted) await anomalousAlert(t('ttsEditorSaveFailed', { error: e.message }));
} finally {
if (!scope.signal.aborted) saveBtn.disabled = false;
}
});
footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), saveBtn);
const body = el('div', 'anomalous-tts-editor-body');
body.append(
datalist,
el('div', 'anomalous-tts-section', t('ttsEditorMainSection')), mainRow, mainHint,
el('div', 'anomalous-tts-section', t('ttsEditorEmotionSection')), rowsBox, addBtn, autoNote,
);
modal.append(header, body, footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
(mainAudio.value ? addBtn : mainAudio).focus();
return close;
}
/** First problem as a message, or '' when the rows can be saved. */
export function validateRows(rows, mainAudio, audioList) {
const files = new Set(audioList);
if (mainAudio && !files.has(mainAudio)) return t('ttsEditorUnknownAudio', { file: mainAudio });
const seen = new Set();
for (const row of rows) {
if (!row.name) return t('ttsEditorNameMissing', { file: row.audio });
if (row.name === 'main' || FORBIDDEN_IN_NAME.test(row.name)) return t('ttsEditorNameInvalid', { name: row.name });
if (seen.has(row.name)) return t('ttsEditorNameDuplicate', { name: row.name });
seen.add(row.name);
if (!row.audio) return t('ttsEditorAudioMissing', { name: row.name });
if (!files.has(row.audio)) return t('ttsEditorUnknownAudio', { file: row.audio });
}
return '';
}
+44 -17
View File
@@ -3,6 +3,7 @@ import { t } from './interface_settings.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
import { buildScriptPackage, comboValueForPath, joinSegments, splitScriptLines, splitSegmentAt, usableEmotions } from './audio_script.js';
import { isScriptTarget, targetForNode } from './audio_node_targets.js';
import { engineById } from './audio_engines.js';
import { bindMaterialDrag } from './material_drag.js';
/**
@@ -20,6 +21,7 @@ const ICONS = {
};
const state = {
engine: 'f5', // audio_engines.js id; decides which nodes the bundle may go to
groups: [],
groupKey: null,
lines: [], // { text, emotion }
@@ -62,7 +64,11 @@ export function isScriptDirectorActive() {
}
/** Called by the studio after each voice fetch; keeps the chosen character when it still exists. */
export function updateScriptDirectorVoices(groups, preferredGroup = null) {
export function updateScriptDirectorVoices(groups, preferredGroup = null, engine = null) {
if (engine && engine !== state.engine) {
state.engine = engine;
state.groupKey = null;
}
state.groups = Array.isArray(groups) ? groups : [];
const exists = key => state.groups.some(group => group.group === key);
if (!exists(state.groupKey)) {
@@ -92,6 +98,18 @@ function selectedGroup() {
return state.groups.find(group => group.group === state.groupKey) || null;
}
/** Node name shown in every hint, e.g. "F5-TTS" or "GPT-SoVITS". */
function nodeLabel() {
return engineById(state.engine)?.label || state.engine;
}
/** Engine-specific wording for the few messages whose advice differs per engine. */
function engineKey(key) {
return state.engine === 'gpt_sovits' && ['scriptDirectorMainMissing', 'scriptDirectorNoCharacters'].includes(key)
? `${key}GptSovits`
: key;
}
function emotionLabel(emotion) {
return emotion === 'main' ? t('scriptDirectorMainEmotion') : emotion;
}
@@ -126,7 +144,8 @@ function createPanel() {
const header = el('div', 'anomalous-sd-header');
const titleGroup = el('div');
titleGroup.append(el('div', 'anomalous-sd-title', t('scriptDirectorTitle')), el('div', 'anomalous-sd-subtitle', t('scriptDirectorSubtitle')));
const subtitle = el('div', 'anomalous-sd-subtitle');
titleGroup.append(el('div', 'anomalous-sd-title', t('scriptDirectorTitle')), subtitle);
header.append(titleGroup, button('anomalous-sd-close-btn', '×', closeScriptDirector, t('scriptDirectorClose')));
const characterBar = el('label', 'anomalous-sd-character-bar');
@@ -176,21 +195,25 @@ function createPanel() {
const preview = el('pre', 'anomalous-sd-package-preview');
const actions = el('div', 'anomalous-sd-package-actions');
const dragHandle = el('div', 'anomalous-sd-drag-handle');
dragHandle.title = t('scriptDirectorDragHint');
dragHandle.innerHTML = ICONS.GRIP;
dragHandle.append(el('span', '', t('scriptDirectorDragHandle')));
bindPackageDrag(dragHandle);
const copyBtn = button('anomalous-sd-btn', t('scriptDirectorCopy'), copyScript);
const pushBtn = button('anomalous-sd-btn accent', t('scriptDirectorInject'), pushToNode, t('scriptDirectorPushHint'));
const pushBtn = button('anomalous-sd-btn accent', t('scriptDirectorInject'), pushToNode);
actions.append(dragHandle, copyBtn, pushBtn);
footer.append(summary, preview, actions);
panel.append(header, characterBar, characterHint, inputArea, linesBar, linesContainer, footer);
refs = { characterSelect, characterHint, inputArea, textarea, cancelEdit, linesBar, linesCount, linesContainer, footer, summary, preview, dragHandle, copyBtn, pushBtn };
refs = { subtitle, characterSelect, characterHint, inputArea, textarea, cancelEdit, linesBar, linesCount, linesContainer, footer, summary, preview, dragHandle, copyBtn, pushBtn };
}
function renderAll() {
if (!panel) return;
const node = nodeLabel();
refs.subtitle.textContent = t('scriptDirectorSubtitle', { node });
refs.dragHandle.title = t('scriptDirectorDragHint', { node });
refs.pushBtn.title = t('scriptDirectorPushHint', { node });
refs.pushBtn.setAttribute('aria-label', refs.pushBtn.title);
renderCharacterBar();
const hasLines = state.lines.length > 0;
refs.inputArea.hidden = hasLines && !state.editing;
@@ -206,7 +229,8 @@ function renderAll() {
function renderCharacterBar() {
const select = refs.characterSelect;
select.replaceChildren(...state.groups.map(group => {
const option = el('option', '', group.folder && group.folder !== 'F5-TTS' ? `${group.character} · ${group.folder}` : group.character);
const showFolder = group.engine !== 'gpt_sovits' && group.folder && group.folder !== 'F5-TTS';
const option = el('option', '', showFolder ? `${group.character} · ${group.folder}` : group.character);
option.value = group.group;
return option;
}));
@@ -215,8 +239,10 @@ function renderCharacterBar() {
const group = selectedGroup();
let hint = '';
if (!state.groups.length) hint = t('scriptDirectorNoCharacters');
else if (group && !usableEmotions(group).includes('main')) hint = t('audioMainMissing', { file: `${group.character}.wav` });
if (!state.groups.length) hint = t(engineKey('scriptDirectorNoCharacters'));
else if (group && !usableEmotions(group).includes('main')) {
hint = state.engine === 'gpt_sovits' ? t('scriptDirectorMainMissingGptSovits') : t('audioMainMissing', { file: `${group.character}.wav` });
}
else if (group) hint = t('scriptDirectorEmotionsAvailable', { emotions: usableEmotions(group).map(emotionLabel).join(' · ') });
refs.characterHint.textContent = hint;
refs.characterHint.classList.toggle('is-warning', Boolean(group && !usableEmotions(group).includes('main')) || !state.groups.length);
@@ -322,7 +348,7 @@ function renderPackage() {
refs.summary.textContent = t('scriptDirectorPackageSummary', { count: pkg.lineCount, emotions: used.join(' · ') });
refs.preview.textContent = pkg.speech;
} else {
refs.summary.textContent = t(pkg.error);
refs.summary.textContent = t(engineKey(pkg.error), { node: nodeLabel() });
refs.preview.textContent = '';
}
refs.preview.hidden = !ok;
@@ -384,9 +410,10 @@ function flash(btn, text, restore) {
}
function findTargetNode() {
const nodes = (app.graph?._nodes || []).filter(isScriptTarget);
const fits = node => isScriptTarget(node, state.engine);
const nodes = (app.graph?._nodes || []).filter(fits);
if (!nodes.length) return { error: 'scriptDirectorNoNode' };
const selected = Object.values(app.canvas?.selected_nodes || {}).filter(isScriptTarget);
const selected = Object.values(app.canvas?.selected_nodes || {}).filter(fits);
if (selected.length === 1) return { node: selected[0] };
if (nodes.length === 1) return { node: nodes[0] };
return { error: 'scriptDirectorPickNode' };
@@ -397,7 +424,7 @@ async function pushToNode() {
if (pkg.error) return;
const { node, error } = findTargetNode();
if (error) {
await anomalousAlert(t(error));
await anomalousAlert(t(error, { node: nodeLabel() }));
return;
}
if (await applyPackageToNode(node, pkg)) flash(refs.pushBtn, t('scriptDirectorSuccess'), t('scriptDirectorInject'));
@@ -406,11 +433,11 @@ async function pushToNode() {
/** Write sample + speech into one node; re-checks the node after any dialog. Returns true when written. */
async function applyPackageToNode(node, pkg) {
const graph = app.graph;
const target = isScriptTarget(node) ? targetForNode(node) : null;
const target = isScriptTarget(node, state.engine) ? targetForNode(node) : null;
const speechWidget = target && node.widgets?.find(w => w.name === target.speechWidget);
const sampleWidget = target && node.widgets?.find(w => w.name === target.voiceWidget);
if (!speechWidget || !sampleWidget) {
await anomalousAlert(t('scriptDirectorDropNotTts'));
await anomalousAlert(t('scriptDirectorDropNotTts', { node: nodeLabel() }));
return false;
}
if (target.overriddenBy && node.inputs?.some(input => input?.name === target.overriddenBy && input.link != null)) {
@@ -419,7 +446,7 @@ async function applyPackageToNode(node, pkg) {
}
const sampleValue = comboValueForPath(sampleWidget, pkg.sample);
if (sampleValue == null) {
await anomalousAlert(t('scriptDirectorSampleNotListed', { file: pkg.sample }));
await anomalousAlert(t('scriptDirectorSampleNotListed', { file: pkg.sample, node: nodeLabel() }));
return false;
}
const previous = speechWidget.value;
@@ -443,9 +470,9 @@ function bindPackageDrag(handle) {
bindMaterialDrag(handle, dragOwner, {
payload: () => {
const pkg = currentPackage();
return pkg.error ? null : { ...pkg, dragHint: t('scriptDirectorDragHint') };
return pkg.error ? null : { ...pkg, dragHint: t('scriptDirectorDragHint', { node: nodeLabel() }) };
},
accepts: node => isScriptTarget(node),
accepts: node => isScriptTarget(node, state.engine),
drop: async node => {
if (await applyPackageToNode(node, currentPackage())) flash(refs.pushBtn, t('scriptDirectorSuccess'), t('scriptDirectorInject'));
},
+210
View File
@@ -1144,3 +1144,213 @@
.anomalous-character-voice-header.is-draggable:active {
cursor: grabbing;
}
/* ---------- Engine switch (F5-TTS / GPT-SoVITS) ---------- */
.anomalous-audio-engine-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
padding: 10px 0 12px;
}
.anomalous-audio-engine-tabs {
display: inline-flex;
padding: 3px;
gap: 3px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.anomalous-audio-engine-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: #94a3b8;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.anomalous-audio-engine-tab:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.05);
}
.anomalous-audio-engine-tab.active {
color: #f8fafc;
background: rgba(129, 140, 248, 0.18);
box-shadow: inset 0 0 0 1px rgba(129, 140, 248, 0.45);
}
.anomalous-audio-engine-tab.is-missing span:first-child {
opacity: 0.7;
}
.anomalous-audio-engine-badge {
padding: 1px 6px;
border-radius: 999px;
border: 1px dashed rgba(148, 163, 184, 0.5);
color: #94a3b8;
font-size: 10px;
font-weight: 500;
}
.anomalous-audio-engine-targets {
color: #64748b;
font-size: 11px;
}
/* ---------- "Not installed" card ---------- */
.anomalous-audio-install-card {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 560px;
margin: 28px auto;
padding: 18px 20px;
border-radius: 12px;
border: 1px dashed rgba(129, 140, 248, 0.4);
background: rgba(129, 140, 248, 0.05);
color: #cbd5e1;
font-size: 12.5px;
line-height: 1.6;
}
.anomalous-audio-install-card.is-compact {
max-width: none;
margin: 0 0 12px;
padding: 10px 14px;
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.06);
}
.anomalous-audio-install-title {
color: #f1f5f9;
font-size: 13.5px;
font-weight: 700;
}
.anomalous-audio-install-how {
color: #94a3b8;
}
.anomalous-audio-install-link {
align-self: flex-start;
color: #a5b4fc;
text-decoration: underline;
text-underline-offset: 2px;
}
/* ---------- GPT-SoVITS cards ---------- */
.anomalous-character-voice-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.anomalous-audio-tool-btn.is-compact {
padding: 3px 8px;
font-size: 11px;
}
.anomalous-voice-slice-row.is-static {
cursor: default;
}
.anomalous-voice-slice-row.is-static:hover {
transform: none;
}
/* ---------- GPT-SoVITS emotion editor ---------- */
.anomalous-tts-editor {
width: 680px;
}
.anomalous-tts-editor-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.anomalous-tts-section {
margin-top: 6px;
color: #cbd5e1;
font-size: 12px;
font-weight: 700;
}
.anomalous-tts-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.anomalous-tts-row {
display: grid;
grid-template-columns: 110px minmax(0, 1.4fr) auto minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
}
.anomalous-tts-row.is-main {
grid-template-columns: 110px minmax(0, 1.4fr) auto minmax(0, 1fr);
}
.anomalous-tts-row-name {
color: #a5b4fc;
font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.anomalous-tts-row input {
min-width: 0;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.25);
color: #e2e8f0;
font-size: 12px;
}
.anomalous-tts-remove,
.anomalous-tts-add {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: #cbd5e1;
cursor: pointer;
}
.anomalous-tts-remove {
width: 26px;
height: 26px;
}
.anomalous-tts-add {
align-self: flex-start;
padding: 5px 10px;
font-size: 12px;
}
.anomalous-tts-hint {
color: #64748b;
font-size: 11px;
line-height: 1.5;
}
.anomalous-tts-hint[hidden] {
display: none;
}
@media (max-width: 560px) {
.anomalous-tts-row,
.anomalous-tts-row.is-main {
grid-template-columns: 1fr auto;
}
}