diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 733b7c4..3b0f64a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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//`). `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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d52d9..b2b5290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/guides/audio-studio.md b/docs/guides/audio-studio.md index c8172f3..4972169 100644 --- a/docs/guides/audio-studio.md +++ b/docs/guides/audio-studio.md @@ -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. 不满意: - 某一句不好,点那张卡片上的 **↻**,只把这一句换一版,其他句子不变(直接用缓存,很快)。 - 整段都不好,点 **整段换一版**,换一个种子全部重新生成。 diff --git a/tests/script_tune.mjs b/tests/script_tune.mjs new file mode 100644 index 0000000..1c72bb3 --- /dev/null +++ b/tests/script_tune.mjs @@ -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'); diff --git a/web/modules/audio_script.js b/web/modules/audio_script.js index 4cb3233..28274bc 100644 --- a/web/modules/audio_script.js +++ b/web/modules/audio_script.js @@ -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(); diff --git a/web/modules/browser.js b/web/modules/browser.js index 58127c1..44b01e1 100644 --- a/web/modules/browser.js +++ b/web/modules/browser.js @@ -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 { diff --git a/web/modules/locales.js b/web/modules/locales.js index b0d0563..2e831b1 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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", diff --git a/web/modules/script_tune.js b/web/modules/script_tune.js new file mode 100644 index 0000000..86f0e8a --- /dev/null +++ b/web/modules/script_tune.js @@ -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(', '); +} diff --git a/web/modules/ui_audio_sidebar.js b/web/modules/ui_audio_sidebar.js index dbc0784..fa7b6d4 100644 --- a/web/modules/ui_audio_sidebar.js +++ b/web/modules/ui_audio_sidebar.js @@ -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); diff --git a/web/modules/ui_browser_navigation.js b/web/modules/ui_browser_navigation.js index 0af1e21..729a8b7 100644 --- a/web/modules/ui_browser_navigation.js +++ b/web/modules/ui_browser_navigation.js @@ -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 diff --git a/web/modules/ui_script_cards.js b/web/modules/ui_script_cards.js index 9d3bf0a..b4c27f5 100644 --- a/web/modules/ui_script_cards.js +++ b/web/modules/ui_script_cards.js @@ -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', () => { diff --git a/web/modules/ui_script_cast.js b/web/modules/ui_script_cast.js index 95a79ec..90c929e 100644 --- a/web/modules/ui_script_cast.js +++ b/web/modules/ui_script_cast.js @@ -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']; diff --git a/web/modules/ui_script_director.js b/web/modules/ui_script_director.js index c13104c..1f81007 100644 --- a/web/modules/ui_script_director.js +++ b/web/modules/ui_script_director.js @@ -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'); } diff --git a/web/modules/ui_script_run.js b/web/modules/ui_script_run.js index aba1914..b6b958a 100644 --- a/web/modules/ui_script_run.js +++ b/web/modules/ui_script_run.js @@ -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(); diff --git a/web/modules/ui_script_tune.js b/web/modules/ui_script_tune.js new file mode 100644 index 0000000..a908a26 --- /dev/null +++ b/web/modules/ui_script_tune.js @@ -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); +} diff --git a/web/styles.css b/web/styles.css index bcda57f..888eada 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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"); diff --git a/web/styles/34-voice-over-panel.css b/web/styles/34-voice-over-panel.css new file mode 100644 index 0000000..750451a --- /dev/null +++ b/web/styles/34-voice-over-panel.css @@ -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; +}