Files
DemonGatanjieuandClaude Opus 5.5 87aa9a1ac2 Put Voice-over back on the Voices page, with calmer settings and cards
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>
2026-10-02 21:06:45 +08:00

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);
}