Voice-over: pick on the left, change it on the right; settings per character and per line

The side panel follows what is picked: the whole script, one character in
it (its language, speed and mode for this script), or the picked lines (who
reads them, and their own values, each following the character until set).
A line set for itself carries a tag on its card. The values reach
Anomalous_TTS interface 17 as [lang:…][speed:…][preset:…] on each line;
an older node reads the whole script as before. Clicking a character in the
cast now shows it instead of giving it the picked lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-20 01:28:22 +08:00
co-authored by Claude Opus 5.5
parent f3517a986d
commit 09ae7c6b33
17 changed files with 906 additions and 248 deletions
+18 -11
View File
@@ -375,27 +375,34 @@ covered by the `styles.css` manifest.
matching, and `buildTtsPrompt` (a GPT-SoVITS script as a ComfyUI API prompt
saving to `output/audio/<character>/`).
`ui_script_director.js` owns the script director, the Voice-over view's body: the lines,
each read by its character (the one picked in the list on the left at first;
`castScriptCharacter`, called when that list is clicked there, gives it the picked lines;
the node's own character is the first line's), and a bundle (`[name]` where the speaker
changes) that is generated in the view, or, under "Into your own workflow"
each read by its character (the one picked in the list on the left at first; the node's
own character is the first line's), and a bundle (`[name]` where the speaker changes, a
line's own `[lang:…][speed:…][preset:…]`) that is generated in the view, or, under "Into your own workflow"
(`ui_script_workflow.js`), pushed to the selected/only target node, dragged onto one
(writing the character and script widgets together, one Ctrl+Z step) or copied. The page
feeds it the voice groups after each fetch. `ui_script_cards.js` draws the line cards: a
click picks (`ui_script_cast.js`: Ctrl / Shift, the characters' colours), a double-click or
✎ edits the text in place, the name opens a menu of characters, emotion chips per the
line's character, buttons on hover.
line's character, a tag when the line is set for itself, buttons on hover.
The left side only picks; the side panel follows it (`ui_script_tune.js`): nothing picked,
the whole script (each character and how it reads); a character clicked in the list
(`castScriptCharacter`), its language, speed and mode in this script; picked lines, who
reads them and how, each value following the character until set there (↺ follows again).
`script_tune.js` holds the rules without DOM: a line's values over its character's, and
the tags Anomalous_TTS interface 17 reads for them (the language unless auto, the speed
when it is not the character's saved one, the mode unless Auto).
It puts the cards on one side and generating on the other: beside
them once the page is wide, below them when it is narrow (docked browser); on a
wide page `ui_split_bar.js` is the divider between them (dragged, remembered).
A card is one row (number, text, buttons); "Into your own workflow" is folded.
`ui_script_run.js` is the director's "Generate" section (the bar with the result and
a way to the audio gallery; language, speed and the mode: Auto, or one preset for
every line through the node's `preset` input, in the same switch measured
against Auto (compact: scale and numbers folded) and heard from the ▶ on a
line card (the takes heard stay on the side to compare), a preset's numbers
changed there saved into the preset; links to the presets and to the
character's generation settings): it runs
a way to the audio gallery; each character's values in this script, starting from its
saved defaults and Auto, which it draws through `ui_script_tune.js`; the modes in the
same switch measured against Auto (compact: scale and numbers folded) and heard on a
line from the panel or the ▶ on its card (the takes heard stay on the side to compare),
a preset's numbers changed there saved into the preset; links to the presets and to the
character's generation settings; a node older than interface 17 reads the whole script
as the first line's character reads, through its inputs): it runs
the script without the canvas, plays the result, retakes the whole script
(new seed) or one line (`[take:N]`, the node caches the rest), and saves a
character's language and speed to its `defaults` (keeping the rest of them)
+3 -1
View File
@@ -47,7 +47,9 @@
- **No more duplicate cover copies**: scans used to keep a copy of Civitai's picture identical to the cover for every model. Now the cover is noted as Civitai's and a copy is kept only when you put your own cover in its place (so you can still bring Civitai's back). **Check and tidy** in the same place sends the existing duplicates to the Recycle Bin.
### 🎭 Several characters in one script (多角色配音)
- **Every line has its character**: on Voice-over, click a character in the list on the left, then paste: the lines are its. To give lines to another, pick them (a click; Ctrl / ⌘ adds one, Shift a run; Esc lets go) and click that character on the left, or click the name on a line for a menu of characters. Each line shows who reads it in that character's colour, and its emotions are that character's. The *Who speaks* bar at the top is gone: the list does its job.
- **Every line has its character**: on Voice-over, click a character in the list on the left, then paste: the lines are its. To give lines to another, pick them (a click; Ctrl / ⌘ adds one, Shift a run; Esc lets go) and choose who reads them on the right, or click the name on a line for a menu of characters. Each line shows who reads it in that character's colour, and its emotions are that character's. The *Who speaks* bar at the top is gone: the list does its job.
- **Pick on the left, change it on the right (左边选,右边改)**: the side panel follows what is picked. Nothing picked: the whole script, each character with how it reads (a click opens it). A character clicked in the list: its language, speed and mode **in this script**, for every line of it (*Save as default* keeps the language and speed for next time). Picked lines: who reads them, and their own language, speed and mode; each one shows **Follow** until you set it, the card then carries a small tag with what it set, and ↺ (or *Follow the character in everything*) lets it follow again. Esc or *‹ Whole script* goes back. The modes can be heard on that line right in the panel.
- So one script can have a calm narrator, a lively character and one line read slower, all in one go. This needs Anomalous_TTS interface 17 (1.1.0), which reads `[lang:…]`, `[speed:…]` and `[preset:…]` on a line; the script pushed or copied to a node carries them too, and a file voiced again from the audio gallery comes back with them. With an older Anomalous_TTS the whole script is read as before, as its first line's character reads, and the panel says so.
- **Picking and editing no longer get in each other's way**: a click on a line picks it; a double-click (or ✎) edits its text in place, Enter keeps it and Esc puts it back.
- **The list on the left is the cast there**: narrower, the characters with their colours only; a click there never takes you back to the Characters page.
- Generating, ▶ on a line, pushing, dragging and copying all follow who reads each line: the script carries `[name]` where the speaker changes (Anomalous_TTS reads it already), and in Auto mode each character uses its own generation settings. A file voiced again from the audio gallery comes back with its speakers.
+13 -8
View File
@@ -59,20 +59,25 @@
## 2. 配音:一段话多种情绪
1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**,或者在角色卡片上点 **配音**,直接带着这个角色过去。谁来念,在左边列表里点角色或在上方选。
1. 在 **角色语音** 页顶部从 **角色** 切到 **配音**。先在左边列表里点一个角色,贴进来的台词就由 TA 念。
2. 把整段台词粘贴进去,点 **分成句子**。每句话会变成一张卡片。
3. 每张卡片点一下情绪(主声音 / happy / angry……),旁边的小喇叭能听这个情绪原本的参考声音。角色只有主声音时不显示情绪按钮。也可以直接改字;鼠标移到卡片上会出现 ✂(在光标处拆开)、↧(和下一句合并)、×(删除)。
### 直接生成,不用画布
页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和 **念法**。窗口窄或者浏览器停靠在侧边时,生成按钮和设置放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。
页面够宽时分成两栏:左边是台词卡片,右边是 **生成语音** 和设置面板。窗口窄或者浏览器停靠在侧边时,这些放在台词下面。两种情况都只有台词这一块会滚动,句子再多,按钮也一直看得见。
1. **念法** 一直显示:语言(自动 / 中 / 日 / 英)点一下就选,语速用滑块。第一次用的是这个角色的默认值。
下面的 **细调(一般不用动)** 收着:候选数量 `top_k`、候选范围 `top_p`、随机程度 `temperature`、防重复 `repetition_penalty`,鼠标停在输入框上有说明。调过时那一行后面会写“调过”。
2. 某次效果满意,点 **把这套设置存为这个角色的默认**:语言、语速和细调都会记住,下次打开这个角色直接用。种子不存,它只对当时那段台词有意义。
3. 点 **生成语音**。插件直接交给 ComfyUI 生成,画布上的工作流一个不动;ComfyUI 正在出图时会先排队。
4. 生成完自动播放,写着“生成好了,用时几秒”。文件存在 `output/audio/角色名/`;点 **已存进音频库 ›** 直接去音频库。
5. 不满意:
**左边选,右边改。** 右边的面板跟着你选了什么变:
- **什么都没选**:整份剧本,每个角色一行,写着 TA 怎么念(语言 · 语速 · 模式)、念几句。点一行就打开这个角色。
- **在左边列表里点角色**:这个角色在这份剧本里怎么念,语言、语速、模式(自动 / 稳定 / 标准 / 有起伏 / 自建的档)。改了,TA 的每一句都跟着变。只对这份剧本有效;点 **把语言和语速存为这个角色的默认**,下次也这样。
- **点句子**(Ctrl / Shift 多选):**谁来念** 点名字就换人;语言、语速、模式默认都是“跟随”这个角色,改了就只管这几句,卡片上会出现一个小黄标签写着改了什么。点 ↺ 或 **全部改回跟随角色** 就回去。
按 Esc 或点 **‹ 整份剧本** 回到整份剧本。模式可以在面板里直接试听这一句。按角色、按句分开设置需要 Anomalous_TTS 接口版本 17(1.1.0);旧版本时整段按第一句的角色念,面板上会提示。
1. 点 **生成语音**。插件直接交给 ComfyUI 生成,画布上的工作流一个不动;ComfyUI 正在出图时会先排队。
2. 生成完自动播放,写着“生成好了,用时几秒”。文件存在 `output/audio/角色名/`;点 **已存进音频库 ›** 直接去音频库。
3. 不满意:
- 某一句不好,点那张卡片上的 **↻**,只把这一句换一版,其他句子不变(直接用缓存,很快)。
- 整段都不好,点 **整段换一版**,换一个种子全部重新生成。
+63
View File
@@ -0,0 +1,63 @@
// How each voice-over line is read: its own values over its character's, and the tags for them
// (web/modules/script_tune.js), written into the script and read back (audio_script.js).
import assert from 'node:assert/strict';
import { buildScriptPackage, parseTaggedSpeech } from '../web/modules/audio_script.js';
import { clearLineTune, hasOwnTune, lineValues, readTuneTag, setLineTune, sharedValue, tuneTags } from '../web/modules/script_tune.js';
const character = { language: 'auto', speed: 1, preset: '' };
const stored = { language: 'auto', speed: 1, preset: '' };
// A line follows its character until it sets something itself; setting undefined follows again.
const line = { text: 'a' };
assert.deepEqual(lineValues(line, character), character);
setLineTune([line], 'speed', 1.2);
setLineTune([line], 'preset', 'lively');
assert.deepEqual(lineValues(line, character), { language: 'auto', speed: 1.2, preset: 'lively' });
assert.equal(hasOwnTune(line), true);
setLineTune([line], 'speed', undefined);
assert.deepEqual(line.tune, { preset: 'lively' });
setLineTune([line], 'preset', undefined);
assert.equal('tune' in line, false);
setLineTune([line], 'language', 'ja');
clearLineTune([line]);
assert.equal(hasOwnTune(line), false);
// Tags: the language unless auto, the speed unless it is the saved one, the mode unless Auto.
assert.equal(tuneTags(character, stored), '');
assert.equal(tuneTags({ language: 'ja', speed: 1.15000001, preset: 'stable' }, stored), '[lang:ja][speed:1.15][preset:stable]');
assert.equal(tuneTags({ language: 'auto', speed: 1.1, preset: '' }, { speed: 1.1 }), '');
// One value over several lines: mixed, all set by the lines, or some.
const lines = [{ text: 'a', tune: { speed: 1.2 } }, { text: 'b' }];
assert.deepEqual(sharedValue(lines, 'speed', l => lineValues(l, character)), { value: 1.2, mixed: true, own: false, some: true });
assert.deepEqual(sharedValue(lines, 'language', l => lineValues(l, character)), { value: 'auto', mixed: false, own: false, some: false });
// The tag reader: settings only, a name stays a name.
assert.deepEqual(readTuneTag('speed:1.2'), { field: 'speed', value: 1.2 });
assert.deepEqual(readTuneTag('语言:中文'), { field: 'language', value: 'zh' });
assert.deepEqual(readTuneTag('lang:fr'), { field: 'language', value: undefined });
assert.deepEqual(readTuneTag('lang:JA'), { field: 'language', value: 'ja' });
assert.deepEqual(readTuneTag('预设:custom_a1'), { field: 'preset', value: 'custom_a1' });
assert.equal(readTuneTag('普拉娜'), null);
// Written into a bundle (before [take:N], after the speaker and emotion) and read back for "voice it again".
const slice = emotion => ({ emotion });
const arona = { group: 'gpt_sovits:Arona', node_value: 'Arona', has_main: true, slices: [slice('main'), slice('happy')] };
const aris = { group: 'gpt_sovits:Aris', node_value: 'Aris', has_main: true, slices: [slice('main')] };
const script = [
{ text: '早上好', emotion: 'happy', take: 2, tune: { speed: 0.9 } },
{ text: '你好', emotion: 'main', speaker: 'gpt_sovits:Aris', tune: { language: 'ja', preset: 'lively' } },
{ text: '再见', emotion: 'main', speaker: 'gpt_sovits:Aris' },
];
const tagsOf = (l, speaker) => tuneTags(lineValues(l, character), speaker === aris ? { speed: 1 } : stored);
const pkg = buildScriptPackage(script, arona, { takes: true, groups: [arona, aris], tagsOf });
assert.equal(pkg.speech, '{happy} [speed:0.9][take:2]早上好\n[Aris]{main} [lang:ja][preset:lively]你好\n{main} 再见');
assert.deepEqual(parseTaggedSpeech(pkg.speech), [
{ emotion: 'happy', text: '早上好', tune: { speed: 0.9 } },
{ emotion: 'main', text: '你好', speaker: 'Aris', tune: { language: 'ja', preset: 'lively' } },
{ emotion: 'main', text: '再见', speaker: 'Aris' },
]);
// Without tagsOf nothing changes.
assert.equal(buildScriptPackage(script, arona, { groups: [arona, aris] }).speech, '{happy} 早上好\n[Aris]{main} 你好\n{main} 再见');
console.log('script_tune ok');
+21 -7
View File
@@ -3,9 +3,12 @@
*
* The node takes a character and a script in which `{happy}` switches to that
* emotion's reference, `{main}` back to the main one, and `[take:N]` asks for
* another take of one line. `buildTtsPrompt` runs a script without a canvas.
* another take of one line; `[lang:…]` `[speed:…]` `[preset:…]` set how one line is
* read (script_tune.js). `buildTtsPrompt` runs a script without a canvas.
*/
import { readTuneTag } from './script_tune.js';
export const MAIN_TAG = '{main}';
const normalisePath = value => String(value ?? '').replace(/\\/g, '/');
@@ -88,15 +91,16 @@ export function joinSegments(first, second) {
/**
* Once any line is tagged, untagged lines get {main} instead of inheriting the previous voice.
* A line with `take` > 1 (GPT-SoVITS only) gets `[take:N]` right before its text; a line's
* `speaker` (`[name]`, where another character takes over) goes first.
* A line's `tune` (its own `[lang:…][speed:…][preset:…]`) and, with `take` > 1 (GPT-SoVITS only),
* `[take:N]` go right before its text; a line's `speaker` (`[name]`, where another character
* takes over) goes first.
*/
export function composeScript(lines) {
const anyTagged = lines.some(line => line.voice?.tag);
return lines
.map(line => {
const tag = line.voice?.tag || (anyTagged ? MAIN_TAG : '');
const text = line.take > 1 ? `[take:${line.take}]${line.text}` : line.text;
const text = `${line.tune || ''}${line.take > 1 ? `[take:${line.take}]` : ''}${line.text}`;
return `${line.speaker || ''}${tag ? `${tag} ${text}` : text}`;
})
.join('\n')
@@ -115,11 +119,12 @@ const speaks = group => Boolean(group?.node_value) && group.has_main !== false &
* `character` widget gets the group's `node_value`. A line's `speaker` (a voice group key from
* `groups`) is read by that character instead: `[name]` goes in where the speaker changes
* (Anomalous_TTS's script syntax). Every emotion must be usable for its line's character.
* `takes` (runs in the director) writes each line's `take` as `[take:N]`.
* `takes` (runs in the director) writes each line's `take` as `[take:N]`; `tagsOf(line, speaker)`
* gives the tags for how a line is read (the director's run section).
* Returns `{ sample, speech, lineCount, speakers }` (the characters heard, the node's first)
* or `{ error: localeKey }`.
*/
export function buildScriptPackage(lines, group, { takes = false, groups = [] } = {}) {
export function buildScriptPackage(lines, group, { takes = false, groups = [], tagsOf = null } = {}) {
if (!group) return { error: 'scriptDirectorPickCharacter' };
if (!speaks(group)) return { error: 'scriptDirectorMainMissing' };
const byKey = new Map(groups.map(item => [item.group, item]));
@@ -137,6 +142,7 @@ export function buildScriptPackage(lines, group, { takes = false, groups = [] }
text,
voice: emotion === 'main' ? null : { tag: `{${emotion}}` },
take: takes ? line.take || 1 : 1,
tune: tagsOf?.(line, speaker) || '',
speaker: speaker === current ? '' : `[${speaker.node_value}]`,
});
current = speaker;
@@ -179,15 +185,19 @@ const PAUSE_TAG = /^\s*(?:pause|wait|stop|停顿|暂停)\s*[::]/i;
* Read a tagged script back into segments, splitting at `{emotion}` and `[name]` tags
* (`speaker`: the name, only on segments after one; the emotion goes back to main there).
* Text before the first tag belongs to the main voice; `[take:N]` is left out, pauses stay.
* A line's own settings start a segment of their own and come back as its `tune` (they cover
* its first line only).
*/
export function parseTaggedSpeech(speech) {
const segments = [];
let speaker = null;
let emotion = 'main';
let text = '';
let tune = null;
const flush = () => {
const words = text.trim();
if (words) segments.push({ emotion, text: words, ...(speaker ? { speaker } : {}) });
if (words) segments.push({ emotion, text: words, ...(speaker ? { speaker } : {}), ...(tune ? { tune } : {}) });
if (words) tune = null;
text = '';
};
for (const part of String(speech || '').split(/(\{[^{}]+\}|\[[^[\]]+\])/)) {
@@ -198,6 +208,10 @@ export function parseTaggedSpeech(speech) {
emotion = tag[1].trim();
} else if (bracket && TAKE_TAG.test(bracket[1])) {
continue; // another take only meant something for that run
} else if (bracket && readTuneTag(bracket[1])) {
flush();
const { field, value } = readTuneTag(bracket[1]);
if (value !== undefined) tune = { ...(tune || {}), [field]: value };
} else if (bracket && !PAUSE_TAG.test(bracket[1])) {
flush();
speaker = bracket[1].trim();
+1 -1
View File
@@ -143,7 +143,7 @@ export class AnomalousBrowser {
this.pendingScript = null;
renderScriptPage(this.scriptPanel, this, pending);
} else if (filter?.type === 'group' && this.currentShellPage() === 'script') {
// On Voice-over the list is the cast: the character reads the picked lines, or by default.
// On Voice-over the list is the cast: the side panel shows the character in this script.
castScriptCharacter(filter.value);
return;
} else {
+62 -14
View File
@@ -2487,7 +2487,6 @@ export const i18n = {
scriptRunLang_ja: '日语',
scriptRunLang_en: '英语',
scriptRunSpeed: '语速',
scriptRunHow: "念法",
scriptRunParamName_top_k: "候选数量",
scriptRunParamName_top_p: "候选范围",
scriptRunParamName_temperature: "随机程度",
@@ -2498,11 +2497,10 @@ export const i18n = {
scriptRunModeAutoBase: "自动",
scriptRunModeIsAuto: "这就是“自动”:这个角色平时实际用的数字,其他档都拿它来比。",
scriptRunModeAutoShort: "每句按生成预设和角色设置各自决定(这里是说{lang}时)。",
scriptRunModeWhole: "{about}。整段都用这一档。",
scriptRunListenLine: "试听第 {n} 句:{text}",
scriptRunModeWhole: "{about}。这个角色的每一句都用这一档。",
scriptRunListenLineAs: "试听第 {n} 句({who}):{text}",
scriptRunListenSample: "试听示例句:{text}",
scriptRunListenLineHint: "点左边句子上的 ▶,就用这一档试听那句",
scriptRunListenLineHint: "点左边句子上的 ▶,按它现在的设置试听那句",
scriptRunModeSaved: "已存进“{name}”,所有角色用这一档时都跟着变。",
scriptRunOpenPresets: "生成预设设置",
scriptRunOpenTuning: "{name} 的生成设置",
@@ -2575,9 +2573,35 @@ export const i18n = {
scriptDirectorEditLine: "改台词",
scriptDirectorSpeakerMenu: "换成谁念",
scriptDirectorCardsHint: "点句子选中,双击改台词",
scriptCastHint: "点句子选中(Ctrl / Shift 多选),双击改台词;要换人,点左边的角色或句子上的名字",
scriptCastPicked: "已选 {count} 句:点左边的角色,或任一选中句子上的名字,换成 TA 念(Esc 取消)",
scriptCastListHint: "点角色:选中的句子交给 TA,之后贴进来、新加的句子也由 TA 念",
scriptCastHint: "点句子选中(Ctrl / Shift 多选),右边就能改谁来念、怎么念;双击改台词",
scriptCastPicked: "已选 {count} 句:在右边改谁来念、怎么念(Esc 取消)",
scriptCastListHint: "点角色:右边显示 TA 在这份剧本里怎么念;之后贴进来、新加的句子也由 TA 念",
scriptCardTuneHint: "这句单独调过。点它,在右边改,或点 ↺ 回到跟随角色",
scriptTuneScript: "整份剧本",
scriptTuneScriptMeta: "{count} 句 · {speakers} 个角色",
scriptTuneScriptHint: "每个角色按自己的设置念。点左边的角色或句子,在这里调它们怎么念。",
scriptTuneLineCount: "{count} 句",
scriptTuneOwnCount: "其中 {count} 句单独调过",
scriptTuneBack: "‹ 整份剧本",
scriptTuneCharacter: "{name} · 在这份剧本里",
scriptTuneCharacterMeta: "念第 {lines} 句,共 {count} 句",
scriptTuneCharacterNone: "这份剧本里还没有 TA 的台词",
scriptTuneCharacterHint: "改这里,TA 的每一句都跟着变(单独调过的那几项除外)。只用于这份剧本;要以后都这样,存为默认或去生成设置。",
scriptTuneResetLines: "把单独调过的 {count} 句改回跟随",
scriptTuneResetAll: "全部改回跟随角色",
scriptTuneLine: "第 {n} 句",
scriptTuneLines: "选中的 {count} 句",
scriptTuneReadBy: "由 {name} 念",
scriptTuneWho: "谁来念",
scriptTuneFollowValue: "跟随({value})",
scriptTuneFollowEach: "各自的",
scriptTuneFollowHint: "和念它的角色一样",
scriptTuneFollowing: "跟随",
scriptTuneReset: "回到跟随角色",
scriptTuneFollowBase: "{name} 的设置",
scriptTuneModeFollow: "跟随 {name} 的模式。",
scriptTuneModeLines: "{about}。选中的句子用这一档。",
scriptTuneOldTts: "按角色、按句分开设置要 Anomalous_TTS 接口 17(1.1.0)。现在整段按第一句的角色念;可以在 GPT-SoVITS 设置里更新。",
scriptDirectorCopy: "复制剧本",
scriptDirectorCopied: "已复制",
scriptDirectorDragHandle: "拖到节点",
@@ -5083,7 +5107,6 @@ export const i18n = {
scriptRunLang_ja: 'Japanese',
scriptRunLang_en: 'English',
scriptRunSpeed: 'Speed',
scriptRunHow: "How it is read",
scriptRunParamName_top_k: "Candidates",
scriptRunParamName_top_p: "Candidate share",
scriptRunParamName_temperature: "Randomness",
@@ -5094,11 +5117,10 @@ export const i18n = {
scriptRunModeAutoBase: "Auto",
scriptRunModeIsAuto: "This is Auto: what this character gets by default; the others are compared with it.",
scriptRunModeAutoShort: "Each line follows the presets and the character's settings (shown: speaking {lang}).",
scriptRunModeWhole: "{about}; the whole script uses it.",
scriptRunListenLine: "Listening to line {n}: {text}",
scriptRunModeWhole: "{about}; every line of this character uses it.",
scriptRunListenLineAs: "Listening to line {n} ({who}): {text}",
scriptRunListenSample: "Listening to a sample: {text}",
scriptRunListenLineHint: "Click ▶ on a line on the left to hear it in this mode",
scriptRunListenLineHint: "Click ▶ on a line on the left to hear it as it is set now",
scriptRunModeSaved: "Saved into \"{name}\", for every character using it.",
scriptRunOpenPresets: "Generation presets",
scriptRunOpenTuning: "{name}'s generation settings",
@@ -5171,9 +5193,35 @@ export const i18n = {
scriptDirectorEditLine: "Edit the line",
scriptDirectorSpeakerMenu: "Who reads it",
scriptDirectorCardsHint: "Click a line to pick it, double-click to edit it",
scriptCastHint: "Click a line to pick it (Ctrl / Shift for more), double-click to edit it; to change who reads, click a character on the left or the name on a line",
scriptCastPicked: "{count} {count|line|lines} picked: click a character on the left, or the name on a picked line, to have it read them (Esc lets go)",
scriptCastListHint: "Click a character: it reads the picked lines, and the lines pasted or added from now on",
scriptCastHint: "Click a line to pick it (Ctrl / Shift for more) and change who reads it and how on the right; double-click to edit it",
scriptCastPicked: "{count} {count|line|lines} picked: change who reads {count|it|them} and how on the right (Esc lets go)",
scriptCastListHint: "Click a character: the right side shows how it reads in this script; it also reads the lines pasted or added from now on",
scriptCardTuneHint: "This line is set for itself. Click it to change that on the right, or ↺ to follow the character again",
scriptTuneScript: "Whole script",
scriptTuneScriptMeta: "{count} {count|line|lines} · {speakers} {speakers|character|characters}",
scriptTuneScriptHint: "Each character reads with its own settings. Click a character or lines on the left to change how they are read.",
scriptTuneLineCount: "{count} {count|line|lines}",
scriptTuneOwnCount: "{count} set for {count|itself|themselves}",
scriptTuneBack: "‹ Whole script",
scriptTuneCharacter: "{name} in this script",
scriptTuneCharacterMeta: "Reads {count|line|lines} {lines} ({count})",
scriptTuneCharacterNone: "No lines for this character in the script yet",
scriptTuneCharacterHint: "Every line of this character follows what you set here (except what a line set for itself). For this script only; to keep it, save it as the default or use the generation settings.",
scriptTuneResetLines: "Let the {count} {count|line|lines} set for {count|itself|themselves} follow again",
scriptTuneResetAll: "Follow the character in everything",
scriptTuneLine: "Line {n}",
scriptTuneLines: "{count} lines picked",
scriptTuneReadBy: "Read by {name}",
scriptTuneWho: "Read by",
scriptTuneFollowValue: "Follow ({value})",
scriptTuneFollowEach: "each its own",
scriptTuneFollowHint: "The same as the character reading it",
scriptTuneFollowing: "Following",
scriptTuneReset: "Follow the character again",
scriptTuneFollowBase: "{name}'s setting",
scriptTuneModeFollow: "Follows {name}'s mode.",
scriptTuneModeLines: "{about}; the picked lines use it.",
scriptTuneOldTts: "Settings per character and per line need Anomalous_TTS interface 17 (1.1.0). Until then the whole script is read as its first line's character reads; update it in GPT-SoVITS Settings.",
scriptDirectorCopy: "Copy script",
scriptDirectorCopied: "Copied",
scriptDirectorDragHandle: "Drag to node",
+95
View File
@@ -0,0 +1,95 @@
/**
* How each line of a voice-over is read (no DOM): its language, speed and mode. A line follows
* its character unless it was set for itself (`line.tune`, only the fields set); the character's
* values in this script come from the Script Director's run section (its saved defaults, changed
* here for this script). Anomalous_TTS interface 17 reads a line's own values from tags in the
* script, `[lang:ja][speed:1.2][preset:lively]`, over every other setting.
*/
export const TUNE_FIELDS = ['language', 'speed', 'preset'];
export const LINE_TAGS_FORMAT = 17; // the node interface that reads [lang:…] [speed:…] [preset:…]
export const AUTO_MODE = ''; // the mode that lets the presets decide
/** What a line set for itself: only those fields. */
export function ownTune(line) {
return line?.tune && typeof line.tune === 'object' ? line.tune : {};
}
export function hasOwnTune(line) {
const own = ownTune(line);
return TUNE_FIELDS.some(field => own[field] !== undefined);
}
/** A line's values: its own over its character's (`character`: `{ language, speed, preset }`). */
export function lineValues(line, character) {
const own = ownTune(line);
return Object.fromEntries(TUNE_FIELDS.map(field => [field, own[field] ?? character?.[field]]));
}
/** Sets `field` on each line, or with `value` undefined lets the lines follow their character again. */
export function setLineTune(lines, field, value) {
for (const line of lines) {
if (!line) continue;
const own = { ...ownTune(line) };
if (value === undefined) delete own[field];
else own[field] = value;
if (Object.keys(own).length) line.tune = own;
else delete line.tune;
}
}
/** Lets the lines follow their character in everything again. */
export function clearLineTune(lines) {
for (const line of lines) if (line) delete line.tune;
}
/**
* The tags a line needs for the node to read it with `values`: the language unless it is auto
* (the node never reads a character's saved language itself), the speed when it is not the one
* the node takes from the character's own settings (`stored.speed`), the mode unless it is Auto.
*/
export function tuneTags(values, stored) {
let tags = '';
if (values.language && values.language !== 'auto') tags += `[lang:${values.language}]`;
if (typeof values.speed === 'number' && values.speed !== stored?.speed) tags += `[speed:${Math.round(values.speed * 100) / 100}]`;
if (values.preset) tags += `[preset:${values.preset}]`;
return tags;
}
/**
* The one value `field` has on all of `lines` (`valuesOf(line)`): `{ value, mixed }`; `own`:
* whether every line set it for itself, `some` whether any did.
*/
export function sharedValue(lines, field, valuesOf) {
const values = lines.map(line => valuesOf(line)[field]);
const owned = lines.filter(line => ownTune(line)[field] !== undefined).length;
return {
value: values[0],
mixed: values.some(value => value !== values[0]),
own: lines.length > 0 && owned === lines.length,
some: owned > 0,
};
}
const TAG = /^\s*(speed|语速|preset|预设|lang|language|语言)\s*[::]\s*(\S+)\s*$/i;
const FIELD = { speed: 'speed', 语速: 'speed', preset: 'preset', 预设: 'preset', lang: 'language', language: 'language', 语言: 'language' };
const LANGUAGE = { ja: 'ja', jp: 'ja', zh: 'zh', cn: 'zh', en: 'en', 日语: 'ja', 中文: 'zh', 英语: 'en' };
/** `[speed:1.2]`'s inside -> `{ field: 'speed', value: 1.2 }`; null when it is no line setting (a name). */
export function readTuneTag(content) {
const match = TAG.exec(String(content || ''));
if (!match) return null;
const field = FIELD[match[1].toLowerCase()];
const raw = match[2];
if (field === 'speed') {
const value = Number(raw);
return Number.isFinite(value) ? { field, value } : { field, value: undefined };
}
if (field === 'language') return { field, value: LANGUAGE[raw.toLowerCase()] };
return { field, value: raw };
}
/** "1, 3, 5" from line indexes (0-based in, 1-based out). */
export function lineNumbers(indexes) {
return indexes.map(index => index + 1).join(', ');
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { speakerColor } from './ui_script_cast.js';
* Audio sidebar: All Voices, the GPT-SoVITS characters (grouped by language; each
* group folds, each character unfolds into its clips) and a settings entry at the
* bottom. On Voice-over it is the cast (CSS: narrow, characters with their colours only):
* a click gives the picked lines to a character, or makes it the one who reads by default
* a click shows the character in the side panel and makes it the one who reads new lines
* (ui_script_director.js castScriptCharacter), and never leaves the page. Owns the active audio
* filter. Characters drag onto canvas nodes like the studio cards; files dropped on a
* character open the import form for it.
@@ -117,7 +117,7 @@ function createNavItem(owner, { label, iconSvg, count, filter, tab }) {
if (count !== undefined && count !== null) item.appendChild(el('span', 'anomalous-audio-nav-count', String(count)));
item.onclick = () => {
// On Voice-over a character click casts it (browser.js); the Characters page's filter stays.
// On Voice-over a character click shows it in the side panel (browser.js); the Characters page's filter stays.
if (!(owner.currentShellPage?.() === 'script' && filter.type === 'group')) {
activeFilter = filter;
syncAudioSidebarSelection(owner);
+1 -1
View File
@@ -59,7 +59,7 @@ export function closeWorkspace() {
}
// The back buttons of the pages one level down: a model's details, one combo, one recipe.
const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-wfd-back';
const BACK_BUTTONS = '.anomalous-detail-back, .anomalous-nb-back, .anomalous-wfd-back, .anomalous-sd-tune-back';
/**
* Esc on the browser: a page one level down (a model's details, one combo or one recipe) goes
+8 -1
View File
@@ -8,6 +8,7 @@ import { speakerColor } from './ui_script_cast.js';
* director keeps what is picked), a double-click or ✎ edits its text in place (Enter or a click
* elsewhere keeps it, Esc puts it back), the name on a card opens a menu of characters to read
* it (all the picked cards when it is one of them), and its emotion chips are its character's.
* A line set to read in its own way shows a tag with what it set (ui_script_tune.js).
* The director owns the lines; a card only calls back (`ctx`, see renderLineCard).
*/
@@ -107,7 +108,7 @@ function lineText(line, index, ctx) {
/**
* One card. `ctx`: `groups`, `speakerOf(line)`, `picked` (Set), `listenIndex`, `editing` (index
* or null), `showSpeakers`, `showChips`, `count`, `canListen`, `canRetake`, and the calls back:
* or null), `showSpeakers`, `showChips`, `count`, `canListen`, `canRetake`, `tuneText(line)`, and the calls back:
* `onPick(index, event)`, `onEdit(index | null)`, `onText(index, text)`, `onSplit(index, a, b)`,
* `onMerge(index)`, `onDelete(index)`, `onSpeaker(index, key)`, `onEmotion()`, `onListen(index)`,
* `onRetake(index)`, `onPreview(line, slices, button)`, `isPreviewing(button)`.
@@ -139,6 +140,12 @@ export function renderLineCard(line, index, ctx) {
chip.style.setProperty('--speaker', speakerColor(speaker?.group, ctx.groups));
head.append(chip);
}
const tune = ctx.tuneText?.(line);
if (tune) {
const tag = el('span', 'anomalous-sd-line-tune', tune);
tag.title = t('scriptCardTuneHint');
head.append(tag);
}
if (box) {
// Done editing: this card goes back to plain text in place, so a click on another card still lands.
box.addEventListener('blur', () => {
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Voice-over with several characters (the cast): each character's colour, its place in the voice
* list, the same on the list (ui_audio_sidebar.js) and on the line cards (ui_script_cards.js); and
* picking lines, a click, Ctrl / ⌘ to add or take one away, Shift for a run, to give to a
* character from the list (ui_script_director.js castScriptCharacter) or from a card's name.
* picking lines, a click, Ctrl / ⌘ to add or take one away, Shift for a run, for the side panel
* to change (ui_script_tune.js: who reads them and how) or for a card's name menu.
*/
const COLORS = ['#5b9cf6', '#ef7ea5', '#3fbf91', '#e3a33b', '#a487f5', '#4fc2d4', '#ee8656', '#94c252'];
+54 -22
View File
@@ -4,17 +4,20 @@ import { TTS_ENGINE, voiceGroupKey } from './audio_engines.js';
import { pickLines } from './ui_script_cast.js';
import { emotionLabel, renderLineCard } from './ui_script_cards.js';
import { createRunSection } from './ui_script_run.js';
import { ownTuneText } from './ui_script_tune.js';
import { bindSplitBar } from './ui_split_bar.js';
import { bindPackageDrag, copyScript, pushToNode } from './ui_script_workflow.js';
/**
* The script director, the body of the Voices page's Voice-over view (ui_script_page.js):
* paste a script, and every line is read by a character (at first the one picked in the list
* on the left, the cast): picked lines go to another from the list, or from the name on a card
* (ui_script_cards.js, ui_script_cast.js); choose an emotion per line, then generate it right
* here (ui_script_run.js), or put the tagged script into the workflow (ui_script_workflow.js).
* The node's own character is the first line's. The panel and its lines live for the page
* session; the page mounts it again each time it is shown.
* on the left, the cast). The left side only picks: lines (ui_script_cards.js, ui_script_cast.js)
* or a character in the cast; the side panel follows it (ui_script_tune.js): the whole script,
* one character in it, or the picked lines, where who reads them and how (language, speed, mode,
* each line following its character until set for itself) are changed. Choose an emotion per
* line, then generate it right here (ui_script_run.js), or put the tagged script into the
* workflow (ui_script_workflow.js). The node's own character is the first line's. The panel and
* its lines live for the page session; the page mounts it again each time it is shown.
*/
// From this width on, the cards get the full height and the generate side sits beside them.
@@ -31,12 +34,13 @@ const ICONS = {
const state = {
groups: [],
groupKey: null, // the character picked in the cast: new and pasted lines go to it
lines: [], // { text, emotion, take, speaker } — take > 1: retaken; speaker: its character's group key
focus: null, // { character: key }: the side panel shows it (while no line is picked)
lines: [], // { text, emotion, take, speaker, tune? } — take > 1: retaken; speaker: its character's group key; tune: script_tune.js
editing: true,
splitMode: 'sentence',
editLine: null, // the card whose text is being edited in place
listenIndex: 0, // the line the modes are heard on (ui_script_run.js)
picked: new Set(), // lines picked to give to a character (castScriptCharacter)
picked: new Set(), // lines picked: the side panel changes them
anchor: null, // where a Shift run of picked lines starts
};
@@ -63,8 +67,12 @@ export function scriptCharacter() {
/** Lines to voice again (a generated file's `{ emotion, text, speaker? }` segments): one card per line. */
export function loadScriptLines(segments) {
// A segment's own settings were written for its first line only.
const lines = segments.flatMap(segment => String(segment.text || '').split(/\n+/)
.map(text => ({ text: text.trim(), emotion: segment.emotion || 'main', take: 1, speaker: segment.speaker ? voiceGroupKey(segment.speaker) : state.groupKey })))
.map((text, at) => ({
text: text.trim(), emotion: segment.emotion || 'main', take: 1, speaker: segment.speaker ? voiceGroupKey(segment.speaker) : state.groupKey,
...(segment.tune && at === 0 ? { tune: { ...segment.tune } } : {}),
})))
.filter(line => line.text);
if (!lines.length) return;
stopScriptDirectorPreview();
@@ -101,18 +109,26 @@ export function updateScriptDirectorVoices(groups, preferredGroup = null, { choo
}
/**
* A character clicked in the list on Voice-over: the picked lines go to it, and it reads the
* lines pasted or added from now on.
* A character clicked in the list on Voice-over: the side panel shows how it reads in this
* script (the picked lines are let go), and it reads the lines pasted or added from now on.
*/
export function castScriptCharacter(key) {
if (!state.groups.some(group => group.group === key)) return;
stopScriptDirectorPreview();
giveLines([...state.picked], key);
state.picked = new Set();
state.focus = { character: key };
state.groupKey = key;
hooks.onCharacterChange?.();
if (panel) renderAll();
}
/** Back to the whole script in the side panel: nothing picked, no character shown. */
function focusScript() {
state.picked = new Set();
state.focus = null;
if (panel) renderAll();
}
/** Lines `indexes` read by `key` from now on; emotions it lacks go back to its main voice. */
function giveLines(indexes, key) {
const group = state.groups.find(item => item.group === key);
@@ -157,17 +173,30 @@ function nodeGroup() {
return speakerOf(state.lines.find(line => line.text.trim()) || null);
}
/** The script with retakes, for a run in the director. */
/** How each line is read, as tags (the run section knows the characters' values). */
const tagsOf = (line, speaker) => runSection?.tagsOf(line, speaker) || '';
/** The script with retakes, for a run in the director, and what the side panel shows. */
function runScript() {
const group = nodeGroup();
return {
group, lines: state.lines, listen: state.listenIndex, speakerOf,
pkg: buildScriptPackage(state.lines, group, { takes: true, groups: state.groups }),
pkg: buildScriptPackage(state.lines, group, { takes: true, groups: state.groups, tagsOf }),
groups: state.groups,
picked: [...state.picked].sort((a, b) => a - b),
focus: state.focus,
assign: (indexes, key) => {
giveLines(indexes, key);
stopScriptDirectorPreview();
renderAll();
},
focusCharacter: castScriptCharacter,
focusScript,
};
}
function currentPackage() {
return buildScriptPackage(state.lines, nodeGroup(), { groups: state.groups });
return buildScriptPackage(state.lines, nodeGroup(), { groups: state.groups, tagsOf });
}
// ---------- DOM ----------
@@ -259,6 +288,7 @@ function createPanel() {
},
resetTakes: () => state.lines.forEach(line => { line.take = 1; }),
onBusyChange: () => renderAll(),
onLinesChange: () => renderLines(),
openGallery: () => hooks.openGallery?.(),
});
scroll.append(linesContainer);
@@ -279,12 +309,12 @@ function createPanel() {
new ResizeObserver(([entry]) => panel.classList.toggle('is-wide', entry.contentRect.width >= WIDE_FROM)).observe(panel);
panel.append(header, inputArea, body);
// Esc lets go of the picked lines first; the browser's own Esc leaves it to this then (ui_browser_navigation.js).
// Esc lets go of the picked lines first; the browser's own Esc leaves it to this then (ui_browser_navigation.js),
// and with a character shown it presses the panel's back link.
window.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !state.picked.size || !panel.getClientRects().length) return;
state.picked = new Set();
refs.linesContainer.querySelectorAll('.anomalous-sd-line-card.is-picked').forEach(card => card.classList.remove('is-picked'));
renderCastHint();
if (e.target?.closest?.('input, textarea, select')) return;
focusScript();
});
refs = { subtitle, pasteFor, inputArea, textarea, cancelEdit, body, linesCount, castHint, linesContainer, summary, workflowTitle, dragHandle, copyBtn, pushBtn };
}
@@ -337,6 +367,7 @@ function renderLines() {
count: state.lines.length,
canListen: Boolean(runSection?.canListen()),
canRetake: Boolean(runSection?.canRetake(nodeGroup())),
tuneText: line => ownTuneText(runSection?.presetsView(), line),
onPick: pickLine,
onEdit: (index, quiet = false) => {
state.editLine = index;
@@ -350,7 +381,7 @@ function renderLines() {
onSplit: (index, first, second) => {
const line = state.lines[index];
line.text = first;
state.lines.splice(index + 1, 0, { text: second, emotion: line.emotion, take: 1, speaker: line.speaker });
state.lines.splice(index + 1, 0, { text: second, emotion: line.emotion, take: 1, speaker: line.speaker, ...(line.tune ? { tune: { ...line.tune } } : {}) });
changeLines(index + 1);
},
onMerge: (index) => {
@@ -396,18 +427,19 @@ function changeLines(edit) {
renderAll();
}
/** A click on a card: picks it (Ctrl / ⌘ adds, Shift a run) and makes it the line to listen with. */
/** A click on a card: picks it (Ctrl / ⌘ adds, Shift a run), the side panel shows the picked lines. */
function pickLine(index, event) {
state.picked = pickLines(state.picked, index, event, state.anchor);
if (!event.shiftKey) state.anchor = index;
refs.linesContainer.querySelectorAll('.anomalous-sd-line-card').forEach((card, at) => card.classList.toggle('is-picked', state.picked.has(at)));
renderCastHint();
chooseListenLine(index);
runSection?.render();
}
/** How to work the cards: picking and editing, and with several characters how to change who reads. */
/** How to work the cards: picking (the side panel changes them) and editing. */
function renderCastHint() {
refs.castHint.textContent = state.picked.size && state.groups.length > 1
refs.castHint.textContent = state.picked.size
? t('scriptCastPicked', { count: state.picked.size })
: t(state.groups.length > 1 ? 'scriptCastHint' : 'scriptDirectorCardsHint');
}
+159 -178
View File
@@ -4,38 +4,38 @@ import { buildTtsPrompt, TTS_NODE_CLASS } from './audio_script.js';
import { startPromptJob } from './audio_tts_run.js';
import { fetchGptSovitsCharacter, loadGptSovitsStatus, saveGptSovitsSettings } from './audio_engines.js';
import {
PRESETS_FORMAT, SAMPLE_LINES, isBuiltin, lineTuning, loadPresets, presetLabel, presetsFile, savePresets, stateFromView, tuningRows,
PRESETS_FORMAT, SAMPLE_LINES, lineTuning, loadPresets, presetLabel, presetsFile, savePresets, stateFromView, tuningRows,
} from './tts_presets_api.js';
import { keepParamFocus, renderPresetSwitch } from './ui_tts_preset_switch.js';
import { AUTO_MODE, LINE_TAGS_FORMAT, clearLineTune, lineValues, setLineTune, tuneTags } from './script_tune.js';
import { keepParamFocus } from './ui_tts_preset_switch.js';
import { createModePreview } from './ui_tts_mode_preview.js';
import { openTtsSetup } from './ui_tts_setup.js';
import { openTuningEditor } from './ui_tts_tuning.js';
import { LANGUAGES, renderTunePanel } from './ui_script_tune.js';
/**
* The Script Director's "Generate" section for GPT-SoVITS characters: runs the
* script through ComfyUI's queue (audio_tts_run.js) without touching the canvas,
* plays the result, and offers another take of the whole script (new seed) or of
* one line (`[take:N]`, the other lines come from the node's cache). Language and
* speed start from the character's `defaults` and can be saved back to it.
* one line (`[take:N]`, the other lines come from the node's cache).
*
* How it sounds is a mode (Anomalous_TTS interface 16): "Auto" lets the presets and the
* character's generation settings decide line by line; a preset sends the node's `preset`
* input, so every line uses it. The modes are shown against Auto (ui_tts_preset_switch.js,
* compact: the scale and the numbers folded) and can be heard on a line from the ▶ on its card
* (`listen()`, ui_tts_mode_preview.js); the takes heard stay here to compare. A preset's numbers
* changed here are saved into that preset.
* Links lead to the presets in GPT-SoVITS Settings and to the character's generation settings.
* How each line is read: every character has its language, speed and mode in this script,
* starting from its saved `defaults` (language and speed, which can be saved back) and Auto (the
* presets and the character's generation settings decide line by line); a line follows its
* character unless it was set for itself (script_tune.js). The side panel showing them follows
* what is picked on the left (ui_script_tune.js). Anomalous_TTS interface 17 gets them as tags
* on each line; an older node reads the whole script with the first line's character's values
* through its inputs. The modes can be heard on a line (ui_tts_mode_preview.js); a preset's
* numbers changed here are saved into that preset.
*
* The director owns the lines; this section owns the job, the options and the
* result, which live for the page session like the director's panel. It has two
* parts that the director places together: `bar` (generate / retake / cancel and the
* result, with a way to the audio gallery) and `settings` (language, speed, mode).
* The director owns the lines; this section owns the job, the characters' values and the
* result, which live for the page session like the director's panel. It has two parts that
* the director places together: `bar` (generate / retake / cancel and the result, with a way to
* the audio gallery) and `settings` (the side panel).
*/
const LANGUAGES = ['auto', 'zh', 'ja', 'en'];
const MIN_FORMAT = 11; // Anomalous_TTS interface with [take:N] and `defaults`
const NUMBERS = ['speed'];
const AUTO = ''; // the mode that lets the presets decide
const ROOMY_FROM = 440; // px of the side from which the modes' details are shown open
function el(tag, className, text) {
@@ -76,24 +76,28 @@ async function loadNodeSpec() {
}
/**
* `getScript()` -> `{ group, lines, listen, speakerOf, pkg }` for the chosen character (the director's
* lines, the index of the one to listen with, who reads a line, pkg from buildScriptPackage with takes); `getGroups()` -> every voice group (for the presets' "used by");
* `onPlay()` when the result starts playing;
* `resetTakes()` when a new take of the whole script starts; `onBusyChange()` when a job
* starts or ends (the director re-renders its line cards, whose retake buttons depend on it);
* `openGallery()` for the result's "saved to the audio gallery".
* `getScript()` -> `{ group, lines, listen, speakerOf, pkg, groups, picked, focus, assign, focusCharacter,
* focusScript }` (the node's character = the first line's, the director's lines, the one to listen
* with, who reads a line, pkg from buildScriptPackage with takes, the characters, the picked line
* indexes, the character shown, and the director's calls); `getGroups()` -> every voice group (for
* the presets' "used by"); `onPlay()` when the result starts playing; `resetTakes()` when a new take
* of the whole script starts; `onBusyChange()` when a job starts or ends (the director re-renders its
* line cards, whose retake buttons depend on it); `onLinesChange()` after a line's values changed (the
* cards' tags); `openGallery()` for the result's "saved to the audio gallery".
*/
export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onBusyChange, openGallery }) {
export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onBusyChange, onLinesChange, openGallery }) {
const state = {
seed: newSeed(),
spec: null, // node input ranges/defaults; null until loaded
presets: null, // loadPresets(); null without interface 16
format: 0, // the node's interface
local: false, // this computer (presets can be changed)
roomy: false, // the side is wide enough to show the modes' scale and numbers open
takeNow: null, // the take a card's ▶ plays: its line in the mode picked
options: new Map(), // character -> { language, speed, preset } being edited
options: new Map(), // character -> { language, speed, preset } in this script
settings: new Map(), // character -> its settings as saved in this session
saved: new Map(), // character -> options saved in this session
savedNow: null, // the character just saved ("Saved" on its button for a moment)
saving: false,
presetNote: '',
job: null,
status: null, // { state: 'queued' | 'running' | 'done' | 'cancelled', value?, max?, seconds? }
@@ -104,44 +108,8 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
const bar = el('div', 'anomalous-sd-run');
const hint = el('div', 'anomalous-sd-run-hint is-warning', t('scriptRunNeedsUpdate'));
// How it is read: language and speed, one per row, then the mode.
// How it is read: what is picked on the left (ui_script_tune.js).
const settings = el('div', 'anomalous-sd-run-settings');
const commitSpeed = (raw) => {
const script = getScript();
const range = state.spec?.speed;
let value = Number(raw);
if (!Number.isFinite(value) || !range) value = range?.default ?? 1;
value = Math.min(range?.max ?? value, Math.max(range?.min ?? value, value));
if (script?.group) currentOptions(script.group).speed = value;
render();
};
const languageRow = el('div', 'anomalous-sd-run-row');
const languageChoices = el('div', 'anomalous-sd-run-segment');
languageChoices.setAttribute('role', 'radiogroup');
const languageButtons = new Map();
for (const code of LANGUAGES) {
const choice = button('anomalous-sd-run-choice', t(`scriptRunLang_${code}`), () => {
const script = getScript();
if (script?.group) currentOptions(script.group).language = code;
render();
});
choice.setAttribute('role', 'radio');
languageButtons.set(code, choice);
languageChoices.append(choice);
}
languageRow.append(el('span', 'anomalous-sd-run-label', t('scriptRunLanguage')), languageChoices);
const speedRow = el('label', 'anomalous-sd-run-row');
const speed = el('input', 'anomalous-sd-run-slider');
speed.type = 'range';
speed.title = t('scriptRunParamHint_speed');
const speedValue = el('span', 'anomalous-sd-run-value');
speed.oninput = () => { speedValue.textContent = speed.value; };
speed.onchange = () => commitSpeed(speed.value);
speedRow.append(el('span', 'anomalous-sd-run-label', t('scriptRunSpeed')), speed, speedValue);
const modeBox = el('div', 'anomalous-sd-run-mode');
// A wide side (a big screen, the full window) shows the scale and the numbers without a click.
new ResizeObserver(([entry]) => {
const roomy = entry.contentRect.width >= ROOMY_FROM;
@@ -149,8 +117,6 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
state.roomy = roomy;
render();
}).observe(settings);
const saveDefaults = button('anomalous-sd-link-btn', t('scriptRunSaveDefaults'), () => storeDefaults(), t('scriptRunSaveDefaultsHint'));
settings.append(el('div', 'anomalous-sd-run-heading', t('scriptRunHow')), languageRow, speedRow, modeBox, saveDefaults);
const actions = el('div', 'anomalous-sd-run-actions');
const generateBtn = button('anomalous-sd-btn accent', t('scriptRunGenerate'), () => generate());
@@ -174,6 +140,7 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
const characterSettings = group => state.settings.get(group.character) ?? group?.raw?.settings ?? {};
const preview = createModePreview(() => render());
const canTag = () => state.format >= LINE_TAGS_FORMAT;
/** The character's saved language and speed over the node's own defaults; the mode starts on Auto. */
function storedOptions(group) {
@@ -182,22 +149,51 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
return {
language: LANGUAGES.includes(defaults.language) ? defaults.language : 'auto',
speed: typeof defaults.speed === 'number' ? defaults.speed : state.spec?.speed?.default ?? null,
preset: AUTO,
preset: AUTO_MODE,
};
}
function currentOptions(group) {
if (!group) return { language: 'auto', speed: state.spec?.speed?.default ?? 1, preset: AUTO_MODE };
if (!state.options.has(group.character)) state.options.set(group.character, { ...storedOptions(group) });
return state.options.get(group.character);
const opts = state.options.get(group.character);
if (opts.preset !== AUTO_MODE && !state.presets?.presets?.[opts.preset]) opts.preset = AUTO_MODE; // deleted meanwhile
return opts;
}
const sameOptions = (a, b) => a.language === b.language && a.speed === b.speed;
const valuesOfLine = line => lineValues(line, currentOptions(getScript().speakerOf(line)));
/** The numbers Auto gives this character's lines in the chosen language (its own one when that is Auto). */
function autoTuning(group, opts) {
/** The tags `line` needs (script_tune.js), for the director's packages; none for an older node. */
function tagsOf(line, speaker) {
if (!canTag() || !speaker) return '';
return tuneTags(lineValues(line, currentOptions(speaker)), storedOptions(speaker));
}
/** The language Auto's numbers are shown for: the one chosen, else the character's own. */
function autoKey(group, values) {
const refLang = group.raw?.reference?.language || group.language || '';
const key = opts.language !== 'auto' ? opts.language : (refLang || 'all');
return { key, values: lineTuning(state.presets, tuningRows(characterSettings(group)), refLang, key).values };
return values.language !== 'auto' ? values.language : (refLang || 'all');
}
/** A mode's four numbers for `group` reading with `values`: Auto's from the presets and its settings. */
function modeNumbers(group, values, id) {
if (id !== AUTO_MODE) return state.presets?.presets?.[id];
const refLang = group.raw?.reference?.language || group.language || '';
return lineTuning(state.presets, tuningRows(characterSettings(group)), refLang, autoKey(group, values)).values;
}
/** One line read by its character (`speaker`) in mode `mode`, for the listen row and a card's ▶. */
function takeFor(line, mode, speaker = null, values = null) {
const who = speaker || getScript().speakerOf(line);
const own = values || (line ? valuesOfLine(line) : currentOptions(who));
const id = mode ?? own.preset;
const words = String(line?.text || '').trim();
const refLang = who.raw?.reference?.language || who.language;
const text = words
? `${line.emotion && line.emotion !== 'main' ? `{${line.emotion}}` : ''}${words}`
: SAMPLE_LINES[refLang] || SAMPLE_LINES.zh;
return { character: who.character, text, language: own.language, speed: own.speed, mode: id, preset: id, numbers: modeNumbers(who, own, id) };
}
async function reloadPresets() {
@@ -211,9 +207,10 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
try {
const [status, spec] = await Promise.all([loadGptSovitsStatus(), loadNodeSpec()]);
state.spec = spec;
state.supported = Number(status?.format) >= MIN_FORMAT && NUMBERS.every(name => spec[name]);
state.format = Number(status?.format) || 0;
state.supported = state.format >= MIN_FORMAT && NUMBERS.every(name => spec[name]);
state.local = status?.local !== false;
if (Number(status?.format) >= PRESETS_FORMAT) state.presets = await loadPresets();
if (state.format >= PRESETS_FORMAT) state.presets = await loadPresets();
} catch (_) {
state.supported = false;
}
@@ -232,82 +229,20 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
} catch (error) {
state.presetNote = t('ttsPresetsSaveFailed', { error: error.message });
}
keepParamFocus(modeBox, render);
}
function renderMode(group, busy) {
modeBox.hidden = !state.presets;
state.takeNow = null;
if (!state.presets) return;
const opts = currentOptions(group);
const view = state.presets;
// The same line for every mode: the one you are on in the director, read by its own character;
// an empty one, a line in the character's language.
const script = getScript();
const index = script?.listen ?? 0;
const chosen = script?.lines?.[index];
const speaker = (chosen && script.speakerOf?.(chosen)) || group;
const auto = autoTuning(speaker, opts);
const values = id => (id === AUTO ? auto.values : view.presets[id]);
if (opts.preset !== AUTO && !view.presets[opts.preset]) opts.preset = AUTO; // deleted in the settings meanwhile
const choices = [{ id: AUTO, label: t('scriptRunModeAuto') },
...Object.keys(view.presets).map(name => ({ id: name, label: presetLabel(view.labels, name, t), badge: isBuiltin(name) ? '' : t('ttsPresetCustom') }))];
const pick = (id) => {
opts.preset = id;
state.presetNote = '';
render();
};
const words = String(chosen?.text || '').trim();
const refLang = speaker.raw?.reference?.language || speaker.language;
const text = words
? `${chosen.emotion && chosen.emotion !== 'main' ? `{${chosen.emotion}}` : ''}${words}`
: SAMPLE_LINES[refLang] || SAMPLE_LINES.zh;
const lineLabel = el('span', 'anomalous-sd-run-listen-line', !words ? t('scriptRunListenSample', { text })
: speaker !== group ? t('scriptRunListenLineAs', { n: index + 1, text: words, who: speaker.character })
: t('scriptRunListenLine', { n: index + 1, text: words }));
lineLabel.title = t('scriptRunListenLineHint');
const take = id => ({ character: speaker.character, text, language: opts.language, speed: opts.speed, mode: id, preset: id, numbers: values(id) });
state.takeNow = busy ? null : () => take(opts.preset);
const lang = auto.key === 'all' ? t('ttsTuningAll') : t(`scriptRunLang_${auto.key}`);
const modes = renderPresetSwitch({
choices,
selected: opts.preset,
base: AUTO,
baseName: t('scriptRunModeAutoBase'),
baseText: t('scriptRunModeIsAuto'),
values,
describe: id => (id === AUTO ? t('scriptRunModeAutoShort', { lang })
: t('scriptRunModeWhole', { about: isBuiltin(id) ? t(`ttsPresetDesc_${id}`) : t('ttsPresetDescCustom') })),
builtin: id => (isBuiltin(id) ? view.builtin[id] : null),
editable: id => id !== AUTO && state.local,
onPick: pick,
onEdit: editPreset,
listen: preview.row({ takeFor: take, selected: opts.preset, modes: choices, onPick: pick, disabled: busy, lead: [lineLabel], withButton: false }),
disabled: busy,
compact: !state.roomy,
});
const note = el('div', 'anomalous-sd-run-note', state.presetNote);
note.hidden = !state.presetNote;
const links = el('div', 'anomalous-sd-run-links');
links.append(
button('anomalous-sd-link-btn', t('scriptRunOpenPresets'), () => openTtsSetup({ groups: getGroups?.() || [], showPresets: true, onChanged: reloadPresets })),
button('anomalous-sd-link-btn', t('scriptRunOpenTuning', { name: speaker.character }), () => openTuningEditor(speaker, {
onSaved: (saved) => {
if (saved?.raw?.settings) state.settings.set(speaker.character, saved.raw.settings);
render();
},
})),
);
modeBox.replaceChildren(el('span', 'anomalous-sd-run-label', t('scriptRunMode')), modes, note, links);
keepParamFocus(settings, render);
}
async function generate() {
const script = getScript();
if (!script?.group || script.pkg.error || state.job || !state.supported) return;
const character = script.group.character;
const { language: lang, speed: speedValue, preset } = currentOptions(script.group);
const chosen = state.presets && preset !== AUTO ? { preset } : {};
const prompt = buildTtsPrompt({ character, speech: script.pkg.speech, seed: state.seed, language: lang, speed: speedValue, ...chosen });
let inputs = {};
if (!canTag()) {
// An older node: the whole script as the first line's character reads it.
const { language, speed, preset } = currentOptions(script.group);
inputs = { language, speed, ...(state.presets && preset !== AUTO_MODE ? { preset } : {}) };
}
const prompt = buildTtsPrompt({ character, speech: script.pkg.speech, seed: state.seed, ...inputs });
state.status = { state: 'queued' };
const started = Date.now();
state.job = startPromptJob(prompt, {
@@ -336,35 +271,92 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
}
}
async function storeDefaults() {
const script = getScript();
if (!script?.group) return;
const { character } = script.group;
const opts = { ...currentOptions(script.group) };
saveDefaults.disabled = true;
async function storeDefaults(group) {
const { character } = group;
const opts = { ...currentOptions(group) };
state.saving = true;
render();
try {
// Start from the file as it is now, so edits made elsewhere (the generation settings) are kept.
const fresh = await fetchGptSovitsCharacter(character);
if (fresh.settings_error) throw new Error(t('scriptRunSettingsBroken', { error: fresh.settings_error }));
const settings = { ...(fresh.settings || {}), format: 1 };
const saved = { ...(fresh.settings || {}), format: 1 };
// Only what differs from the node's own defaults, so the file stays short.
const defaults = { ...(settings.defaults || {}) };
const defaults = { ...(saved.defaults || {}) };
if (opts.language !== 'auto') defaults.language = opts.language; else delete defaults.language;
if (opts.speed !== state.spec.speed.default) defaults.speed = opts.speed; else delete defaults.speed;
if (Object.keys(defaults).length) settings.defaults = defaults; else delete settings.defaults;
await saveGptSovitsSettings(character, settings);
state.settings.set(character, settings);
state.saved.set(character, opts);
saveDefaults.textContent = t('scriptRunDefaultsSaved');
setTimeout(() => { saveDefaults.textContent = t('scriptRunSaveDefaults'); render(); }, 1600);
if (Object.keys(defaults).length) saved.defaults = defaults; else delete saved.defaults;
await saveGptSovitsSettings(character, saved);
state.settings.set(character, saved);
state.saved.set(character, { ...opts, preset: AUTO_MODE });
state.savedNow = character;
setTimeout(() => { state.savedNow = null; render(); }, 1600);
} catch (error) {
await anomalousAlert(t('scriptRunDefaultsFailed', { error: error.message }));
} finally {
saveDefaults.disabled = false;
state.saving = false;
render();
}
}
function saveButton(group) {
const label = state.savedNow === group.character ? t('scriptRunDefaultsSaved') : t('scriptRunSaveDefaults');
const btn = button('anomalous-sd-link-btn', label, () => storeDefaults(group), t('scriptRunSaveDefaultsHint'));
btn.disabled = state.saving;
return btn;
}
/** The side panel's view of the moment (ui_script_tune.js). */
function renderSettings(script, busy) {
const changed = () => {
onLinesChange?.();
render();
};
renderTunePanel(settings, {
script,
view: state.presets,
spec: state.spec,
busy,
local: state.local,
roomy: state.roomy,
canTag: canTag(),
presetNote: state.presetNote,
preview,
options: currentOptions,
lineValues: valuesOfLine,
setCharacter: (group, field, value) => {
currentOptions(group)[field] = value;
state.presetNote = '';
changed();
},
setLines: (indexes, field, value) => {
setLineTune(indexes.map(index => script.lines[index]), field, value);
state.presetNote = '';
changed();
},
clearLines: (indexes) => {
clearLineTune(indexes.map(index => script.lines[index]));
changed();
},
modeNumbers,
autoLanguage: (group, values) => {
const key = autoKey(group, values);
return key === 'all' ? t('ttsTuningAll') : t(`scriptRunLang_${key}`);
},
takeFor,
editPreset,
canSave: group => state.savedNow === group.character || !sameOptions(currentOptions(group), storedOptions(group)),
saveButton,
openPresets: () => openTtsSetup({ groups: getGroups?.() || [], showPresets: true, onChanged: reloadPresets }),
openTuning: group => openTuningEditor(group, {
onSaved: (saved) => {
if (saved?.raw?.settings) state.settings.set(group.character, saved.raw.settings);
render();
},
}),
});
}
function renderStatus() {
const s = state.status;
let text = '';
@@ -389,23 +381,7 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
generateBtn.title = t('scriptRunHint', { character: group?.character || '' });
settings.hidden = actions.hidden = !ready;
if (ready) {
const opts = currentOptions(group);
for (const [code, choice] of languageButtons) {
choice.setAttribute('aria-checked', String(opts.language === code));
choice.disabled = busy;
}
const range = state.spec.speed;
Object.assign(speed, { min: String(range.min), max: String(range.max), step: String(range.step ?? 1) });
speed.value = String(opts.speed);
speed.disabled = busy;
speedValue.textContent = String(opts.speed);
renderMode(group, busy);
// Language and speed belong to one character: with several reading, there is no one to keep them for.
saveDefaults.hidden = sameOptions(opts, storedOptions(group)) || (pkg.speakers?.length || 0) > 1;
} else {
saveDefaults.hidden = true;
}
if (ready) renderSettings(script, busy);
generateBtn.disabled = !ready || Boolean(pkg.error) || busy;
const hasResult = Boolean(state.result && group && state.result.character === group.character);
@@ -430,10 +406,13 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
bar,
render,
generate,
/** A card's ▶: its line (the director set it as the one to listen with) in the mode picked. */
tagsOf,
/** A card's ▶: its line (the director set it as the one to listen with) as it will be read. */
listen() {
render();
if (state.takeNow) preview.play(state.takeNow());
const script = getScript();
const line = script?.lines?.[script.listen];
if (!line || !this.canListen()) return;
preview.play(takeFor(line));
},
/** Whether the cards show ▶ (the node has modes, a character is chosen, nothing runs). */
canListen() {
@@ -443,6 +422,8 @@ export function createRunSection({ getScript, getGroups, onPlay, resetTakes, onB
canRetake(group) {
return Boolean(state.supported && !state.job && state.result && group && state.result.character === group.character);
},
/** The presets, for the tags on the cards (ui_script_tune.js ownTuneText). */
presetsView: () => state.presets,
stopPlayback() {
player.pause();
preview.stop();
+299
View File
@@ -0,0 +1,299 @@
import { t } from './interface_settings.js';
import { button, el } from './usage_view.js';
import { isBuiltin, presetLabel } from './tts_presets_api.js';
import { AUTO_MODE, hasOwnTune, lineNumbers, ownTune, sharedValue } from './script_tune.js';
import { speakerColor } from './ui_script_cast.js';
import { renderPresetSwitch } from './ui_tts_preset_switch.js';
/**
* The Script Director's side panel, which follows what is picked on the left: nothing, the whole
* script (each character and how it reads); a character in the cast, how it reads in this script
* (every line of it follows); picked lines, who reads them and how, each value following the
* character until it is set here (then the card shows a tag and ↺ lets it follow again).
* ui_script_run.js keeps the state and draws this again after each change (`ctx`, see
* renderTunePanel); the lines are the director's.
*/
export const LANGUAGES = ['auto', 'zh', 'ja', 'en'];
const FOLLOW = '__follow'; // a line's mode choice: its character's
/** "1.2×" */
export const speedText = speed => `${Math.round(Number(speed) * 100) / 100}×`;
/** A mode's name: Auto, a built-in preset or one made in Anomalous. */
export function modeName(view, id) {
return id === AUTO_MODE || !view ? t('scriptRunModeAuto') : presetLabel(view.labels, id, t);
}
/** "中文 · 1.2× · 有起伏": a set of values in a few words (`skip`: leave out the auto language). */
export function valuesText(view, values, { skip = false } = {}) {
const parts = [];
if (!(skip && values.language === 'auto')) parts.push(t(`scriptRunLang_${values.language || 'auto'}`));
if (typeof values.speed === 'number') parts.push(speedText(values.speed));
parts.push(modeName(view, values.preset ?? AUTO_MODE));
return parts.join(' · ');
}
/** What a line set for itself, for the tag on its card; '' when it follows its character. */
export function ownTuneText(view, line) {
if (!hasOwnTune(line)) return '';
const own = ownTune(line);
const parts = [];
if (own.language !== undefined) parts.push(t(`scriptRunLang_${own.language}`));
if (own.speed !== undefined) parts.push(speedText(own.speed));
if (own.preset !== undefined) parts.push(modeName(view, own.preset));
return parts.join(' · ');
}
function heading(title, meta, back) {
const head = el('div', 'anomalous-sd-tune-head');
if (back) head.append(button('anomalous-sd-link-btn anomalous-sd-tune-back', t('scriptTuneBack'), back));
head.append(el('div', 'anomalous-sd-tune-title', title));
if (meta) head.append(el('div', 'anomalous-sd-tune-meta', meta));
return head;
}
function row(label, ...content) {
const line = el('div', 'anomalous-sd-run-row');
line.append(el('span', 'anomalous-sd-run-label', label), ...content);
return line;
}
/** Choices in a segment; `checked(id)`; a click on the checked one does nothing. */
function segment(choices, checked, onPick, disabled) {
const box = el('div', 'anomalous-sd-run-segment');
box.setAttribute('role', 'radiogroup');
for (const choice of choices) {
const chip = button(`anomalous-sd-run-choice${choice.className ? ` ${choice.className}` : ''}`, choice.label, () => {
if (!checked(choice.id)) onPick(choice.id);
});
chip.setAttribute('role', 'radio');
chip.setAttribute('aria-checked', String(checked(choice.id)));
chip.disabled = disabled;
if (choice.title) chip.title = choice.title;
box.append(chip);
}
return box;
}
/** The speed slider: `value`, `onChange(number)`; `after`: elements after the number. */
function speedSlider(ctx, value, onChange, after = []) {
const range = ctx.spec.speed;
const slider = el('input', 'anomalous-sd-run-slider');
Object.assign(slider, { type: 'range', min: String(range.min), max: String(range.max), step: String(range.step ?? 1) });
slider.value = String(value ?? range.default);
slider.title = t('scriptRunParamHint_speed');
slider.disabled = ctx.busy;
const shown = el('span', 'anomalous-sd-run-value', String(value ?? range.default));
slider.oninput = () => { shown.textContent = slider.value; };
slider.onchange = () => {
const number = Math.min(range.max, Math.max(range.min, Number(slider.value)));
onChange(Number.isFinite(number) ? number : range.default);
};
return row(t('scriptRunSpeed'), slider, shown, ...after);
}
/**
* The mode switch with its listen row; `line`: the line heard (null: a sample), read by `speaker` with
* `values` (null: the line's own); `choices` and `base` as renderPresetSwitch.
*/
function modeBox(ctx, { speaker, line, values = null, selected, choices, base, baseName, baseText, describe, numbers, onPick }) {
const box = el('div', 'anomalous-sd-run-mode');
if (!ctx.view) return box;
const view = ctx.view;
const words = String(line?.text || '').trim();
const take = id => ctx.takeFor(line, id === FOLLOW ? ctx.options(speaker).preset : id, speaker, values);
const index = ctx.script.lines.indexOf(line);
const lead = el('span', 'anomalous-sd-run-listen-line', !words ? t('scriptRunListenSample', { text: take(selected).text })
: t('scriptRunListenLineAs', { n: index + 1, text: words, who: speaker.character }));
lead.title = t('scriptRunListenLineHint');
const switcher = renderPresetSwitch({
choices,
selected,
base,
baseName,
baseText,
values: numbers,
describe,
builtin: id => (isBuiltin(id) ? view.builtin[id] : null),
editable: id => id !== AUTO_MODE && id !== FOLLOW && ctx.local,
onPick,
onEdit: ctx.editPreset,
listen: ctx.preview.row({ takeFor: take, selected, modes: choices, onPick, disabled: ctx.busy, lead: [lead] }),
disabled: ctx.busy,
compact: !ctx.roomy,
});
const note = el('div', 'anomalous-sd-run-note', ctx.presetNote);
note.hidden = !ctx.presetNote;
box.append(el('span', 'anomalous-sd-run-label', t('scriptRunMode')), switcher, note);
return box;
}
const presetChoices = view => [{ id: AUTO_MODE, label: t('scriptRunModeAuto') },
...Object.keys(view?.presets || {}).map(name => ({ id: name, label: presetLabel(view.labels, name, t), badge: isBuiltin(name) ? '' : t('ttsPresetCustom') }))];
const presetAbout = id => (isBuiltin(id) ? t(`ttsPresetDesc_${id}`) : t('ttsPresetDescCustom'));
function links(ctx, speaker) {
const box = el('div', 'anomalous-sd-run-links');
box.append(button('anomalous-sd-link-btn', t('scriptRunOpenPresets'), ctx.openPresets));
if (speaker) box.append(button('anomalous-sd-link-btn', t('scriptRunOpenTuning', { name: speaker.character }), () => ctx.openTuning(speaker)));
return box;
}
/** Each character reading in this script, a click opens it. */
function renderScriptView(ctx) {
const { script } = ctx;
const spoken = script.lines.filter(line => line.text.trim());
const cast = script.groups.filter(group => spoken.some(line => script.speakerOf(line) === group));
const parts = [heading(t('scriptTuneScript'), t('scriptTuneScriptMeta', { count: spoken.length, speakers: cast.length }))];
const list = el('div', 'anomalous-sd-tune-cast');
for (const group of cast) {
const lines = spoken.filter(line => script.speakerOf(line) === group);
const own = lines.filter(hasOwnTune).length;
const item = button('anomalous-sd-tune-person', '', () => script.focusCharacter(group.group));
item.style.setProperty('--speaker', speakerColor(group.group, script.groups));
const name = el('span', 'anomalous-sd-tune-person-name');
name.append(el('span', 'anomalous-sd-speaker-dot'), el('span', '', group.character));
name.firstChild.style.background = speakerColor(group.group, script.groups);
const about = el('span', 'anomalous-sd-tune-person-about', valuesText(ctx.view, ctx.options(group)));
const count = el('span', 'anomalous-sd-tune-person-count', t('scriptTuneLineCount', { count: lines.length }));
item.append(name, count, about);
if (own) item.append(el('span', 'anomalous-sd-tune-person-own', t('scriptTuneOwnCount', { count: own })));
list.append(item);
}
parts.push(list, el('div', 'anomalous-sd-run-note', t('scriptTuneScriptHint')), links(ctx, null));
return parts;
}
/** One character in this script: its language, speed and mode, for every line of it that follows. */
function renderCharacterView(ctx, group) {
const { script } = ctx;
const values = ctx.options(group);
const indexes = script.lines.map((line, index) => [line, index]).filter(([line]) => line.text.trim() && script.speakerOf(line) === group).map(([, index]) => index);
const meta = indexes.length ? t('scriptTuneCharacterMeta', { lines: lineNumbers(indexes), count: indexes.length }) : t('scriptTuneCharacterNone');
const parts = [heading(t('scriptTuneCharacter', { name: group.character }), meta, script.focusScript)];
const set = (field, value) => ctx.setCharacter(group, field, value);
parts.push(row(t('scriptRunLanguage'), segment(LANGUAGES.map(code => ({ id: code, label: t(`scriptRunLang_${code}`) })),
code => values.language === code, code => set('language', code), ctx.busy)));
parts.push(speedSlider(ctx, values.speed, value => set('speed', value)));
// The line heard: the one listened to on the left when it is this character's, else its first.
const listened = script.lines[script.listen];
const line = listened && script.speakerOf(listened) === group ? listened : (script.lines[indexes[0]] || null);
const lang = ctx.autoLanguage(group, values);
parts.push(modeBox(ctx, {
speaker: group,
line,
values,
selected: values.preset,
choices: presetChoices(ctx.view),
base: AUTO_MODE,
baseName: t('scriptRunModeAutoBase'),
baseText: t('scriptRunModeIsAuto'),
describe: id => (id === AUTO_MODE ? t('scriptRunModeAutoShort', { lang }) : t('scriptRunModeWhole', { about: presetAbout(id) })),
numbers: id => ctx.modeNumbers(group, values, id),
onPick: id => set('preset', id),
}));
parts.push(el('div', 'anomalous-sd-run-note', t('scriptTuneCharacterHint')));
const own = indexes.filter(index => hasOwnTune(script.lines[index]));
const foot = el('div', 'anomalous-sd-run-links');
if (ctx.canSave(group)) foot.append(ctx.saveButton(group));
if (own.length) foot.append(button('anomalous-sd-link-btn', t('scriptTuneResetLines', { count: own.length }), () => ctx.clearLines(own)));
if (foot.childNodes.length) parts.push(foot);
parts.push(links(ctx, group));
return parts;
}
/** Picked lines: who reads them, then each value following its character until set here. */
function renderLinesView(ctx, indexes) {
const { script } = ctx;
const lines = indexes.map(index => script.lines[index]).filter(Boolean);
const speakers = [...new Set(lines.map(line => script.speakerOf(line)).filter(Boolean))];
const single = speakers.length === 1 ? speakers[0] : null;
const title = lines.length === 1 ? t('scriptTuneLine', { n: indexes[0] + 1 }) : t('scriptTuneLines', { count: lines.length });
const meta = single ? t('scriptTuneReadBy', { name: single.character })
: speakers.map(group => `${group.character} ${lines.filter(line => script.speakerOf(line) === group).length}`).join(' · ');
const parts = [heading(title, meta, script.focusScript)];
const valuesOf = line => ctx.lineValues(line);
const set = (field, value) => ctx.setLines(indexes, field, value);
const followText = field => {
if (!single) return t('scriptTuneFollowEach');
const value = ctx.options(single)[field];
return field === 'speed' ? speedText(value) : field === 'language' ? t(`scriptRunLang_${value}`) : modeName(ctx.view, value);
};
if (script.groups.length > 1) {
const who = segment(script.groups.map(group => ({ id: group.group, label: group.character, className: 'anomalous-sd-tune-who' })),
key => speakers.length === 1 && single.group === key, key => script.assign(indexes, key), ctx.busy);
who.querySelectorAll('.anomalous-sd-tune-who').forEach((chip, at) => chip.style.setProperty('--speaker', speakerColor(script.groups[at].group, script.groups)));
parts.push(row(t('scriptTuneWho'), who));
}
const language = sharedValue(lines, 'language', valuesOf);
const languageChoices = [{ id: FOLLOW, label: t('scriptTuneFollowValue', { value: followText('language') }), title: t('scriptTuneFollowHint') },
...LANGUAGES.map(code => ({ id: code, label: t(`scriptRunLang_${code}`) }))];
parts.push(row(t('scriptRunLanguage'), segment(languageChoices,
id => (id === FOLLOW ? !language.some : language.own && !language.mixed && language.value === id),
id => set('language', id === FOLLOW ? undefined : id), ctx.busy)));
const speed = sharedValue(lines, 'speed', valuesOf);
const speedAfter = speed.some
? [button('anomalous-sd-icon-btn anomalous-sd-tune-reset', '↺', () => set('speed', undefined), t('scriptTuneReset'))]
: [el('span', 'anomalous-sd-tune-follow', t('scriptTuneFollowing'))];
parts.push(speedSlider(ctx, speed.mixed ? null : speed.value, value => set('speed', value), speedAfter));
if (ctx.view) {
const mode = sharedValue(lines, 'preset', valuesOf);
const selected = !mode.some ? FOLLOW : (mode.own && !mode.mixed ? mode.value : null);
const speaker = single || speakers[0];
const heard = lines.includes(script.lines[script.listen]) ? script.lines[script.listen] : lines[0];
const followName = single ? single.character : t('scriptTuneFollowEach');
parts.push(modeBox(ctx, {
speaker: script.speakerOf(heard) || speaker,
line: heard,
selected: selected ?? FOLLOW,
choices: [{ id: FOLLOW, label: t('scriptTuneFollowValue', { value: followText('preset') }) }, ...presetChoices(ctx.view)],
base: FOLLOW,
baseName: t('scriptTuneFollowBase', { name: followName }),
baseText: t('scriptTuneModeFollow', { name: followName }),
describe: id => (id === FOLLOW ? '' : id === AUTO_MODE ? t('scriptRunModeAutoShort', { lang: ctx.autoLanguage(speaker, valuesOf(heard)) })
: t('scriptTuneModeLines', { about: presetAbout(id) })),
numbers: id => {
const owner = script.speakerOf(heard) || speaker;
const values = valuesOf(heard);
return ctx.modeNumbers(owner, values, id === FOLLOW ? ctx.options(owner).preset : id);
},
onPick: id => set('preset', id === FOLLOW ? undefined : id),
}));
}
if (lines.some(hasOwnTune)) {
const foot = el('div', 'anomalous-sd-run-links');
foot.append(button('anomalous-sd-link-btn', t('scriptTuneResetAll'), () => ctx.clearLines(indexes)));
parts.push(foot);
}
parts.push(links(ctx, single));
return parts;
}
/**
* Draws the panel into `box`. `ctx`: `script` (the director's: lines, groups, speakerOf, listen,
* picked indexes, focus `{ character }` or null, assign / focusCharacter / focusScript), `view` (the
* presets or null), `spec`, `busy`, `local`, `roomy`, `canTag` (the node reads a line's own values),
* `presetNote`, `preview`, and the run section's calls: `options(group)` (its values in this script),
* `lineValues(line)`, `setCharacter(group, field, value)`, `setLines(indexes, field, value)`,
* `clearLines(indexes)`, `modeNumbers(group, values, id)`, `autoLanguage(group, values)`,
* `takeFor(line, mode, speaker, values)`, `editPreset`, `canSave(group)`, `saveButton(group)`,
* `openPresets()`, `openTuning(group)`.
*/
export function renderTunePanel(box, ctx) {
const { script } = ctx;
const group = script.focus?.character ? script.groups.find(item => item.group === script.focus.character) : null;
let parts;
if (script.picked.length) parts = renderLinesView(ctx, script.picked);
else if (group) parts = renderCharacterView(ctx, group);
else parts = renderScriptView(ctx);
if (!ctx.canTag && (script.picked.length || group)) {
parts.splice(1, 0, el('div', 'anomalous-sd-run-hint is-warning', t('scriptTuneOldTts')));
}
box.replaceChildren(...parts);
}
+1
View File
@@ -32,3 +32,4 @@
@import url("./styles/31-model-info.css?v=20261014-desc-2");
@import url("./styles/32-canvas-tools.css?v=20261019-places");
@import url("./styles/33-kept-combos.css?v=20261019-hero");
@import url("./styles/34-voice-over-panel.css?v=20261020-tune");
+104
View File
@@ -0,0 +1,104 @@
/* Voice-over's side panel, which follows what is picked on the left (ui_script_tune.js). */
.anomalous-sd-tune-head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.anomalous-sd-tune-back {
padding: 0;
font-size: 11.5px;
}
.anomalous-sd-tune-title {
color: var(--amb-text-main);
font-size: 14px;
font-weight: 600;
}
.anomalous-sd-tune-meta {
color: var(--amb-text-soft);
font-size: 12px;
}
/* The whole script: one row per character reading, a click opens it. */
.anomalous-sd-tune-cast {
display: flex;
flex-direction: column;
gap: 4px;
}
.anomalous-sd-tune-person {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 10px;
padding: 7px 10px;
border: 1px solid var(--amb-border);
border-left: 3px solid var(--speaker, var(--amb-border));
border-radius: 0 8px 8px 0;
background: rgba(var(--amb-ink-rgb), 0.03);
color: var(--amb-text-main);
font: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.anomalous-sd-tune-person:hover {
background: rgba(var(--amb-ink-rgb), 0.07);
}
.anomalous-sd-tune-person-name {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
font-weight: 600;
}
.anomalous-sd-tune-person-count {
color: var(--amb-text-dim);
font-size: 11.5px;
}
.anomalous-sd-tune-person-about {
grid-column: 1 / -1;
color: var(--amb-text-soft);
font-size: 12px;
}
.anomalous-sd-tune-person-own {
grid-column: 1 / -1;
color: var(--amb-warn);
font-size: 11.5px;
}
/* Who reads the picked lines: each name in its character's colour. */
.anomalous-sd-run-choice.anomalous-sd-tune-who[aria-checked="true"] {
background: color-mix(in srgb, var(--speaker) 30%, transparent);
}
.anomalous-sd-tune-follow {
color: var(--amb-text-dim);
font-size: 11px;
}
.anomalous-sd-tune-reset {
flex-shrink: 0;
}
/* On a line card: what the line set for itself. */
.anomalous-sd-line-tune {
flex-shrink: 0;
max-width: 160px;
overflow: hidden;
padding: 1px 7px;
border-radius: 6px;
background: color-mix(in srgb, var(--amb-warn) 18%, transparent);
color: var(--amb-text-main);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}