Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_audio_tts_editor.js
T
DemonGatanjieuandClaude Opus 5.5 a3ee55dfd2 Edit emotions: list every clip, and let the line follow the clip
The audio boxes are a native datalist, which lists only files whose names contain
the box's text, so a filled box showed one or two clips. Pressing a box now empties
it to list all of them (the file shows as the placeholder) and puts the file back
when nothing is picked; Backspace or Delete really clears it.

Picking another clip kept the old clip's line next to it. The line box now follows:
the line it had for the first clip, the line the node already reads for a clip in
use, or blank, which makes the node read the clip's own .txt / .lab / list line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:39:15 +08:00

258 lines
11 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, 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.
*
* 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 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 read for it
// when it is in use already, or blank, which the node fills from the file's .txt / .lab / list.
const knownText = new Map([raw.reference, ...Object.values(raw.emotions || {})]
.filter(ref => ref?.audio && ref.text).map(ref => [ref.audio, ref.text]));
const linkText = (audio, text) => {
const start = { audio: audio.value.trim(), text: text.value };
audio.addEventListener('input', () => {
const path = audio.value.trim();
if (!audioList.includes(path)) return;
text.value = path === start.audio ? start.text : knownText.get(path) || '';
});
};
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;
// 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 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;
fetchGptSovitsCharacter(name, scope.signal).then(detail => {
if (scope.signal.aborted) return;
audioList = Array.isArray(detail.audio) ? detail.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;
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,
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 '';
}