Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_audio_tts_editor.js
T
DemonGatanjieuandClaude Opus 5.5 4a109c4df8 Edit emotions: search a character's clips by line or file name
A search box above the clips (ui_tts_clip_search.js) finds clips by part of their
line or file name and lists the first 30 with their lines, to hear, use as the main
voice or add as an emotion (the new row gets the clip and its line). The lines come
once per dialog from Anomalous_TTS's GET /anomalous_tts/reference_lines (interface
14, by character name only); with an older node the request fails quietly and the
search covers file names. Picking a clip now fills its line from the same lines,
so the per-clip request is gone. Lines reach the page as text only (textContent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:03:46 +08:00

280 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/**
* 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.
*
* 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.
*/
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 || '';
// 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;
}
/**
* 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;
let audioList = [];
let settings = raw.settings && typeof raw.settings === 'object' ? raw.settings : {};
let loaded = false;
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);
// 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 playPath = (path) => {
player?.pause();
if (!audioList.includes(path)) return;
player = new Audio(ttsAudioUrl(name, path));
player.play().catch(() => {});
};
const play = (input) => playPath(input.value.trim());
// 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;
// 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'));
// 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 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(),
});
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 () => {
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 (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 = !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;
Promise.all([
fetchGptSovitsCharacter(name, scope.signal),
fetchReferenceLines(name, scope.signal).catch(() => null), // the lines only help: never block the files
]).then(([detail, found]) => {
if (scope.signal.aborted) return;
audioList = Array.isArray(detail.audio) ? detail.audio : [];
lines = found || new Map();
search.setClips(audioList, found);
// 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;
saveBtn.disabled = false;
}).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);
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 '';
}