Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_audio_uploader.js
T
DemonGatanjieuandClaude Opus 5.5 66979f698a fix(audio): align voices with ComfyUI-F5-TTS multi-voice layout and repair studio UI
ComfyUI-F5-TTS resolves {happy} to <sample stem>.happy.wav beside the selected
sample, so `Arona_happy.wav` + `{Arona_happy}` could never run. Voices now use
Character.wav (main, {main}) and Character.<emotion>.wav ({emotion}); the
catalog groups a main voice with its variants and marks tags that F5-TTS
cannot resolve (missing main, missing .txt, different format).

- script director: one character per script, untagged lines pinned to {main},
  pushes sample (main voice, node's own combo spelling) and speech together,
  confirms before overwriting and re-checks the node after the dialog
- pure rules (splitting, composing, planning, combo spelling) in audio_script.js
- one module instance per file: drop ?v= imports that duplicated
  ui_audio_gallery/ui_audio_studio state, so studio playback stops the gallery
- all user/file-derived text goes through textContent (no innerHTML)
- studio/sidebar/gallery renders ignore stale responses; errors are shown
- gallery: count badge no longer overwrites the title, paging via "load more",
  deletion errors surfaced, timeupdate instead of a 100ms timer
- sidebar: filter selection updates in place instead of refetching; the header
  tabs, sidebar and domain switch share browser.switchAudioTab
- uploader: createViewScope lifecycle (Esc/overlay/close release listeners and
  object URLs, Esc no longer reaches the parent), 409 -> confirm -> overwrite,
  sends the pre-romanization text as original_text, no hard-coded character
- workflow template: confirm before replacing the canvas, clear message when
  the template file is missing, align sample paths to the node's combo values
- static styles moved from inline JS into 11-audio-studio.css; strings localized

Verified: tests/audio_script_rules.mjs, tests/test_audio_catalog.py (15), a
jsdom walkthrough of studio/gallery/director/uploader with a stubbed app
(outside the repo), JS syntax and import/export link check. Not yet checked in
a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:02:33 +00:00

297 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, anomalousConfirm } from './ui_dialog.js';
/**
* Voice ingestion modal: pick an audio file, name it as a ComfyUI-F5-TTS voice
* (`Character.wav` for the main voice, `Character.<emotion>.wav` for variants),
* enter its transcript, optionally romanize it, and save it to input/F5-TTS.
*/
const MAIN_VOICE = 'main';
const PRESET_EMOTIONS = [MAIN_VOICE, 'normal', 'happy', 'angry', 'sad', 'surprised', 'whisper'];
const TARGET_SUBFOLDER = 'F5-TTS';
let activeScope = null;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function renderModalHeader(onClose) {
const header = el('div', 'anomalous-voice-modal-header');
const info = el('div', 'anomalous-voice-modal-heading');
info.append(
el('div', 'anomalous-voice-modal-title', t('audioUploadModalTitle')),
el('div', 'anomalous-voice-modal-subtitle', t('audioUploadModalSubtitle')),
);
const closeBtn = el('button', 'anomalous-voice-modal-close', '×');
closeBtn.type = 'button';
closeBtn.title = t('close');
closeBtn.setAttribute('aria-label', t('close'));
closeBtn.onclick = onClose;
header.append(info, closeBtn);
return header;
}
function createDropzone(onFileSelected) {
const dropzone = el('div', 'anomalous-audio-dropzone');
dropzone.tabIndex = 0;
dropzone.setAttribute('role', 'button');
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.wav,.mp3,.flac,.ogg,.m4a';
fileInput.hidden = true;
dropzone.append(
el('div', 'anomalous-audio-dropzone-hint', t('audioDropzoneHint')),
el('div', 'anomalous-audio-dropzone-formats', 'WAV, MP3, FLAC, OGG, M4A (Max 100MB)'),
fileInput,
);
dropzone.onclick = () => fileInput.click();
dropzone.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } };
fileInput.onchange = () => { if (fileInput.files?.[0]) onFileSelected(fileInput.files[0]); };
dropzone.ondragover = (e) => { e.preventDefault(); dropzone.classList.add('dragover'); };
dropzone.ondragleave = () => dropzone.classList.remove('dragover');
dropzone.ondrop = (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
if (e.dataTransfer?.files?.[0]) onFileSelected(e.dataTransfer.files[0]);
};
return dropzone;
}
function createPreviewPlayer(file, objectUrl) {
const wrap = el('div', 'anomalous-voice-preview');
const info = el('div', 'anomalous-voice-preview-info');
info.append(el('span', 'anomalous-voice-preview-name', file.name), el('span', '', `${(file.size / 1024).toFixed(1)} KB`));
const audio = document.createElement('audio');
audio.controls = true;
audio.src = objectUrl;
wrap.append(info, audio);
return wrap;
}
function createField(labelText, control) {
const wrap = el('label', 'anomalous-voice-field');
wrap.append(el('span', 'anomalous-voice-field-label', labelText), control);
return wrap;
}
function createCharacterSection(initialChar) {
const input = el('input', 'anomalous-uploader-input');
input.type = 'text';
input.value = initialChar || '';
input.placeholder = t('audioCharPlaceholder');
return { wrap: createField(t('audioCharLabel'), input), input };
}
function createEmotionSection() {
const input = el('input', 'anomalous-uploader-input');
input.type = 'text';
input.value = MAIN_VOICE;
input.placeholder = t('audioEmotionPlaceholder');
const chipsRow = el('div', 'anomalous-voice-chip-row');
const syncChips = () => chipsRow.querySelectorAll('.anomalous-tag-chip').forEach(chip => {
chip.classList.toggle('active', chip.dataset.value === input.value.trim().toLowerCase());
});
PRESET_EMOTIONS.forEach(emo => {
const chip = el('button', 'anomalous-tag-chip', emo);
chip.type = 'button';
chip.dataset.value = emo;
chip.onclick = () => { input.value = emo; syncChips(); };
chipsRow.appendChild(chip);
});
input.oninput = syncChips;
syncChips();
const wrap = createField(t('audioEmotionLabel'), input);
wrap.append(chipsRow, el('span', 'anomalous-voice-field-hint', t('audioEmotionMainHint')));
return { wrap, input };
}
function createTranscriptSection(scope) {
const textarea = el('textarea', 'anomalous-uploader-textarea');
textarea.rows = 3;
textarea.placeholder = t('audioRefTextPlaceholder');
const originalHint = el('span', 'anomalous-voice-field-hint', t('audioOriginalKeptHint'));
originalHint.hidden = true;
const state = { originalText: '' };
textarea.addEventListener('input', () => {
if (!textarea.value.trim()) {
state.originalText = '';
originalHint.hidden = true;
}
});
const romanizeBtn = el('button', 'anomalous-romanize-btn', t('audioRomanizeBtn'));
romanizeBtn.type = 'button';
romanizeBtn.title = t('audioRomanizeTitle');
romanizeBtn.onclick = async () => {
const source = textarea.value.trim();
if (!source) {
await anomalousAlert(t('audioRomanizeEmptyHint'));
textarea.focus();
return;
}
romanizeBtn.disabled = true;
romanizeBtn.textContent = t('audioRomanizeConverting');
try {
const resp = await fetch('/anomalous/romanize_text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: source }),
signal: scope.signal,
});
const data = await resp.json().catch(() => ({}));
if (scope.signal.aborted) return;
if (!data.success) {
const key = data.code === 'missing_dependency' ? 'audioRomanizeMissingDep' : 'audioRomanizeFailed';
await anomalousAlert(t(key, { error: data.error || `HTTP ${resp.status}` }));
} else if (data.lang === 'unchanged') {
await anomalousAlert(t('audioRomanizeUnchanged'));
} else {
state.originalText = state.originalText || source;
textarea.value = data.romanized;
originalHint.hidden = false;
}
} catch (err) {
if (!scope.signal.aborted) await anomalousAlert(t('audioRomanizeFailed'));
} finally {
romanizeBtn.disabled = false;
romanizeBtn.textContent = t('audioRomanizeBtn');
}
};
const wrap = el('div', 'anomalous-voice-field');
const header = el('div', 'anomalous-voice-field-header');
header.append(el('span', 'anomalous-voice-field-label', t('audioRefTextLabel')), romanizeBtn);
wrap.append(header, textarea, originalHint);
return { wrap, textarea, state };
}
/** Not tied to the modal's AbortSignal: closing the modal must not pretend to cancel a server write. */
async function postVoice(fields, overwrite) {
const formData = new FormData();
formData.append('audio', fields.file);
formData.append('character', fields.character);
formData.append('emotion', fields.emotion);
formData.append('text', fields.text);
formData.append('original_text', fields.originalText);
formData.append('target_subfolder', TARGET_SUBFOLDER);
if (overwrite) formData.append('overwrite', '1');
const resp = await fetch('/anomalous/upload_audio_voice', { method: 'POST', body: formData });
const result = await resp.json().catch(() => ({ success: false, error: `HTTP ${resp.status}` }));
return { status: resp.status, result };
}
function voiceName(character, emotion) {
return emotion === MAIN_VOICE ? character : `${character}.${emotion}`;
}
export function openAudioUploaderModal(options = {}) {
activeScope?.dispose();
const scope = createViewScope();
activeScope = scope;
let selectedFile = null;
let objectUrl = null;
let previewEl = null;
const overlay = el('div', 'anomalous-voice-modal-overlay');
const modal = el('div', 'anomalous-voice-modal');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => {
overlay.remove();
if (objectUrl) URL.revokeObjectURL(objectUrl);
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 dropzoneContainer = el('div', 'anomalous-voice-dropzone-wrap');
const dropzone = createDropzone((file) => {
selectedFile = file;
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = URL.createObjectURL(file);
previewEl?.remove();
previewEl = createPreviewPlayer(file, objectUrl);
dropzone.after(previewEl);
});
dropzoneContainer.appendChild(dropzone);
const charSection = createCharacterSection(options.defaultCharacter);
const emoSection = createEmotionSection();
const transcriptSection = createTranscriptSection(scope);
const footer = el('div', 'anomalous-voice-modal-footer');
const cancelBtn = el('button', 'anomalous-voice-modal-cancel', t('dialogCancel'));
cancelBtn.type = 'button';
cancelBtn.onclick = close;
const submitBtn = el('button', 'anomalous-voice-modal-submit', t('audioSaveAndIngest'));
submitBtn.type = 'button';
submitBtn.onclick = async () => {
const fields = {
file: selectedFile,
character: charSection.input.value.trim(),
emotion: emoSection.input.value.trim().toLowerCase() || MAIN_VOICE,
text: transcriptSection.textarea.value.trim(),
originalText: transcriptSection.state.originalText,
};
if (!fields.file || !fields.character) {
await anomalousAlert(t('audioUploadRequiredError'));
return;
}
submitBtn.disabled = true;
submitBtn.textContent = t('audioSaving');
try {
let { status, result } = await postVoice(fields, false);
if (status === 409 && result.code === 'exists') {
const confirmed = await anomalousConfirm(t('audioUploadExistsConfirm', { name: voiceName(fields.character, fields.emotion) }));
if (!confirmed || scope.signal.aborted) return;
({ status, result } = await postVoice(fields, true));
}
if (!result.success) {
if (!scope.signal.aborted) await anomalousAlert(t('audioUploadFailed', { error: result.error || `HTTP ${status}` }));
return;
}
// The files are saved even if the modal was closed meanwhile; refresh the list either way.
options.onSaved?.(result.slice);
if (result.main_missing && !scope.signal.aborted) {
await anomalousAlert(t('audioMainMissing', { file: `${result.slice.character}.wav` }));
}
close();
} catch (e) {
if (!scope.signal.aborted) await anomalousAlert(t('audioUploadFailed', { error: e.message }));
} finally {
if (!scope.signal.aborted) {
submitBtn.disabled = false;
submitBtn.textContent = t('audioSaveAndIngest');
}
}
};
footer.append(cancelBtn, submitBtn);
modal.append(renderModalHeader(close), dropzoneContainer, charSection.wrap, emoSection.wrap, transcriptSection.wrap, footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
charSection.input.focus();
return close;
}