Reference audio editor: every clip listed, main and auxiliary references

"Reference audio and emotions" replaces Edit emotions: a large window with
the voices on top (main voice, emotions, new emotion) and every clip of the
character below, one row each (listen, file and length, its line editable
in place, where else it is used, Main / Aux for the voice picked), with
filters, search and paging. Auxiliary references (timbre averaged in, v1 /
v2 / v2Pro), fixed lines (`lines`) and lengths need Anomalous_TTS
interface 18. tts_voice_refs.js holds the rules; the old clip search and
merge helper go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-20 12:52:19 +08:00
co-authored by Claude Opus 5.5
parent 0a315bf1f8
commit a3ce3cae94
13 changed files with 1115 additions and 445 deletions
+15 -28
View File
@@ -95,9 +95,16 @@ async function getJson(url, signal) {
* Each clip's line as the node finds it (a `Map` path -> line; clips without one are left
* out), or null when the node is older than interface 14 and cannot tell.
*/
export async function fetchReferenceLines(name, signal) {
/**
* Each clip's line as the node finds it and its length in seconds (`seconds`: interface 18,
* empty before): `{ lines: Map, seconds: Map }`, or null when the node cannot tell.
*/
export async function fetchReferenceClips(name, signal) {
const { ok, data } = await getJson(`/anomalous_tts/reference_lines?character=${encodeURIComponent(name)}`, signal);
return ok && data?.lines && typeof data.lines === 'object' ? new Map(Object.entries(data.lines)) : null;
if (!ok || !data?.lines || typeof data.lines !== 'object') return null;
const seconds = data.seconds && typeof data.seconds === 'object'
? Object.entries(data.seconds).filter(([, value]) => Number.isFinite(value)) : [];
return { lines: new Map(Object.entries(data.lines)), seconds: new Map(seconds) };
}
/** Installed = ComfyUI knows the node class (`/object_info/<class>` is `{}` otherwise). */
@@ -189,9 +196,14 @@ export function isTtsInstalled() {
* summary list leaves out because a folder can hold thousands of clips.
*/
export async function fetchGptSovitsCharacter(name, signal) {
return (await fetchGptSovitsDetail(name, signal)).character;
}
/** One character's detail and the node's interface version: `{ character, format }`. */
export async function fetchGptSovitsDetail(name, signal) {
const { ok, status, data } = await getJson(`/anomalous_tts/characters?name=${encodeURIComponent(name)}`, signal);
if (!ok || !data?.character) throw new Error(status === 404 ? `404 ${name}` : `HTTP ${status}`);
return data.character;
return { character: data.character, format: Number(data.format) || 0 };
}
// ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ----------
@@ -215,28 +227,3 @@ export async function saveGptSovitsSettings(name, settings) {
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;
}
+90 -42
View File
@@ -2109,36 +2109,60 @@ export const i18n = {
ttsPronounceAdd: '+ 添加一条',
ttsPronounceFromMissing: '有一条只写了读法({to}),左边要写剧本里的字',
ttsPronounceDuplicate: '“{from}”写了两遍,请合并成一条',
ttsEditorOpen: "编辑情绪",
ttsEditorTitle: "编辑情绪 · {name}",
ttsEditorSubtitle: "为主声音和每种情绪各挑一条参考音频(3~10 秒、有台词最好)。保存后写进角色文件夹里的 anomalous_tts.json。",
ttsEditorAudioPlaceholder: "输入或选择音频文件",
ttsEditorTextPlaceholder: "参考台词(留空自动查找)",
ttsEditorMainSection: "主声音",
ttsEditorEmotionSection: "情绪",
ttsEditorMainAuto: "不设置时,节点会自动使用:{file}",
ttsEditorMainHint: "留空:由节点自动挑一条 3~10 秒的音频",
ttsEditorEmotionPlaceholder: "情绪名,例如 开心",
ttsEditorOpen: "参考音频与情绪",
ttsRefsTitle: "参考音频 · {name}",
ttsRefsSubtitle: "上面选一个声音,下面给它挑参考音频:主参考决定语气和念法,副参考用来融合音色。保存后写进角色文件夹里的 anomalous_tts.json。",
ttsRefsMainVoice: "主声音",
ttsRefsCountMain: "1 主",
ttsRefsCountAux: "1 主 {aux} 副",
ttsRefsCountAuto: "自动",
ttsRefsCountAutoAux: "自动 + {aux} 副",
ttsRefsCountNone: "还没选主参考",
ttsRefsNewEmotion: "新情绪",
ttsRefsNewEmotionPrompt: "新情绪的名字,例如 开心(剧本里标上这个情绪的句子就用它)",
ttsRefsNameMissing: "情绪名不能为空",
ttsRefsRename: "改名",
ttsRefsRenamePrompt: "情绪的新名字(剧本里用旧名字标的句子要跟着改)",
ttsRefsDelete: "删除这个情绪",
ttsRefsDeleteConfirm: "删除情绪“{name}”?音频文件不会删,只是不再用它。",
ttsRefsMainHint: "剧本里没标情绪的句子用主声音。",
ttsRefsMainAuto: "主声音的主参考现在是节点自动挑的({file})。在下面点“主参考”就换成你选的。",
ttsRefsBackToAuto: "改回自动挑",
ttsRefsEmotionHint: "剧本里标了“{name}”的句子用这个声音。",
ttsRefsFromFilename: "这个情绪来自文件名(原名.情绪.wav):可以换主参考、加副参考;要改名或删掉,改文件名。",
ttsRefsSearch: "搜台词或文件名",
ttsRefsFilter_all: "全部 {count}",
ttsRefsFilter_here: "这个声音用的 {count}",
ttsRefsFilter_used: "已用上 {count}",
ttsRefsFilter_noline: "没台词 {count}",
ttsRefsNoLine: "没找到台词,点这里填",
ttsRefsLineBack: "保存后换回文件里的台词",
ttsRefsNeedsUpdate: "副参考和改台词需要 Anomalous_TTS 1.1.0(接口 18),先在 GPT-SoVITS 设置里更新。",
ttsRefsSeconds: "{seconds} 秒",
ttsRefsTooLongShort: "主参考要 3~10 秒;这条可以当副参考。",
ttsRefsUse_main: "{name}·主",
ttsRefsUse_aux: "{name}·副",
ttsRefsUse_auto: "节点自动挑的",
ttsRefsMain: "主参考",
ttsRefsAux: "副参考",
ttsRefsMainHintRow: "设为这个声音的主参考(决定语气和念法,只有一条)",
ttsRefsMainOffHint: "再点一下:改回让节点自动挑",
ttsRefsAuxHintRow: "加为副参考:音色和主参考取平均,可以加好几条,不需要台词",
ttsRefsAuxVersion: "这个角色是 {version} 模型,只有 v1 / v2 / v2Pro 用得上副参考。",
ttsRefsNote: "副参考建议用同一个人、音质干净的 2~4 条。",
ttsRefsNothing: "没有符合的音频。",
ttsRefsShowMore: "再显示一些(还有 {count} 条)",
ttsRefsNoMain: "情绪“{name}”还没选主参考。",
ttsRefsCloseUnsaved: "有改动还没保存,确定关闭吗?",
ttsEditorRemove: "删除这一行",
ttsEditorAdd: "+ 添加情绪",
ttsEditorFilenameEmotions: "另外,文件名里带情绪的音频会被自动识别:{emotions}",
ttsEditorSave: "保存",
ttsEditorSaveFailed: "保存失败:{error}",
ttsEditorLoadingFiles: "正在读取这个角色的音频列表…",
ttsEditorFileCount: "这个角色有 {count} 条音频。",
ttsSearchPlaceholder: "搜台词或文件名,找一条参考音频",
ttsSearchNamesOnly: "搜文件名(Anomalous_TTS 更新后还能搜台词)",
ttsSearchNoLine: "(没有台词)",
ttsSearchUseMain: "设为主声音",
ttsSearchAddEmotion: "加为情绪",
ttsSearchNone: "没有找到。",
ttsSearchMore: "还有 {count} 条,多打几个字缩小范围。",
ttsEditorLoadFailed: "读取音频列表失败:{error}",
ttsEditorUnknownAudio: "{file} 不在这个角色的文件夹里",
ttsEditorNameMissing: "{file} 还没填情绪名",
ttsEditorNameInvalid: "情绪名“{name}”不能用:不能叫 main,也不能带 {} [] 这些符号",
ttsEditorNameDuplicate: "情绪名“{name}”重复了",
ttsEditorAudioMissing: "情绪“{name}”还没选音频",
ttsCharacterError: "这个角色读取失败:{error}",
ttsMainMissing: "没有找到合适的主声音(3~10 秒的参考音频)。点“编辑情绪”选一条。",
ttsEmptyTitle: "还没有角色",
@@ -4777,36 +4801,60 @@ export const i18n = {
ttsPronounceAdd: '+ Add a line',
ttsPronounceFromMissing: 'A line has only a reading ({to}); write the script text on the left',
ttsPronounceDuplicate: '"{from}" is listed twice; keep one line',
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",
ttsEditorOpen: "Reference audio and emotions",
ttsRefsTitle: "Reference audio · {name}",
ttsRefsSubtitle: "Pick a voice on top, then its reference clips below: the main reference sets the tone and way of speaking, auxiliary ones blend in their timbre. Saved to anomalous_tts.json in the character folder.",
ttsRefsMainVoice: "Main voice",
ttsRefsCountMain: "1 main",
ttsRefsCountAux: "1 main, {aux} aux",
ttsRefsCountAuto: "automatic",
ttsRefsCountAutoAux: "automatic + {aux} aux",
ttsRefsCountNone: "no main reference yet",
ttsRefsNewEmotion: "New emotion",
ttsRefsNewEmotionPrompt: "The new emotion's name, e.g. happy (lines marked with it use this voice)",
ttsRefsNameMissing: "An emotion needs a name",
ttsRefsRename: "Rename",
ttsRefsRenamePrompt: "The emotion's new name (lines marked with the old one need it too)",
ttsRefsDelete: "Delete this emotion",
ttsRefsDeleteConfirm: "Delete the emotion “{name}”? The audio files stay; it is just not used any more.",
ttsRefsMainHint: "Lines without an emotion use the main voice.",
ttsRefsMainAuto: "The node picks the main voice's reference itself ({file}). Click Main below to use the one you pick.",
ttsRefsBackToAuto: "Let the node pick again",
ttsRefsEmotionHint: "Lines marked “{name}” use this voice.",
ttsRefsFromFilename: "This emotion comes from a file name (name.emotion.wav): its main reference can change and aux ones can be added; to rename or delete it, rename the file.",
ttsRefsSearch: "Search lines or file names",
ttsRefsFilter_all: "All {count}",
ttsRefsFilter_here: "This voice {count}",
ttsRefsFilter_used: "In use {count}",
ttsRefsFilter_noline: "No line {count}",
ttsRefsNoLine: "No line found: click to type it",
ttsRefsLineBack: "Back to the file's line once saved",
ttsRefsNeedsUpdate: "Auxiliary references and fixed lines need Anomalous_TTS 1.1.0 (interface 18): update it in GPT-SoVITS settings.",
ttsRefsSeconds: "{seconds} s",
ttsRefsTooLongShort: "A main reference needs 3–10 s; this one can be an auxiliary one.",
ttsRefsUse_main: "{name} · main",
ttsRefsUse_aux: "{name} · aux",
ttsRefsUse_auto: "picked by the node",
ttsRefsMain: "Main",
ttsRefsAux: "Aux",
ttsRefsMainHintRow: "This voice's main reference (sets the tone and way of speaking; just one)",
ttsRefsMainOffHint: "Click again to let the node pick",
ttsRefsAuxHintRow: "An auxiliary reference: its timbre is averaged with the main one; any number, no line needed",
ttsRefsAuxVersion: "This character is a {version} model: only v1 / v2 / v2Pro use auxiliary references.",
ttsRefsNote: "Auxiliary references work best from the same speaker, 2–4 clean clips.",
ttsRefsNothing: "No clip matches.",
ttsRefsShowMore: "Show more ({count} left)",
ttsRefsNoMain: "The emotion “{name}” has no main reference yet.",
ttsRefsCloseUnsaved: "There are unsaved changes. Close anyway?",
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}",
ttsEditorLoadingFiles: "Reading this character's audio files…",
ttsEditorFileCount: "{count} audio files in this character.",
ttsSearchPlaceholder: "Search lines or file names for a reference clip",
ttsSearchNamesOnly: "Search file names (update Anomalous_TTS to search lines too)",
ttsSearchNoLine: "(no line)",
ttsSearchUseMain: "Use as main voice",
ttsSearchAddEmotion: "Add as emotion",
ttsSearchNone: "Nothing found.",
ttsSearchMore: "{count} more: type a little more to narrow it down.",
ttsEditorLoadFailed: "Could not read the audio files: {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 characters yet",
+199
View File
@@ -0,0 +1,199 @@
/**
* The rules of the reference audio editor (ui_audio_tts_editor.js), without DOM: a character's
* voices (the main voice and each emotion), each with one main reference (its tone and way of
* speaking) and auxiliary references (Anomalous_TTS interface 18: their timbre is averaged in,
* SoVITS v1 / v2 / v2Pro only), the reference lines fixed here, and how all of it goes back into
* the character's anomalous_tts.json, keeping every field the editor does not know.
*
* A voice: `{ key, name, main, aux, kept, filename, touched }`. `name` is 'main' for the main
* voice; `main` the clip (null on the main voice: the node picks one itself); `kept` the rest
* of its settings entry; `filename` an emotion found from a file name (`X.开心.wav`), written
* into the settings only once it is changed (`touched`).
*/
export const AUX_FORMAT = 18;
export const AUX_VERSIONS = new Set(['v1', 'v2', 'v2Pro', 'v2ProPlus']);
export const REF_MIN_SEC = 3;
export const REF_MAX_SEC = 10;
export const LINE_LIMIT = 500;
export const MAIN = 'main';
const FORBIDDEN_IN_NAME = /[{}[\]]/;
const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
function own(spec) {
const { audio, aux, ...rest } = isObject(spec) ? spec : {};
return rest;
}
function auxList(spec) {
return Array.isArray(spec?.aux) ? [...new Set(spec.aux.filter(path => typeof path === 'string' && path))] : [];
}
/**
* The voices and the fixed lines from the saved settings and the node's view of them (`detail`:
* the character's `reference` and `emotions` as resolved). `lineFixes` (interface 18): a line a
* voice kept in its own `text` becomes that clip's fixed line, so every clip has one line,
* unless it is the line the clip's files give anyway (`found`).
*/
export function readVoices(settings, detail, { lineFixes = false, found = new Map() } = {}) {
const fixes = new Map();
if (lineFixes && isObject(settings?.lines)) {
for (const [path, text] of Object.entries(settings.lines)) if (typeof text === 'string' && text.trim()) fixes.set(path, text);
}
const voice = (key, name, spec, extra = {}) => {
const kept = own(spec);
const main = typeof spec?.audio === 'string' && spec.audio ? spec.audio : null;
if (lineFixes && typeof kept.text === 'string') {
const text = kept.text.trim();
if (text && main && !fixes.has(main) && found.get(main)?.trim() !== text) fixes.set(main, text);
delete kept.text;
}
return { key, name, main, aux: auxList(spec).filter(path => path !== main), kept, filename: false, touched: false, ...extra };
};
const voices = [voice('main', MAIN, settings?.reference)];
const saved = isObject(settings?.emotions) ? settings.emotions : {};
for (const [name, spec] of Object.entries(saved)) {
if (name.trim() && name.trim() !== MAIN) voices.push(voice(`e:${name}`, name, spec));
}
const named = isObject(detail?.emotions) ? detail.emotions : {};
for (const name of Object.keys(named).sort()) {
const ref = named[name];
if (ref?.source !== 'filename' || voices.some(item => item.name === name)) continue;
voices.push(voice(`e:${name}`, name, { audio: ref.audio }, { filename: true }));
}
return { voices, fixes };
}
/** What `audio` is to `voice`: 'main', 'aux' or null. */
export function roleOf(voice, audio) {
if (!voice) return null;
if (voice.main === audio) return 'main';
return voice.aux.includes(audio) ? 'aux' : null;
}
/** `audio` becomes the voice's main reference (and no longer one of its auxiliary ones). */
export function setMain(voice, audio) {
voice.main = audio;
voice.aux = voice.aux.filter(path => path !== audio);
voice.touched = true;
}
/** The main voice back to the node's own pick. */
export function clearMain(voice) {
if (voice.name !== MAIN) return;
voice.main = null;
voice.touched = true;
}
export function toggleAux(voice, audio) {
if (voice.main === audio) return;
voice.aux = voice.aux.includes(audio) ? voice.aux.filter(path => path !== audio) : [...voice.aux, audio];
voice.touched = true;
}
/** Where else `audio` is used: `[{ name, role }]` for the voices other than `except`. */
export function usesOf(voices, audio, except = null) {
const out = [];
for (const voice of voices) {
if (voice === except) continue;
const role = roleOf(voice, audio);
if (role) out.push({ name: voice.name, role });
}
return out;
}
/** A clip fits as a main reference unless its known length is outside 3~10 s. */
export function fitsMain(seconds) {
return !(seconds > 0) || (seconds >= REF_MIN_SEC && seconds <= REF_MAX_SEC);
}
/** An emotion name that can be used: '' or the locale key of what is wrong with it. */
export function nameProblem(name, voices, self = null) {
const clean = String(name || '').trim();
if (!clean) return 'ttsRefsNameMissing';
if (clean === MAIN || FORBIDDEN_IN_NAME.test(clean)) return 'ttsEditorNameInvalid';
if (voices.some(voice => voice !== self && voice.name === clean)) return 'ttsEditorNameDuplicate';
return '';
}
/** The first thing that keeps the voices from being saved, as `{ key, values }`, or null. */
export function voicesProblem(voices, audioList) {
const files = new Set(audioList);
const seen = new Set();
for (const voice of voices) {
if (voice.name !== MAIN) {
if (seen.has(voice.name)) return { key: 'ttsEditorNameDuplicate', values: { name: voice.name } };
seen.add(voice.name);
if (!voice.main) return { key: 'ttsRefsNoMain', values: { name: voice.name } };
}
for (const path of [voice.main, ...voice.aux]) {
if (path && !files.has(path)) return { key: 'ttsEditorUnknownAudio', values: { file: path } };
}
}
return null;
}
/**
* The settings to save: the previous ones with the voices and the fixed lines written in.
* `aux` / `lines`: the node reads them (interface 18); without, they are left as they were.
*/
export function writeSettings(previous, voices, fixes, { aux = false, lines = false } = {}) {
const next = { ...(isObject(previous) ? previous : {}), format: 1 };
const entry = (voice) => {
const out = { ...voice.kept };
if (voice.main) out.audio = voice.main;
if (aux && voice.aux.length) out.aux = [...voice.aux];
return out;
};
const main = voices.find(voice => voice.name === MAIN);
const reference = main ? entry(main) : {};
if (!aux && Array.isArray(previous?.reference?.aux)) reference.aux = previous.reference.aux;
if (Object.keys(reference).length) next.reference = reference; else delete next.reference;
const emotions = {};
const before = isObject(previous?.emotions) ? previous.emotions : {};
for (const voice of voices) {
if (voice.name === MAIN || !voice.main || (voice.filename && !voice.touched)) continue;
emotions[voice.name] = entry(voice);
if (!aux && Array.isArray(before[voice.name]?.aux)) emotions[voice.name].aux = before[voice.name].aux;
}
if (Object.keys(emotions).length) next.emotions = emotions; else delete next.emotions;
if (lines) {
const table = {};
for (const [path, text] of fixes) if (text.trim()) table[path] = text.trim().slice(0, LINE_LIMIT);
if (Object.keys(table).length) next.lines = table; else delete next.lines;
}
return next;
}
/**
* The clips the list shows, best first for the voice picked: its main reference, its
* auxiliary ones, then the others in folder order. `filter`: 'all' | 'here' (this voice) |
* 'used' (any voice) | 'noline'; `query` matches the path or the line.
*/
export function listClips(audioList, { voice, voices, lineOf, filter = 'all', query = '' }) {
const words = String(query).trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = path => words.every(word => path.toLowerCase().includes(word) || lineOf(path).toLowerCase().includes(word));
const keep = {
all: () => true,
here: path => Boolean(roleOf(voice, path)),
used: path => voices.some(item => roleOf(item, path)),
noline: path => !lineOf(path),
}[filter] || (() => true);
const rank = (path) => {
const role = roleOf(voice, path);
return role === 'main' ? 0 : role === 'aux' ? 1 : 2;
};
return audioList.filter(path => keep(path) && matches(path))
.map((path, at) => ({ path, at, rank: rank(path) }))
.sort((a, b) => a.rank - b.rank || a.at - b.at)
.map(item => item.path);
}
/** "1 main, 2 aux" for a voice tab, as `{ key, values }`. */
export function voiceCount(voice) {
if (!voice.main) return { key: voice.aux.length ? 'ttsRefsCountAutoAux' : 'ttsRefsCountAuto', values: { aux: voice.aux.length } };
return { key: voice.aux.length ? 'ttsRefsCountAux' : 'ttsRefsCountMain', values: { aux: voice.aux.length } };
}
+341 -182
View File
@@ -1,23 +1,32 @@
import { t } from './interface_settings.js';
import { createViewScope } from './ui_lifecycle.js';
import { anomalousAlert } from './ui_dialog.js';
import { fetchGptSovitsCharacter, fetchReferenceLines, mergeGptSovitsSettings, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js';
import { createClipSearch } from './ui_tts_clip_search.js';
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
import { fetchGptSovitsDetail, fetchReferenceClips, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js';
import {
AUX_FORMAT, AUX_VERSIONS, LINE_LIMIT, MAIN, clearMain, fitsMain, listClips, nameProblem, readVoices, roleOf,
setMain, toggleAux, usesOf, voiceCount, voicesProblem, writeSettings,
} from './tts_voice_refs.js';
/**
* GPT-SoVITS (Anomalous_TTS) emotion editor: pick one reference audio from the
* character's folder for the main voice and for each emotion, typed or found by its
* line in the clip search (ui_tts_clip_search.js). The node owns the
* file; this modal only sends the new settings through its API, keeping every
* field it does not edit.
* GPT-SoVITS (Anomalous_TTS) reference audio editor, opened from a character card: the voices
* on top (the main voice and each emotion, "+ New emotion"), and below every clip of the
* character, one row each (listen, file and length, its line, where else it is used), where the
* voice picked gets its **main reference** (its tone; one, with a line, 3~10 s) and its
* **auxiliary references** (their timbre is averaged in; any number, no line needed). A clip's
* line can be fixed right in its row. The rules are in tts_voice_refs.js; the node owns the
* file, this window only sends the new settings through its API, keeping every field it does
* not edit. Auxiliary references and fixed lines need Anomalous_TTS interface 18 (and
* auxiliary references a v1 / v2 / v2Pro model); otherwise those controls are off and say why.
*
* The studio list carries no file lists (a folder can hold thousands of clips), so
* the modal opens at once and fetches this character's files (and their lines) in the
* background; saving waits until they are here.
* The window opens at once and reads the character's clips, their lines and lengths in the
* background (a folder can hold a thousand clips: the list shows the first ones, then more).
*/
const SHOWN_FIRST = 60;
const SHOW_MORE = 120;
const FILTERS = ['all', 'here', 'used', 'noline'];
let activeScope = null;
const FORBIDDEN_IN_NAME = /[{}[\]]/;
function el(tag, className, text) {
const node = document.createElement(tag);
@@ -34,41 +43,10 @@ function button(className, label, onClick, title) {
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 || '';
// The browser lists only the files matching the box's text, so a filled box offered one or
// two. Pressing it empties the box to list them all (the file shows greyed meanwhile);
// leaving without a pick puts the file back, unless it was deleted with a key.
let kept = '';
input.addEventListener('mousedown', () => {
if (!input.value) return;
kept = input.value;
input.placeholder = kept;
input.value = '';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') kept = '';
});
input.addEventListener('blur', () => {
if (!input.value) input.value = kept;
kept = '';
input.placeholder = t('ttsEditorAudioPlaceholder');
});
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;
}
const say = ({ key, values }) => t(key, values);
const voiceLabel = voice => (voice.name === MAIN ? t('ttsRefsMainVoice') : voice.name);
const baseName = path => path.split('/').pop();
const folderOf = path => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
/**
* Open the editor for one GPT-SoVITS voice group (audio_engines.gptSovitsGroups shape).
@@ -80,21 +58,40 @@ export function openGptSovitsEditor(group, { onSaved } = {}) {
activeScope = scope;
const raw = group.raw || {};
const name = raw.name || group.character;
let audioList = [];
let settings = raw.settings && typeof raw.settings === 'object' ? raw.settings : {};
let loaded = false;
const state = {
loaded: false,
format: 0,
version: raw.version || null,
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
audio: [],
found: new Map(), // path -> the line the node finds (a fixed one first)
seconds: new Map(), // path -> length (interface 18)
voices: [],
fixes: new Map(), // path -> the line fixed here
cleared: new Set(), // fixed lines taken back here (the file's line comes back once saved)
voiceKey: 'main',
filter: 'all',
query: '',
shown: SHOWN_FIRST,
dirty: false,
playing: null, // { path, button }
};
let player = null;
const overlay = el('div', 'anomalous-voice-modal-overlay');
const modal = el('div', 'anomalous-voice-modal anomalous-tts-editor');
const modal = el('div', 'anomalous-voice-modal anomalous-tts-refs');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => {
player?.pause();
overlay.remove();
if (activeScope === scope) activeScope = null;
});
const close = async () => {
if (state.dirty && !await anomalousConfirm(t('ttsRefsCloseUnsaved'))) return;
scope.dispose();
};
overlay.onclick = (e) => { if (e.target === overlay) close(); };
scope.listen(window, 'keydown', (e) => {
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
@@ -102,178 +99,340 @@ export function openGptSovitsEditor(group, { onSaved } = {}) {
close();
}, true);
// A picked file gets its own line: the one it had in this dialog, the line the node uses for
// it when it is in use, else the one the node finds for it (blank with a node too old to
// tell, which then reads it when it speaks). A blank box shows the line greyed, unsaved.
const knownText = new Map([raw.reference, ...Object.values(raw.emotions || {})]
.filter(ref => ref?.audio && ref.text).map(ref => [ref.audio, ref.text]));
let lines = new Map();
const lineOf = path => knownText.get(path) || lines.get(path) || '';
const linkText = (audio, text) => {
const start = { audio: audio.value.trim(), text: text.value };
const showLine = () => { text.placeholder = lineOf(audio.value.trim()) || t('ttsEditorTextPlaceholder'); };
showLine();
audio.addEventListener('input', () => {
const path = audio.value.trim();
if (!audioList.includes(path)) return;
text.value = path === start.audio ? start.text : lineOf(path);
showLine();
});
const voice = () => state.voices.find(item => item.key === state.voiceKey) || state.voices[0];
const lineOf = path => (state.fixes.has(path) ? state.fixes.get(path) : state.cleared.has(path) ? '' : state.found.get(path) || '');
const canAux = () => state.format >= AUX_FORMAT && (!state.version || AUX_VERSIONS.has(state.version));
const canFix = () => state.format >= AUX_FORMAT;
const changed = () => {
state.dirty = true;
saveBtn.disabled = !state.loaded;
};
const playPath = (path) => {
// ---------- listening ----------
function stopPlaying() {
player?.pause();
if (!audioList.includes(path)) return;
player = null;
if (state.playing) state.playing.button.classList.remove('is-playing');
state.playing = null;
}
function play(path, btn) {
const again = state.playing?.path === path;
stopPlaying();
if (again) return;
player = new Audio(ttsAudioUrl(name, path));
player.play().catch(() => {});
};
const play = (input) => playPath(input.value.trim());
state.playing = { path, button: btn };
btn.classList.add('is-playing');
const done = () => { if (state.playing?.path === path) stopPlaying(); };
player.onended = done;
player.onerror = done;
player.play().catch(done);
}
// ---------- header ----------
// 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')),
el('div', 'anomalous-voice-modal-title', t('ttsRefsTitle', { name })),
el('div', 'anomalous-voice-modal-subtitle', t('ttsRefsSubtitle')),
);
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;
const status = el('div', 'anomalous-tts-hint anomalous-tts-status', t('ttsEditorLoadingFiles'));
// Main reference
const mainRow = el('div', 'anomalous-tts-row is-main');
const mainAudio = audioInput(listId, settings.reference?.audio);
const mainText = textInput(settings.reference?.text);
linkText(mainAudio, mainText);
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'));
// ---------- the voices ----------
// 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);
linkText(audio, 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 voicesBar = el('div', 'anomalous-tts-refs-voices');
const voiceTools = el('div', 'anomalous-tts-refs-voice-tools');
function renderVoices() {
const tabs = state.voices.map((item) => {
const tab = button(`anomalous-tts-refs-voice${item.key === state.voiceKey ? ' is-current' : ''}`, '', () => {
state.voiceKey = item.key;
state.shown = SHOWN_FIRST;
renderAll();
});
tab.append(el('span', 'anomalous-tts-refs-voice-name', voiceLabel(item)), el('span', 'anomalous-tts-refs-voice-count', say(item.name !== MAIN && !item.main ? { key: 'ttsRefsCountNone' } : voiceCount(item))));
if (item.name !== MAIN && !item.main) tab.classList.add('is-missing');
return tab;
});
tabs.push(button('anomalous-tts-refs-voice is-new', `+ ${t('ttsRefsNewEmotion')}`, addEmotion));
voicesBar.replaceChildren(...tabs);
const current = voice();
const tools = [];
if (current?.name === MAIN) {
tools.push(el('span', '', current.main ? t('ttsRefsMainHint') : t('ttsRefsMainAuto', { file: raw.reference?.audio ? baseName(raw.reference.audio) : '—' })));
if (current.main) tools.push(button('anomalous-sd-link-btn', t('ttsRefsBackToAuto'), () => { clearMain(current); changed(); renderAll(); }));
} else if (current) {
tools.push(el('span', '', current.filename ? t('ttsRefsFromFilename') : t('ttsRefsEmotionHint', { name: current.name })));
if (!current.filename) {
tools.push(
button('anomalous-sd-link-btn', t('ttsRefsRename'), () => renameEmotion(current)),
button('anomalous-sd-link-btn is-danger', t('ttsRefsDelete'), () => deleteEmotion(current)),
);
}
}
voiceTools.replaceChildren(...tools);
}
const addBtn = button('anomalous-tts-add', t('ttsEditorAdd'), () => addRow().focus());
const search = createClipSearch({
play: playPath,
onMain: (path) => {
mainAudio.value = path;
mainAudio.dispatchEvent(new Event('input'));
},
onEmotion: (path) => addRow({ audio: path, text: lineOf(path) }).focus(),
async function addEmotion() {
const value = await anomalousPrompt(t('ttsRefsNewEmotionPrompt'), '', t('ttsRefsNewEmotion'));
if (value === null || value === undefined) return;
const problem = nameProblem(value, state.voices);
if (problem) return anomalousAlert(t(problem, { name: String(value).trim(), file: '' }));
const item = { key: `e:${value.trim()}:${Date.now()}`, name: value.trim(), main: null, aux: [], kept: {}, filename: false, touched: true };
state.voices.push(item);
state.voiceKey = item.key;
state.filter = 'all';
changed();
renderAll();
}
async function renameEmotion(item) {
const value = await anomalousPrompt(t('ttsRefsRenamePrompt'), item.name, t('ttsRefsRename'));
if (value === null || value === undefined || value.trim() === item.name) return;
const problem = nameProblem(value, state.voices, item);
if (problem) return anomalousAlert(t(problem, { name: String(value).trim(), file: '' }));
item.name = value.trim();
changed();
renderAll();
}
async function deleteEmotion(item) {
if (!await anomalousConfirm(t('ttsRefsDeleteConfirm', { name: item.name }))) return;
state.voices = state.voices.filter(other => other !== item);
state.voiceKey = 'main';
changed();
renderAll();
}
// ---------- the clips ----------
const toolbar = el('div', 'anomalous-tts-refs-toolbar');
const filters = el('div', 'anomalous-tts-refs-filters');
const search = el('input', 'anomalous-tts-refs-search');
search.type = 'search';
search.placeholder = t('ttsRefsSearch');
search.addEventListener('input', () => {
state.query = search.value;
state.shown = SHOWN_FIRST;
renderList();
});
toolbar.append(filters, search);
const list = el('div', 'anomalous-tts-refs-list');
const note = el('div', 'anomalous-tts-hint anomalous-tts-refs-note');
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;
function renderFilters() {
const current = voice();
const count = {
all: state.audio.length,
here: current ? [current.main, ...current.aux].filter(Boolean).length : 0,
used: state.audio.filter(path => state.voices.some(item => roleOf(item, path))).length,
noline: state.audio.filter(path => !lineOf(path)).length,
};
filters.replaceChildren(...FILTERS.map(id => button(
`anomalous-tts-refs-filter${state.filter === id ? ' is-on' : ''}`,
t(`ttsRefsFilter_${id}`, { count: count[id] }),
() => { state.filter = id; state.shown = SHOWN_FIRST; renderList(); renderFilters(); },
)));
}
function lineBox(path) {
const input = el('input', 'anomalous-tts-refs-line');
input.type = 'text';
input.maxLength = LINE_LIMIT;
input.value = lineOf(path);
input.placeholder = state.cleared.has(path) ? t('ttsRefsLineBack') : t('ttsRefsNoLine');
input.disabled = !canFix();
if (!canFix()) input.title = t('ttsRefsNeedsUpdate');
input.classList.toggle('is-fixed', state.fixes.has(path));
input.addEventListener('change', () => {
const text = input.value.trim();
if (!text) {
if (state.fixes.delete(path)) state.cleared.add(path);
} else if (text !== lineOf(path)) {
state.fixes.set(path, text);
state.cleared.delete(path);
} else {
return;
}
changed();
renderRow(path);
renderFilters();
});
return input;
}
const rows = new Map();
function rowFor(path) {
const current = voice();
const role = roleOf(current, path);
const seconds = state.seconds.get(path) || 0;
const row = el('div', `anomalous-tts-refs-row${role ? ` is-${role}` : ''}`);
const playBtn = button('anomalous-audio-play-btn', '▶', () => play(path, playBtn), t('audioPlay'));
if (state.playing?.path === path) {
playBtn.classList.add('is-playing');
state.playing.button = playBtn;
}
const file = el('div', 'anomalous-tts-refs-file');
const nameLine = el('div', 'anomalous-tts-refs-file-name', baseName(path));
nameLine.title = path;
if (seconds) {
const length = el('span', `anomalous-tts-refs-seconds${fitsMain(seconds) ? '' : ' is-off'}`, t('ttsRefsSeconds', { seconds: seconds.toFixed(1) }));
if (!fitsMain(seconds)) length.title = t('ttsRefsTooLongShort');
nameLine.append(length);
}
file.append(nameLine);
if (folderOf(path)) file.append(el('div', 'anomalous-tts-refs-file-folder', folderOf(path)));
const middle = el('div', 'anomalous-tts-refs-middle');
middle.append(lineBox(path));
const uses = usesOf(state.voices, path, current);
if (current?.name === MAIN && !current.main && raw.reference?.audio === path) uses.unshift({ name: MAIN, role: 'auto' });
if (uses.length) {
const tags = el('div', 'anomalous-tts-refs-uses');
tags.append(...uses.map(use => el('span', `anomalous-tts-refs-use is-${use.role}`, t(`ttsRefsUse_${use.role}`, { name: use.name === MAIN ? t('ttsRefsMainVoice') : use.name }))));
middle.append(tags);
}
const roles = el('div', 'anomalous-tts-refs-roles');
const mainBtn = button(`anomalous-tts-refs-role${role === 'main' ? ' is-on' : ''}`, role === 'main' ? `◉ ${t('ttsRefsMain')}` : `○ ${t('ttsRefsMain')}`, () => {
if (role === 'main') {
if (current.name !== MAIN) return;
clearMain(current);
} else {
setMain(current, path);
}
changed();
renderAll(false);
}, role === 'main' && current?.name === MAIN ? t('ttsRefsMainOffHint') : t('ttsRefsMainHintRow'));
mainBtn.disabled = !current || (!fitsMain(seconds) && role !== 'main');
if (!fitsMain(seconds) && role !== 'main') mainBtn.title = t('ttsRefsTooLongShort');
const auxBtn = button(`anomalous-tts-refs-role is-aux${role === 'aux' ? ' is-on' : ''}`, role === 'aux' ? `☑ ${t('ttsRefsAux')}` : `☐ ${t('ttsRefsAux')}`, () => {
toggleAux(current, path);
changed();
renderAll(false);
}, t('ttsRefsAuxHintRow'));
auxBtn.disabled = !current || !canAux() || role === 'main';
if (!canAux()) auxBtn.title = auxReason();
roles.append(mainBtn, auxBtn);
row.append(playBtn, file, middle, roles);
return row;
}
function renderRow(path) {
const old = rows.get(path);
if (!old) return;
const next = rowFor(path);
old.replaceWith(next);
rows.set(path, next);
}
// The order is set when the voice, the filter or the search changes, not on each click,
// so a row does not move away under the pointer.
let order = [];
function renderList(reorder = true) {
if (!state.loaded) return;
if (reorder) order = listClips(state.audio, { voice: voice(), voices: state.voices, lineOf, filter: state.filter, query: state.query });
rows.clear();
const shown = order.slice(0, state.shown);
const items = shown.map((path) => {
const row = rowFor(path);
rows.set(path, row);
return row;
});
if (!order.length) items.push(el('div', 'anomalous-tts-refs-empty', t('ttsRefsNothing')));
if (order.length > shown.length) {
items.push(button('anomalous-tts-refs-more', t('ttsRefsShowMore', { count: order.length - shown.length }), () => {
state.shown += SHOW_MORE;
renderList(false);
}));
}
list.replaceChildren(...items);
}
function auxReason() {
if (state.format < AUX_FORMAT) return t('ttsRefsNeedsUpdate');
return t('ttsRefsAuxVersion', { version: state.version });
}
function renderNote() {
const parts = [t('ttsRefsNote')];
if (!canAux()) parts.push(auxReason());
note.textContent = parts.join(' ');
}
function renderAll(reorder = true) {
renderVoices();
renderFilters();
renderList(reorder);
renderNote();
}
// ---------- saving ----------
// Footer
const footer = el('div', 'anomalous-voice-modal-footer');
const saveBtn = button('anomalous-voice-modal-submit', t('ttsEditorSave'), async () => {
if (!loaded) return;
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 (!state.loaded) return;
const problem = voicesProblem(state.voices, state.audio);
if (problem) {
await anomalousAlert(problem);
const missing = state.voices.find(item => item.name === problem.values?.name && !item.main);
if (missing) {
state.voiceKey = missing.key;
renderAll();
}
await anomalousAlert(say(problem));
return;
}
const next = mergeGptSovitsSettings(settings, {
main: mainAudio.value.trim() ? { audio: mainAudio.value.trim(), text: mainText.value.trim() } : null,
emotions: rows,
});
const next = writeSettings(state.settings, state.voices, state.fixes, { aux: state.format >= AUX_FORMAT, lines: canFix() });
saveBtn.disabled = true;
try {
const updated = await saveGptSovitsSettings(name, next);
state.dirty = false;
onSaved?.(updated);
close();
scope.dispose();
} catch (e) {
if (!scope.signal.aborted) await anomalousAlert(t('ttsEditorSaveFailed', { error: e.message }));
} finally {
if (!scope.signal.aborted) saveBtn.disabled = !loaded;
if (!scope.signal.aborted) saveBtn.disabled = !state.loaded;
}
});
footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), saveBtn);
const status = el('div', 'anomalous-tts-hint anomalous-tts-status', t('ttsEditorLoadingFiles'));
saveBtn.disabled = true;
footer.append(note, button('anomalous-voice-modal-cancel', t('dialogCancel'), close), saveBtn);
Promise.all([
fetchGptSovitsCharacter(name, scope.signal),
fetchReferenceLines(name, scope.signal).catch(() => null), // the lines only help: never block the files
]).then(([detail, found]) => {
fetchGptSovitsDetail(name, scope.signal),
fetchReferenceClips(name, scope.signal).catch(() => null), // the lines only help: never block the files
]).then(([{ character, format }, clips]) => {
if (scope.signal.aborted) return;
audioList = Array.isArray(detail.audio) ? detail.audio : [];
lines = found || new Map();
search.setClips(audioList, found);
state.format = format;
state.version = character.version || state.version;
state.audio = Array.isArray(character.audio) ? character.audio : [];
// The detail is read from disk now: keep its unknown fields when saving.
if (detail.settings && typeof detail.settings === 'object') settings = detail.settings;
datalist.replaceChildren(...audioList.map(path => { const option = el('option'); option.value = path; return option; }));
status.textContent = t('ttsEditorFileCount', { count: audioList.length });
loaded = true;
if (character.settings && typeof character.settings === 'object') state.settings = character.settings;
state.found = clips?.lines || new Map();
state.seconds = clips?.seconds || new Map();
({ voices: state.voices, fixes: state.fixes } = readVoices(state.settings, character, { lineFixes: canFix(), found: state.found }));
status.textContent = t('ttsEditorFileCount', { count: state.audio.length });
state.loaded = true;
saveBtn.disabled = false;
renderAll();
}).catch(e => {
if (scope.signal.aborted) return;
status.textContent = t('ttsEditorLoadFailed', { error: e.message || String(e) });
status.classList.add('is-error');
});
const body = el('div', 'anomalous-tts-editor-body');
body.append(
status,
search.element,
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);
const top = el('div', 'anomalous-tts-refs-top');
top.append(status, voicesBar, voiceTools, toolbar);
modal.append(header, top, list, 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 '';
search.focus();
return () => scope.dispose();
}
-82
View File
@@ -1,82 +0,0 @@
import { t } from './interface_settings.js';
/**
* Edit emotions' clip search: finds a character's clips by file name or by their line (the
* lines come from Anomalous_TTS interface 14; with an older node only names are searched)
* and lists the first matches to hear, to use as the main voice or to add as an emotion.
*/
const SHOWN = 30;
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 node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
if (title) node.title = title;
return node;
}
/** `play(path)`, `onMain(path)` and `onEmotion(path)` act on a match. Returns { element, setClips }. */
export function createClipSearch({ play, onMain, onEmotion }) {
const box = el('div', 'anomalous-tts-search');
const input = el('input', 'anomalous-tts-search-input');
input.type = 'search';
input.placeholder = t('ttsSearchPlaceholder');
input.disabled = true;
const results = el('div', 'anomalous-tts-search-results');
box.append(input, results);
let clips = [];
let lines = new Map();
const row = (path) => {
const line = el('div', 'anomalous-tts-search-row');
const copy = el('div', 'anomalous-tts-search-copy');
copy.title = path;
copy.append(
el('span', 'anomalous-tts-search-name', path.split('/').pop()),
el('span', 'anomalous-tts-search-line', lines.get(path) || t('ttsSearchNoLine')),
);
line.append(
button('anomalous-audio-play-btn', '▶', () => play(path), t('audioPlay')),
copy,
button('anomalous-tts-search-use', t('ttsSearchUseMain'), () => onMain(path)),
button('anomalous-tts-search-use', t('ttsSearchAddEmotion'), () => onEmotion(path)),
);
return line;
};
const render = () => {
const words = input.value.trim().toLowerCase().split(' ').filter(Boolean);
if (!words.length) {
results.replaceChildren();
return;
}
const found = clips.filter(path => {
const hay = `${path} ${lines.get(path) || ''}`.toLowerCase();
return words.every(word => hay.includes(word));
});
results.replaceChildren(...found.slice(0, SHOWN).map(row));
if (!found.length) results.append(el('div', 'anomalous-tts-hint', t('ttsSearchNone')));
else if (found.length > SHOWN) results.append(el('div', 'anomalous-tts-hint', t('ttsSearchMore', { count: found.length - SHOWN })));
};
input.addEventListener('input', render);
return {
element: box,
/** The character's clips and their lines (`null`: the node sends no lines, names only). */
setClips(list, lineMap) {
clips = list;
lines = lineMap || new Map();
input.placeholder = t(lineMap ? 'ttsSearchPlaceholder' : 'ttsSearchNamesOnly');
input.disabled = false;
render();
},
};
}