Voices has two views switched at the top, Characters and Voice-over, sharing the character list (clicking a character on Voice-over makes it speak); the rail's Voices entry reopens the view used last, and the separate Voice-over rail entry is gone. Generation settings: language as choices and speed as a slider in view, the sampling numbers folded under fine-tuning with plain names, no sideways scroll. A finished take says how long it took and links to the audio gallery. Line cards show editing buttons on hover, the emotion's reference clip sits beside its chips, and a character with only its main voice shows no chips. The audio gallery shows each file's lines once and can open them on Voice-over for another take; the list no longer repeats the gallery. Also fixes the audio list staying empty after a reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.8 KiB
JavaScript
112 lines
4.8 KiB
JavaScript
/**
|
|
* The Voices page's Voice-over view (配音, shell page `script`, under the rail's Voices
|
|
* entry like the character cards): the switch to Characters, then the script director.
|
|
* It loads the GPT-SoVITS characters and mounts ui_script_director.js; without the node
|
|
* pack it shows how to install it, without characters the way to import one. The
|
|
* character list on the left chooses who speaks here; a character card's "Voice-over"
|
|
* and the audio gallery's "voice it again" open it with a character (and lines) chosen
|
|
* (`owner.openScript`).
|
|
*/
|
|
|
|
import { t } from './interface_settings.js';
|
|
import { isTtsInstalled, loadVoices } from './audio_engines.js';
|
|
import { parseTaggedSpeech, ttsOutputPrefix } from './audio_script.js';
|
|
import { loadScriptLines, mountScriptDirector, scriptCharacter, setScriptDirectorHooks, updateScriptDirectorVoices } from './ui_script_director.js';
|
|
import { renderInstallCard, stopAudioStudioPlayback } from './ui_audio_studio.js';
|
|
import { stopGalleryAudio } from './ui_audio_gallery.js';
|
|
import { setActiveAudioFilter, syncAudioSidebarSelection } from './ui_audio_sidebar.js';
|
|
import { renderVoiceTabs } from './ui_voice_tabs.js';
|
|
|
|
const renderTokens = new WeakMap();
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
/** A centred message instead of the director, with an optional button. */
|
|
function note(text, action = null) {
|
|
const box = el('div', 'anomalous-script-page-note');
|
|
box.append(el('p', '', text));
|
|
if (action) {
|
|
const btn = el('button', 'anomalous-sd-btn primary', action.label);
|
|
btn.type = 'button';
|
|
btn.onclick = action.onClick;
|
|
box.append(btn);
|
|
}
|
|
return box;
|
|
}
|
|
|
|
/** The voice group whose output folder holds a generated file (`audio/<character>`), or null. */
|
|
function groupForFolder(groups, subfolder) {
|
|
const folder = String(subfolder || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
if (!folder) return null;
|
|
return groups.find(group => {
|
|
const prefix = ttsOutputPrefix(group.character);
|
|
return prefix.slice(0, prefix.lastIndexOf('/')) === folder;
|
|
})?.group || null;
|
|
}
|
|
|
|
/** The left list marks who speaks, as it marks the character shown on Characters. */
|
|
function markCharacter(owner, groups) {
|
|
const group = groups.find(item => item.group === scriptCharacter());
|
|
if (!group) return;
|
|
setActiveAudioFilter({ type: 'group', value: group.group, character: group.character });
|
|
syncAudioSidebarSelection(owner);
|
|
}
|
|
|
|
/**
|
|
* Shows the view in `panel`. `character`: a voice group key to choose (a card, the list);
|
|
* `script`: `{ speech, subfolder }` of a generated file to voice again.
|
|
* Only the latest call for a panel may write to it.
|
|
*/
|
|
export async function renderScriptPage(panel, owner, { character = null, script = null } = {}) {
|
|
const token = {};
|
|
renderTokens.set(panel, token);
|
|
setScriptDirectorHooks({
|
|
owner,
|
|
onPreviewStart: () => {
|
|
stopAudioStudioPlayback();
|
|
stopGalleryAudio();
|
|
},
|
|
onCharacterChange: () => loadVoices().then(result => markCharacter(owner, result.groups || [])),
|
|
openGallery: () => owner.goTo('audio-gallery'),
|
|
});
|
|
if (!panel._scriptTop) {
|
|
panel._scriptTop = el('div', 'anomalous-audio-toolbar anomalous-script-topbar');
|
|
panel._scriptHost = el('div', 'anomalous-script-host');
|
|
panel.replaceChildren(panel._scriptTop, panel._scriptHost);
|
|
}
|
|
panel._scriptTop.replaceChildren(renderVoiceTabs(owner, 'script'));
|
|
const host = panel._scriptHost;
|
|
const mounted = host.querySelector('.anomalous-script-director-panel');
|
|
if (!mounted) host.replaceChildren(note(t('audioLoading')));
|
|
|
|
const installed = await isTtsInstalled();
|
|
if (renderTokens.get(panel) !== token) return;
|
|
if (!installed) {
|
|
host.replaceChildren(el('div', 'anomalous-script-page-note'));
|
|
host.firstChild.append(renderInstallCard());
|
|
return;
|
|
}
|
|
const result = await loadVoices();
|
|
if (renderTokens.get(panel) !== token) return;
|
|
if (result.error) {
|
|
host.replaceChildren(note(t('audioLoadFailed', { error: result.error })));
|
|
return;
|
|
}
|
|
const groups = result.groups || [];
|
|
if (!groups.length) {
|
|
host.replaceChildren(note(t('scriptPageNoCharacters'), { label: t('scriptPageGoVoices'), onClick: () => owner.goTo('voices') }));
|
|
return;
|
|
}
|
|
const chosen = character || (script ? groupForFolder(groups, script.subfolder) : null);
|
|
updateScriptDirectorVoices(groups, chosen, { choose: Boolean(chosen) });
|
|
if (script?.speech) loadScriptLines(parseTaggedSpeech(script.speech));
|
|
if (!mounted) host.replaceChildren();
|
|
mountScriptDirector(host);
|
|
markCharacter(owner, groups);
|
|
}
|