Reference audio editor: every clip listed, main and auxiliary references
"Reference audio and emotions" replaces Edit emotions: a large window with the voices on top (main voice, emotions, new emotion) and every clip of the character below, one row each (listen, file and length, its line editable in place, where else it is used, Main / Aux for the voice picked), with filters, search and paging. Auxiliary references (timbre averaged in, v1 / v2 / v2Pro), fixed lines (`lines`) and lengths need Anomalous_TTS interface 18. tts_voice_refs.js holds the rules; the old clip search and merge helper go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0a315bf1f8
commit
a3ce3cae94
+12
-6
@@ -296,12 +296,18 @@ covered by the `styles.css` manifest.
|
||||
requests; `invalidateEngineCache()` drops them after a save or an added voice,
|
||||
and `invalidateEngineCache({ rescan: true })` (Refresh button) also makes the node
|
||||
re-read its folders (`?refresh=1`).
|
||||
`ui_audio_tts_editor.js` edits a character's emotion references through that API:
|
||||
it opens at once, loads the character's audio list and their lines
|
||||
(`/anomalous_tts/reference_lines`, interface 14; optional) in the background (save
|
||||
waits for the list), and keeps settings fields it does not know.
|
||||
`ui_tts_clip_search.js` is its clip search: by file name or line, the first 30
|
||||
matches with listen, use as main voice and add as emotion.
|
||||
`ui_audio_tts_editor.js` is the reference audio editor (a character card's "Reference audio
|
||||
and emotions"): a large window with the voices on top (main voice, each emotion, new
|
||||
emotion) and every clip of the character below, one row each (listen, file and length, its
|
||||
line editable in place, where else it is used, Main / Aux for the voice picked), with filters,
|
||||
a search and the first 60 rows then more. It opens at once and loads the character's clips,
|
||||
their lines and lengths (`/anomalous_tts/reference_lines`) in the background (save waits for
|
||||
them), and keeps settings fields it does not know. `tts_voice_refs.js` holds its rules without
|
||||
DOM: the voices read from the settings and the node's view (an emotion found from a file
|
||||
name is written only once changed), one main reference and any number of auxiliary ones per
|
||||
voice, the lines fixed here (`lines`; a voice's own `text` moves there), what keeps it from
|
||||
saving, the settings written back, and the order of the list. Auxiliary references and
|
||||
fixed lines need Anomalous_TTS interface 18 (auxiliary ones a v1 / v2 / v2Pro model).
|
||||
`tts_setup_api.js` holds the other calls (pretrained downloads, chunked
|
||||
upload, inspect, commit, discard) and the pure import-form rules (`importKind`,
|
||||
`pickWeights`, `nameConflict`, `textFromFile`, `buildImportBody`, `importProblem`,
|
||||
|
||||
@@ -61,6 +61,13 @@
|
||||
|
||||
## v1.60.1 — unreleased
|
||||
|
||||
### 🎚️ Reference audio: every clip in a list, main and auxiliary references (参考音频:全部列出,主参考和副参考)
|
||||
- **Reference audio and emotions** (the character card's button, formerly *Edit emotions*) is now a large window. On top, the **voices**: the main voice, each emotion with how many references it has (*1 main, 2 aux*), and **+ New emotion**. Below, **every clip** of the character, one row each: ▶ to hear it, its file and length, its line, and where else it is used (*Main voice · aux*). For the voice picked, a row is its **Main** reference or one of its **Aux** references, or nothing; unused clips just stay in the list. Filters (*All*, *This voice*, *In use*, *No line*) and a search by line or file name; the first 60 rows, then more.
|
||||
- **Auxiliary references (副参考音频)**: a voice keeps one main reference (its tone and way of speaking, 3–10 s, with a line) and can take any number of auxiliary ones, whose timbre is averaged in when it speaks, as GPT-SoVITS does with several references; they need no line. Only v1 / v2 / v2Pro models use them (the controls say so for v3 / v4).
|
||||
- **A clip's line can be fixed right in its row** (改参考台词): the fixed line is kept in the character's settings and used before its .txt / .list files, which stay as they are. A line that is missing shows *No line found: click to type it*.
|
||||
- Clips too long or too short to be a main reference are marked (they can still be auxiliary). The main voice can go back to the node's own pick; an emotion can be renamed or deleted (one found from a file name is changed by renaming the file). Closing with unsaved changes asks first.
|
||||
- Auxiliary references, fixed lines and lengths need Anomalous_TTS interface 18 (1.1.0); with an older version the window works as before for main references and says what needs the update.
|
||||
|
||||
### 🔍 Scanning (扫描)
|
||||
- **The scan page opens at once, also with many models**: it shows the last counts right away, says it is updating them, and shows the fresh ones when they come. What each model file's header says is now kept on disk (`user/anomalous/model_heads.json`, by file, read again only when the file changes), so a big library or a network drive is not read again after every restart either; requests that come while the page counts share that one count.
|
||||
- **See the models found on Civitai**: the scan page's "Found on Civitai" count opens a list of them, with each model's Civitai name and base model, a box to find one by name, and a way to open each.
|
||||
|
||||
+12
-10
@@ -31,7 +31,7 @@
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ ⠿ 🎙 Arona 编辑情绪 读音 添加文件 3 角色音色预设 │ ← 标题栏:代表“整个角色”
|
||||
│ ⠿ 🎙 Arona 参考音频 读音 添加文件 3 角色音色预设 │ ← 标题栏:代表“整个角色”
|
||||
├───────────────────────────────────────────────┤
|
||||
│ ▶ MAIN “それぞれの学園…” [{main}] │ ← 每一行:一条参考音频
|
||||
│ ▶ ANGRY “相手の雰囲気が…” [{angry}] │
|
||||
@@ -44,7 +44,7 @@
|
||||
| 点 ▶ | 试听这一条 |
|
||||
| 点右边的 **复制 `{标签}`** 按钮(角色设了情绪才有) | 复制标签,粘贴到台词里 |
|
||||
| **拖标题栏** → 放到 **角色语音 (GPT-SoVITS)** 节点 | 节点的“角色”设为这个角色 |
|
||||
| **编辑情绪** / **读音** / **添加文件** | 见第 2、4 节 |
|
||||
| **参考音频与情绪** / **读音** / **添加文件** | 见第 2、4 节 |
|
||||
|
||||
情绪靠台词里的 `{happy}` 这种标签来切换,所以拖的是整个角色,不是某一条音频。
|
||||
|
||||
@@ -205,16 +205,18 @@
|
||||
- 角色文件夹放在存放位置里(默认 `ComfyUI/models/gpt_sovits/`),也可以放在 `extra_model_paths.yaml` 里写的 `gpt_sovits` 目录。自己往存放位置里放整理好的角色文件夹也可以,点 **刷新音色** 就能看到。
|
||||
- 同一个角色有日配、中配两个版本时,会显示成 `阿罗娜/日配`、`阿罗娜/中配` 两个角色。
|
||||
|
||||
### 编辑情绪
|
||||
### 参考音频与情绪
|
||||
|
||||
点角色卡片上的 **编辑情绪**:
|
||||
点角色卡片上的 **参考音频与情绪**,打开一个大窗口:
|
||||
|
||||
最上面是搜索框:输入一句台词里的几个字或文件名,下面列出找到的音频和它的台词,可以试听(▶),点 **设为主声音** 或 **加为情绪**(新加一行情绪,音频和台词都填好,再写个情绪名)。搜台词需要 Anomalous_TTS 接口版本 14,旧版本只搜文件名。
|
||||
1. **上面一排是声音**:主声音、每个情绪(后面写着“1 主 2 副”),最后是 **+ 新情绪**。点一个声音,下面的勾选就只管它。情绪可以改名、删除。
|
||||
2. **下面是这个角色的全部参考音频**,每条一行:▶ 试听、文件名和长度、台词、还用在哪(比如“主声音·副”)。没用上的音频就留在列表里。可以按 **全部 / 这个声音用的 / 已用上 / 没台词** 筛选,也能搜台词或文件名;先显示 60 条,往下点“再显示一些”。
|
||||
3. **主参考**:决定这个声音的语气和念法。每个声音一条,要 3~10 秒、有台词(太长太短的会标黄,不能当主参考)。主声音也可以 **改回自动挑**,让节点自己选。
|
||||
4. **副参考**:只用来融合音色,让声音更稳、更像本人。可以勾好几条,不需要台词,建议同一个人、音质干净的 2~4 条。只有 v1 / v2 / v2Pro 模型用得上,v3 / v4 的角色这一列是灰的。
|
||||
5. **改台词**:直接点一行的台词就能改;找不到台词的会写“没找到台词,点这里填”。改过的台词存在角色的设置里,比 `.txt` / 标注文件优先,原来的文件不动。清空就换回文件里的台词。
|
||||
6. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。有问题(比如某个情绪还没选主参考)会提示,什么都不会改。没保存就关窗口会先问一句。
|
||||
|
||||
1. **主声音 `{main}`**:选一条 3~10 秒的参考音频。留空的话,节点会自己挑一条。
|
||||
2. **情绪**:每行填一个情绪名(例如“开心”),再从角色文件夹里选一条参考音频。点一下音频框会列出全部音频(现在用的那条灰着显示,没选新的就还原);也可以直接打字搜索文件名。
|
||||
3. **参考台词**:换了音频,台词框会自动填上这条音频的台词(节点从同名 `.txt` / `.lab` 或 GPT-SoVITS 的标注文件里找到的,需要 Anomalous_TTS 接口版本 14)。找不到就留空,节点说话时会再找一遍。也可以自己改。
|
||||
4. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。选了不存在的文件时,会提示原因,什么都不会改。
|
||||
副参考、改台词和音频长度需要 Anomalous_TTS 接口版本 18(1.1.0);旧版本只能选主参考,窗口里会说明。
|
||||
|
||||
文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。
|
||||
|
||||
@@ -232,7 +234,7 @@
|
||||
| 拖到节点上提示“列表里还没有 …” | ComfyUI 还没刷新节点定义。按 **R** 后再拖 |
|
||||
| 标题栏拖不动 | 这个角色没有主声音 |
|
||||
| 拖到别的语音节点被拒绝 | 目前只支持“角色语音 (GPT-SoVITS)”节点 |
|
||||
| 卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“编辑情绪”手动选一条 |
|
||||
| 卡片上提示“没有找到合适的主声音” | 角色文件夹里没有 3~10 秒、找得到台词的音频。点“参考音频与情绪”手动选一条 |
|
||||
| 新放进去的角色或音频没出现 | 点 **刷新音色**。节点那边在 ComfyUI 里按 **R** 刷新下拉框 |
|
||||
| 侧栏里看不到“GPT-SoVITS 设置”,工具栏上没有“导入角色” | Anomalous_TTS 版本太旧(没有接口版本 3)。更新 Anomalous_TTS 后重启 ComfyUI |
|
||||
| 同名文件导入时报“有重名的文件” | Anomalous_TTS 早于接口版本 9。更新后重启 ComfyUI |
|
||||
|
||||
@@ -59,15 +59,4 @@ const detail = await engines.fetchGptSovitsCharacter('阿罗娜/日配');
|
||||
assert.deepEqual(detail.audio, ['ref/a.wav', 'ref/a.开心.wav']);
|
||||
assert.equal(calls.at(-1), `/anomalous_tts/characters?name=${encodeURIComponent('阿罗娜/日配')}`);
|
||||
|
||||
// Saving keeps fields Anomalous does not know.
|
||||
const merged = engines.mergeGptSovitsSettings({ format: 1, keep: 1, reference: { audio: 'old.wav', language: 'ja' } }, {
|
||||
main: { audio: 'ref/a.wav', text: '' },
|
||||
emotions: [{ name: '开心', audio: 'ref/a.开心.wav', text: 'x' }, { name: 'main', audio: 'y.wav' }],
|
||||
});
|
||||
assert.deepEqual(merged, {
|
||||
format: 1, keep: 1,
|
||||
reference: { audio: 'ref/a.wav', language: 'ja' },
|
||||
emotions: { 开心: { audio: 'ref/a.开心.wav', text: 'x' } },
|
||||
});
|
||||
|
||||
console.log('audio_engines: ok');
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// The reference audio editor's rules (web/modules/tts_voice_refs.js): voices with a main and
|
||||
// auxiliary references, fixed lines, the settings written back, and the list's order.
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
clearMain, fitsMain, listClips, nameProblem, readVoices, roleOf, setMain, toggleAux, usesOf, voiceCount, voicesProblem, writeSettings,
|
||||
} from '../web/modules/tts_voice_refs.js';
|
||||
|
||||
const settings = {
|
||||
format: 1,
|
||||
keep: { me: true },
|
||||
reference: { language: 'ja', aux: ['b.wav', 'b.wav', 7] }, // the main reference is automatic
|
||||
emotions: {
|
||||
开心: { audio: 'h.wav', text: '自分の台詞', aux: ['h.wav', 'b.wav'] },
|
||||
main: { audio: 'x.wav' },
|
||||
},
|
||||
lines: { 'c.wav': '直した', 'd.wav': ' ' },
|
||||
};
|
||||
const detail = {
|
||||
reference: { audio: 'a.wav', source: 'auto' },
|
||||
emotions: { 开心: { audio: 'h.wav', source: 'settings' }, 生气: { audio: 'a.生气.wav', source: 'filename' } },
|
||||
};
|
||||
|
||||
// Reading: the main voice, the saved emotions, then the ones found from file names.
|
||||
const { voices, fixes } = readVoices(settings, detail, { lineFixes: true });
|
||||
assert.deepEqual(voices.map(voice => [voice.name, voice.main, voice.aux, voice.filename]), [
|
||||
['main', null, ['b.wav'], false],
|
||||
['开心', 'h.wav', ['b.wav'], false],
|
||||
['生气', 'a.生气.wav', [], true],
|
||||
]);
|
||||
assert.deepEqual([...fixes], [['c.wav', '直した'], ['h.wav', '自分の台詞']], "a voice's own text becomes its clip's line");
|
||||
assert.equal('text' in voices[1].kept, false);
|
||||
// A voice's text that is the clip's line anyway is not a fix.
|
||||
assert.equal(readVoices(settings, detail, { lineFixes: true, found: new Map([['h.wav', '自分の台詞']]) }).fixes.has('h.wav'), false);
|
||||
// An older node: the voice keeps its own text, no lines are fixed here.
|
||||
const old = readVoices(settings, detail);
|
||||
assert.equal(old.fixes.size, 0);
|
||||
assert.equal(old.voices[1].kept.text, '自分の台詞');
|
||||
|
||||
// Main and auxiliary references for one voice.
|
||||
const [main, happy, angry] = voices;
|
||||
assert.equal(roleOf(happy, 'h.wav'), 'main');
|
||||
assert.equal(roleOf(happy, 'b.wav'), 'aux');
|
||||
assert.equal(roleOf(happy, 'z.wav'), null);
|
||||
toggleAux(happy, 'h.wav');
|
||||
assert.deepEqual(happy.aux, ['b.wav'], 'the main reference is not also an auxiliary one');
|
||||
setMain(happy, 'b.wav');
|
||||
assert.deepEqual([happy.main, happy.aux], ['b.wav', []]);
|
||||
toggleAux(happy, 'h.wav');
|
||||
toggleAux(main, 'c.wav');
|
||||
assert.deepEqual(usesOf(voices, 'b.wav', happy), [{ name: 'main', role: 'aux' }]);
|
||||
assert.deepEqual(usesOf(voices, 'b.wav'), [{ name: 'main', role: 'aux' }, { name: '开心', role: 'main' }]);
|
||||
setMain(main, 'a.wav');
|
||||
clearMain(main);
|
||||
assert.equal(main.main, null);
|
||||
clearMain(happy);
|
||||
assert.equal(happy.main, 'b.wav', 'only the main voice can go back to the automatic pick');
|
||||
|
||||
// Lengths and names.
|
||||
assert.equal(fitsMain(0), true);
|
||||
assert.equal(fitsMain(5), true);
|
||||
assert.equal(fitsMain(12.3), false);
|
||||
assert.equal(fitsMain(1), false);
|
||||
assert.equal(nameProblem(' ', voices), 'ttsRefsNameMissing');
|
||||
assert.equal(nameProblem('main', voices), 'ttsEditorNameInvalid');
|
||||
assert.equal(nameProblem('a{b}', voices), 'ttsEditorNameInvalid');
|
||||
assert.equal(nameProblem('开心', voices), 'ttsEditorNameDuplicate');
|
||||
assert.equal(nameProblem('开心', voices, happy), '');
|
||||
assert.equal(nameProblem('害羞', voices), '');
|
||||
|
||||
// What keeps them from being saved.
|
||||
const files = ['a.wav', 'b.wav', 'c.wav', 'h.wav', 'a.生气.wav'];
|
||||
assert.equal(voicesProblem(voices, files), null);
|
||||
const shy = { key: 'e:害羞', name: '害羞', main: null, aux: [], kept: {}, filename: false, touched: true };
|
||||
assert.deepEqual(voicesProblem([...voices, shy], files), { key: 'ttsRefsNoMain', values: { name: '害羞' } });
|
||||
assert.deepEqual(voicesProblem(voices, ['a.wav']), { key: 'ttsEditorUnknownAudio', values: { file: 'b.wav' } });
|
||||
|
||||
// Writing back: unknown fields stay; an emotion from a file name only once changed; fixed lines.
|
||||
fixes.set('h.wav', ' 変えた ');
|
||||
const next = writeSettings(settings, voices, fixes, { aux: true, lines: true });
|
||||
assert.deepEqual(next, {
|
||||
format: 1,
|
||||
keep: { me: true },
|
||||
reference: { language: 'ja', aux: ['b.wav', 'c.wav'] },
|
||||
emotions: { 开心: { audio: 'b.wav', aux: ['h.wav'] } },
|
||||
lines: { 'c.wav': '直した', 'h.wav': '変えた' },
|
||||
});
|
||||
toggleAux(angry, 'c.wav');
|
||||
assert.deepEqual(writeSettings(settings, voices, fixes, { aux: true, lines: true }).emotions['生气'], { audio: 'a.生气.wav', aux: ['c.wav'] });
|
||||
// An older node: auxiliary references and lines stay as they were.
|
||||
const older = writeSettings(settings, voices, new Map(), {});
|
||||
assert.deepEqual(older.reference.aux, settings.reference.aux);
|
||||
assert.deepEqual(older.emotions['开心'].aux, ['h.wav', 'b.wav']);
|
||||
assert.deepEqual(older.lines, settings.lines);
|
||||
// Nothing left: the entries go.
|
||||
const bare = writeSettings({ format: 1, reference: { audio: 'a.wav' }, emotions: {}, lines: { 'a.wav': 'x' } },
|
||||
[{ key: 'main', name: 'main', main: null, aux: [], kept: {}, filename: false, touched: true }], new Map(), { aux: true, lines: true });
|
||||
assert.deepEqual(bare, { format: 1 });
|
||||
|
||||
// The list: this voice's main, then its auxiliary ones, then the rest in folder order; filters and search.
|
||||
const lineOf = path => ({ 'a.wav': 'おはよう', 'c.wav': '直した' })[path] || '';
|
||||
const order = listClips(files, { voice: happy, voices, lineOf });
|
||||
assert.deepEqual(order, ['b.wav', 'h.wav', 'a.wav', 'c.wav', 'a.生气.wav']);
|
||||
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, filter: 'here' }), ['b.wav', 'h.wav']);
|
||||
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, filter: 'noline' }), ['b.wav', 'h.wav', 'a.生气.wav']);
|
||||
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, query: 'おはよ' }), ['a.wav']);
|
||||
assert.deepEqual(listClips(files, { voice: happy, voices, lineOf, query: '生气' }), ['a.生气.wav']);
|
||||
|
||||
// The counts on the voice tabs.
|
||||
assert.deepEqual(voiceCount(main), { key: 'ttsRefsCountAutoAux', values: { aux: 2 } });
|
||||
assert.deepEqual(voiceCount(happy), { key: 'ttsRefsCountAux', values: { aux: 1 } });
|
||||
assert.deepEqual(voiceCount({ main: 'x', aux: [] }), { key: 'ttsRefsCountMain', values: { aux: 0 } });
|
||||
|
||||
console.log('tts_voice_refs: ok');
|
||||
@@ -95,9 +95,16 @@ async function getJson(url, signal) {
|
||||
* Each clip's line as the node finds it (a `Map` path -> line; clips without one are left
|
||||
* out), or null when the node is older than interface 14 and cannot tell.
|
||||
*/
|
||||
export async function fetchReferenceLines(name, signal) {
|
||||
/**
|
||||
* Each clip's line as the node finds it and its length in seconds (`seconds`: interface 18,
|
||||
* empty before): `{ lines: Map, seconds: Map }`, or null when the node cannot tell.
|
||||
*/
|
||||
export async function fetchReferenceClips(name, signal) {
|
||||
const { ok, data } = await getJson(`/anomalous_tts/reference_lines?character=${encodeURIComponent(name)}`, signal);
|
||||
return ok && data?.lines && typeof data.lines === 'object' ? new Map(Object.entries(data.lines)) : null;
|
||||
if (!ok || !data?.lines || typeof data.lines !== 'object') return null;
|
||||
const seconds = data.seconds && typeof data.seconds === 'object'
|
||||
? Object.entries(data.seconds).filter(([, value]) => Number.isFinite(value)) : [];
|
||||
return { lines: new Map(Object.entries(data.lines)), seconds: new Map(seconds) };
|
||||
}
|
||||
|
||||
/** Installed = ComfyUI knows the node class (`/object_info/<class>` is `{}` otherwise). */
|
||||
@@ -189,9 +196,14 @@ export function isTtsInstalled() {
|
||||
* summary list leaves out because a folder can hold thousands of clips.
|
||||
*/
|
||||
export async function fetchGptSovitsCharacter(name, signal) {
|
||||
return (await fetchGptSovitsDetail(name, signal)).character;
|
||||
}
|
||||
|
||||
/** One character's detail and the node's interface version: `{ character, format }`. */
|
||||
export async function fetchGptSovitsDetail(name, signal) {
|
||||
const { ok, status, data } = await getJson(`/anomalous_tts/characters?name=${encodeURIComponent(name)}`, signal);
|
||||
if (!ok || !data?.character) throw new Error(status === 404 ? `404 ${name}` : `HTTP ${status}`);
|
||||
return data.character;
|
||||
return { character: data.character, format: Number(data.format) || 0 };
|
||||
}
|
||||
|
||||
// ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ----------
|
||||
@@ -215,28 +227,3 @@ export async function saveGptSovitsSettings(name, settings) {
|
||||
return data.character ? gptSovitsGroups({ characters: [data.character] })[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the new settings for an emotion editor result without dropping fields
|
||||
* Anomalous does not know. `main` = { audio, text } | null; `emotions` = [{ name, audio, text }].
|
||||
*/
|
||||
export function mergeGptSovitsSettings(previous, { main, emotions }) {
|
||||
const next = { ...(previous || {}), format: 1 };
|
||||
if (main?.audio) {
|
||||
const kept = previous?.reference && typeof previous.reference === 'object' ? previous.reference : {};
|
||||
next.reference = { ...kept, audio: main.audio };
|
||||
if (main.text) next.reference.text = main.text; else delete next.reference.text;
|
||||
} else {
|
||||
delete next.reference;
|
||||
}
|
||||
const oldEmotions = previous?.emotions && typeof previous.emotions === 'object' ? previous.emotions : {};
|
||||
const out = {};
|
||||
for (const row of emotions || []) {
|
||||
const name = String(row?.name || '').trim();
|
||||
if (!name || name === 'main' || !row.audio) continue;
|
||||
const kept = oldEmotions[name] && typeof oldEmotions[name] === 'object' ? oldEmotions[name] : {};
|
||||
out[name] = { ...kept, audio: row.audio };
|
||||
if (row.text) out[name].text = row.text; else delete out[name].text;
|
||||
}
|
||||
if (Object.keys(out).length) next.emotions = out; else delete next.emotions;
|
||||
return next;
|
||||
}
|
||||
|
||||
+90
-42
@@ -2109,36 +2109,60 @@ export const i18n = {
|
||||
ttsPronounceAdd: '+ 添加一条',
|
||||
ttsPronounceFromMissing: '有一条只写了读法({to}),左边要写剧本里的字',
|
||||
ttsPronounceDuplicate: '“{from}”写了两遍,请合并成一条',
|
||||
ttsEditorOpen: "编辑情绪",
|
||||
ttsEditorTitle: "编辑情绪 · {name}",
|
||||
ttsEditorSubtitle: "为主声音和每种情绪各挑一条参考音频(3~10 秒、有台词最好)。保存后写进角色文件夹里的 anomalous_tts.json。",
|
||||
ttsEditorAudioPlaceholder: "输入或选择音频文件",
|
||||
ttsEditorTextPlaceholder: "参考台词(留空自动查找)",
|
||||
ttsEditorMainSection: "主声音",
|
||||
ttsEditorEmotionSection: "情绪",
|
||||
ttsEditorMainAuto: "不设置时,节点会自动使用:{file}",
|
||||
ttsEditorMainHint: "留空:由节点自动挑一条 3~10 秒的音频",
|
||||
ttsEditorEmotionPlaceholder: "情绪名,例如 开心",
|
||||
ttsEditorOpen: "参考音频与情绪",
|
||||
ttsRefsTitle: "参考音频 · {name}",
|
||||
ttsRefsSubtitle: "上面选一个声音,下面给它挑参考音频:主参考决定语气和念法,副参考用来融合音色。保存后写进角色文件夹里的 anomalous_tts.json。",
|
||||
ttsRefsMainVoice: "主声音",
|
||||
ttsRefsCountMain: "1 主",
|
||||
ttsRefsCountAux: "1 主 {aux} 副",
|
||||
ttsRefsCountAuto: "自动",
|
||||
ttsRefsCountAutoAux: "自动 + {aux} 副",
|
||||
ttsRefsCountNone: "还没选主参考",
|
||||
ttsRefsNewEmotion: "新情绪",
|
||||
ttsRefsNewEmotionPrompt: "新情绪的名字,例如 开心(剧本里标上这个情绪的句子就用它)",
|
||||
ttsRefsNameMissing: "情绪名不能为空",
|
||||
ttsRefsRename: "改名",
|
||||
ttsRefsRenamePrompt: "情绪的新名字(剧本里用旧名字标的句子要跟着改)",
|
||||
ttsRefsDelete: "删除这个情绪",
|
||||
ttsRefsDeleteConfirm: "删除情绪“{name}”?音频文件不会删,只是不再用它。",
|
||||
ttsRefsMainHint: "剧本里没标情绪的句子用主声音。",
|
||||
ttsRefsMainAuto: "主声音的主参考现在是节点自动挑的({file})。在下面点“主参考”就换成你选的。",
|
||||
ttsRefsBackToAuto: "改回自动挑",
|
||||
ttsRefsEmotionHint: "剧本里标了“{name}”的句子用这个声音。",
|
||||
ttsRefsFromFilename: "这个情绪来自文件名(原名.情绪.wav):可以换主参考、加副参考;要改名或删掉,改文件名。",
|
||||
ttsRefsSearch: "搜台词或文件名",
|
||||
ttsRefsFilter_all: "全部 {count}",
|
||||
ttsRefsFilter_here: "这个声音用的 {count}",
|
||||
ttsRefsFilter_used: "已用上 {count}",
|
||||
ttsRefsFilter_noline: "没台词 {count}",
|
||||
ttsRefsNoLine: "没找到台词,点这里填",
|
||||
ttsRefsLineBack: "保存后换回文件里的台词",
|
||||
ttsRefsNeedsUpdate: "副参考和改台词需要 Anomalous_TTS 1.1.0(接口 18),先在 GPT-SoVITS 设置里更新。",
|
||||
ttsRefsSeconds: "{seconds} 秒",
|
||||
ttsRefsTooLongShort: "主参考要 3~10 秒;这条可以当副参考。",
|
||||
ttsRefsUse_main: "{name}·主",
|
||||
ttsRefsUse_aux: "{name}·副",
|
||||
ttsRefsUse_auto: "节点自动挑的",
|
||||
ttsRefsMain: "主参考",
|
||||
ttsRefsAux: "副参考",
|
||||
ttsRefsMainHintRow: "设为这个声音的主参考(决定语气和念法,只有一条)",
|
||||
ttsRefsMainOffHint: "再点一下:改回让节点自动挑",
|
||||
ttsRefsAuxHintRow: "加为副参考:音色和主参考取平均,可以加好几条,不需要台词",
|
||||
ttsRefsAuxVersion: "这个角色是 {version} 模型,只有 v1 / v2 / v2Pro 用得上副参考。",
|
||||
ttsRefsNote: "副参考建议用同一个人、音质干净的 2~4 条。",
|
||||
ttsRefsNothing: "没有符合的音频。",
|
||||
ttsRefsShowMore: "再显示一些(还有 {count} 条)",
|
||||
ttsRefsNoMain: "情绪“{name}”还没选主参考。",
|
||||
ttsRefsCloseUnsaved: "有改动还没保存,确定关闭吗?",
|
||||
ttsEditorRemove: "删除这一行",
|
||||
ttsEditorAdd: "+ 添加情绪",
|
||||
ttsEditorFilenameEmotions: "另外,文件名里带情绪的音频会被自动识别:{emotions}",
|
||||
ttsEditorSave: "保存",
|
||||
ttsEditorSaveFailed: "保存失败:{error}",
|
||||
ttsEditorLoadingFiles: "正在读取这个角色的音频列表…",
|
||||
ttsEditorFileCount: "这个角色有 {count} 条音频。",
|
||||
ttsSearchPlaceholder: "搜台词或文件名,找一条参考音频",
|
||||
ttsSearchNamesOnly: "搜文件名(Anomalous_TTS 更新后还能搜台词)",
|
||||
ttsSearchNoLine: "(没有台词)",
|
||||
ttsSearchUseMain: "设为主声音",
|
||||
ttsSearchAddEmotion: "加为情绪",
|
||||
ttsSearchNone: "没有找到。",
|
||||
ttsSearchMore: "还有 {count} 条,多打几个字缩小范围。",
|
||||
ttsEditorLoadFailed: "读取音频列表失败:{error}",
|
||||
ttsEditorUnknownAudio: "{file} 不在这个角色的文件夹里",
|
||||
ttsEditorNameMissing: "{file} 还没填情绪名",
|
||||
ttsEditorNameInvalid: "情绪名“{name}”不能用:不能叫 main,也不能带 {} [] 这些符号",
|
||||
ttsEditorNameDuplicate: "情绪名“{name}”重复了",
|
||||
ttsEditorAudioMissing: "情绪“{name}”还没选音频",
|
||||
ttsCharacterError: "这个角色读取失败:{error}",
|
||||
ttsMainMissing: "没有找到合适的主声音(3~10 秒的参考音频)。点“编辑情绪”选一条。",
|
||||
ttsEmptyTitle: "还没有角色",
|
||||
@@ -4777,36 +4801,60 @@ export const i18n = {
|
||||
ttsPronounceAdd: '+ Add a line',
|
||||
ttsPronounceFromMissing: 'A line has only a reading ({to}); write the script text on the left',
|
||||
ttsPronounceDuplicate: '"{from}" is listed twice; keep one line',
|
||||
ttsEditorOpen: "Edit emotions",
|
||||
ttsEditorTitle: "Edit emotions · {name}",
|
||||
ttsEditorSubtitle: "Pick one reference clip (3–10 s, with its line) for the main voice and each emotion. Saved to anomalous_tts.json in the character folder.",
|
||||
ttsEditorAudioPlaceholder: "Type or pick an audio file",
|
||||
ttsEditorTextPlaceholder: "Reference line (blank: found automatically)",
|
||||
ttsEditorMainSection: "Main voice",
|
||||
ttsEditorEmotionSection: "Emotions",
|
||||
ttsEditorMainAuto: "When unset, the node uses: {file}",
|
||||
ttsEditorMainHint: "Blank: the node picks a 3–10 s clip itself",
|
||||
ttsEditorEmotionPlaceholder: "Emotion name, e.g. happy",
|
||||
ttsEditorOpen: "Reference audio and emotions",
|
||||
ttsRefsTitle: "Reference audio · {name}",
|
||||
ttsRefsSubtitle: "Pick a voice on top, then its reference clips below: the main reference sets the tone and way of speaking, auxiliary ones blend in their timbre. Saved to anomalous_tts.json in the character folder.",
|
||||
ttsRefsMainVoice: "Main voice",
|
||||
ttsRefsCountMain: "1 main",
|
||||
ttsRefsCountAux: "1 main, {aux} aux",
|
||||
ttsRefsCountAuto: "automatic",
|
||||
ttsRefsCountAutoAux: "automatic + {aux} aux",
|
||||
ttsRefsCountNone: "no main reference yet",
|
||||
ttsRefsNewEmotion: "New emotion",
|
||||
ttsRefsNewEmotionPrompt: "The new emotion's name, e.g. happy (lines marked with it use this voice)",
|
||||
ttsRefsNameMissing: "An emotion needs a name",
|
||||
ttsRefsRename: "Rename",
|
||||
ttsRefsRenamePrompt: "The emotion's new name (lines marked with the old one need it too)",
|
||||
ttsRefsDelete: "Delete this emotion",
|
||||
ttsRefsDeleteConfirm: "Delete the emotion “{name}”? The audio files stay; it is just not used any more.",
|
||||
ttsRefsMainHint: "Lines without an emotion use the main voice.",
|
||||
ttsRefsMainAuto: "The node picks the main voice's reference itself ({file}). Click Main below to use the one you pick.",
|
||||
ttsRefsBackToAuto: "Let the node pick again",
|
||||
ttsRefsEmotionHint: "Lines marked “{name}” use this voice.",
|
||||
ttsRefsFromFilename: "This emotion comes from a file name (name.emotion.wav): its main reference can change and aux ones can be added; to rename or delete it, rename the file.",
|
||||
ttsRefsSearch: "Search lines or file names",
|
||||
ttsRefsFilter_all: "All {count}",
|
||||
ttsRefsFilter_here: "This voice {count}",
|
||||
ttsRefsFilter_used: "In use {count}",
|
||||
ttsRefsFilter_noline: "No line {count}",
|
||||
ttsRefsNoLine: "No line found: click to type it",
|
||||
ttsRefsLineBack: "Back to the file's line once saved",
|
||||
ttsRefsNeedsUpdate: "Auxiliary references and fixed lines need Anomalous_TTS 1.1.0 (interface 18): update it in GPT-SoVITS settings.",
|
||||
ttsRefsSeconds: "{seconds} s",
|
||||
ttsRefsTooLongShort: "A main reference needs 3–10 s; this one can be an auxiliary one.",
|
||||
ttsRefsUse_main: "{name} · main",
|
||||
ttsRefsUse_aux: "{name} · aux",
|
||||
ttsRefsUse_auto: "picked by the node",
|
||||
ttsRefsMain: "Main",
|
||||
ttsRefsAux: "Aux",
|
||||
ttsRefsMainHintRow: "This voice's main reference (sets the tone and way of speaking; just one)",
|
||||
ttsRefsMainOffHint: "Click again to let the node pick",
|
||||
ttsRefsAuxHintRow: "An auxiliary reference: its timbre is averaged with the main one; any number, no line needed",
|
||||
ttsRefsAuxVersion: "This character is a {version} model: only v1 / v2 / v2Pro use auxiliary references.",
|
||||
ttsRefsNote: "Auxiliary references work best from the same speaker, 2–4 clean clips.",
|
||||
ttsRefsNothing: "No clip matches.",
|
||||
ttsRefsShowMore: "Show more ({count} left)",
|
||||
ttsRefsNoMain: "The emotion “{name}” has no main reference yet.",
|
||||
ttsRefsCloseUnsaved: "There are unsaved changes. Close anyway?",
|
||||
ttsEditorRemove: "Remove this row",
|
||||
ttsEditorAdd: "+ Add emotion",
|
||||
ttsEditorFilenameEmotions: "Clips with an emotion in their file name are also found automatically: {emotions}",
|
||||
ttsEditorSave: "Save",
|
||||
ttsEditorSaveFailed: "Could not save: {error}",
|
||||
ttsEditorLoadingFiles: "Reading this character's audio files…",
|
||||
ttsEditorFileCount: "{count} audio files in this character.",
|
||||
ttsSearchPlaceholder: "Search lines or file names for a reference clip",
|
||||
ttsSearchNamesOnly: "Search file names (update Anomalous_TTS to search lines too)",
|
||||
ttsSearchNoLine: "(no line)",
|
||||
ttsSearchUseMain: "Use as main voice",
|
||||
ttsSearchAddEmotion: "Add as emotion",
|
||||
ttsSearchNone: "Nothing found.",
|
||||
ttsSearchMore: "{count} more: type a little more to narrow it down.",
|
||||
ttsEditorLoadFailed: "Could not read the audio files: {error}",
|
||||
ttsEditorUnknownAudio: "{file} is not in this character's folder",
|
||||
ttsEditorNameMissing: "{file} has no emotion name yet",
|
||||
ttsEditorNameInvalid: "The emotion name \"{name}\" cannot be used: not main, and no {} [] characters",
|
||||
ttsEditorNameDuplicate: "The emotion name \"{name}\" is used twice",
|
||||
ttsEditorAudioMissing: "Emotion \"{name}\" has no audio yet",
|
||||
ttsCharacterError: "This character could not be read: {error}",
|
||||
ttsMainMissing: "No suitable main voice (a 3–10 s reference clip) was found. Use \"Edit emotions\" to pick one.",
|
||||
ttsEmptyTitle: "No characters yet",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The rules of the reference audio editor (ui_audio_tts_editor.js), without DOM: a character's
|
||||
* voices (the main voice and each emotion), each with one main reference (its tone and way of
|
||||
* speaking) and auxiliary references (Anomalous_TTS interface 18: their timbre is averaged in,
|
||||
* SoVITS v1 / v2 / v2Pro only), the reference lines fixed here, and how all of it goes back into
|
||||
* the character's anomalous_tts.json, keeping every field the editor does not know.
|
||||
*
|
||||
* A voice: `{ key, name, main, aux, kept, filename, touched }`. `name` is 'main' for the main
|
||||
* voice; `main` the clip (null on the main voice: the node picks one itself); `kept` the rest
|
||||
* of its settings entry; `filename` an emotion found from a file name (`X.开心.wav`), written
|
||||
* into the settings only once it is changed (`touched`).
|
||||
*/
|
||||
|
||||
export const AUX_FORMAT = 18;
|
||||
export const AUX_VERSIONS = new Set(['v1', 'v2', 'v2Pro', 'v2ProPlus']);
|
||||
export const REF_MIN_SEC = 3;
|
||||
export const REF_MAX_SEC = 10;
|
||||
export const LINE_LIMIT = 500;
|
||||
export const MAIN = 'main';
|
||||
const FORBIDDEN_IN_NAME = /[{}[\]]/;
|
||||
|
||||
const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
function own(spec) {
|
||||
const { audio, aux, ...rest } = isObject(spec) ? spec : {};
|
||||
return rest;
|
||||
}
|
||||
|
||||
function auxList(spec) {
|
||||
return Array.isArray(spec?.aux) ? [...new Set(spec.aux.filter(path => typeof path === 'string' && path))] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The voices and the fixed lines from the saved settings and the node's view of them (`detail`:
|
||||
* the character's `reference` and `emotions` as resolved). `lineFixes` (interface 18): a line a
|
||||
* voice kept in its own `text` becomes that clip's fixed line, so every clip has one line,
|
||||
* unless it is the line the clip's files give anyway (`found`).
|
||||
*/
|
||||
export function readVoices(settings, detail, { lineFixes = false, found = new Map() } = {}) {
|
||||
const fixes = new Map();
|
||||
if (lineFixes && isObject(settings?.lines)) {
|
||||
for (const [path, text] of Object.entries(settings.lines)) if (typeof text === 'string' && text.trim()) fixes.set(path, text);
|
||||
}
|
||||
const voice = (key, name, spec, extra = {}) => {
|
||||
const kept = own(spec);
|
||||
const main = typeof spec?.audio === 'string' && spec.audio ? spec.audio : null;
|
||||
if (lineFixes && typeof kept.text === 'string') {
|
||||
const text = kept.text.trim();
|
||||
if (text && main && !fixes.has(main) && found.get(main)?.trim() !== text) fixes.set(main, text);
|
||||
delete kept.text;
|
||||
}
|
||||
return { key, name, main, aux: auxList(spec).filter(path => path !== main), kept, filename: false, touched: false, ...extra };
|
||||
};
|
||||
const voices = [voice('main', MAIN, settings?.reference)];
|
||||
const saved = isObject(settings?.emotions) ? settings.emotions : {};
|
||||
for (const [name, spec] of Object.entries(saved)) {
|
||||
if (name.trim() && name.trim() !== MAIN) voices.push(voice(`e:${name}`, name, spec));
|
||||
}
|
||||
const named = isObject(detail?.emotions) ? detail.emotions : {};
|
||||
for (const name of Object.keys(named).sort()) {
|
||||
const ref = named[name];
|
||||
if (ref?.source !== 'filename' || voices.some(item => item.name === name)) continue;
|
||||
voices.push(voice(`e:${name}`, name, { audio: ref.audio }, { filename: true }));
|
||||
}
|
||||
return { voices, fixes };
|
||||
}
|
||||
|
||||
/** What `audio` is to `voice`: 'main', 'aux' or null. */
|
||||
export function roleOf(voice, audio) {
|
||||
if (!voice) return null;
|
||||
if (voice.main === audio) return 'main';
|
||||
return voice.aux.includes(audio) ? 'aux' : null;
|
||||
}
|
||||
|
||||
/** `audio` becomes the voice's main reference (and no longer one of its auxiliary ones). */
|
||||
export function setMain(voice, audio) {
|
||||
voice.main = audio;
|
||||
voice.aux = voice.aux.filter(path => path !== audio);
|
||||
voice.touched = true;
|
||||
}
|
||||
|
||||
/** The main voice back to the node's own pick. */
|
||||
export function clearMain(voice) {
|
||||
if (voice.name !== MAIN) return;
|
||||
voice.main = null;
|
||||
voice.touched = true;
|
||||
}
|
||||
|
||||
export function toggleAux(voice, audio) {
|
||||
if (voice.main === audio) return;
|
||||
voice.aux = voice.aux.includes(audio) ? voice.aux.filter(path => path !== audio) : [...voice.aux, audio];
|
||||
voice.touched = true;
|
||||
}
|
||||
|
||||
/** Where else `audio` is used: `[{ name, role }]` for the voices other than `except`. */
|
||||
export function usesOf(voices, audio, except = null) {
|
||||
const out = [];
|
||||
for (const voice of voices) {
|
||||
if (voice === except) continue;
|
||||
const role = roleOf(voice, audio);
|
||||
if (role) out.push({ name: voice.name, role });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A clip fits as a main reference unless its known length is outside 3~10 s. */
|
||||
export function fitsMain(seconds) {
|
||||
return !(seconds > 0) || (seconds >= REF_MIN_SEC && seconds <= REF_MAX_SEC);
|
||||
}
|
||||
|
||||
/** An emotion name that can be used: '' or the locale key of what is wrong with it. */
|
||||
export function nameProblem(name, voices, self = null) {
|
||||
const clean = String(name || '').trim();
|
||||
if (!clean) return 'ttsRefsNameMissing';
|
||||
if (clean === MAIN || FORBIDDEN_IN_NAME.test(clean)) return 'ttsEditorNameInvalid';
|
||||
if (voices.some(voice => voice !== self && voice.name === clean)) return 'ttsEditorNameDuplicate';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** The first thing that keeps the voices from being saved, as `{ key, values }`, or null. */
|
||||
export function voicesProblem(voices, audioList) {
|
||||
const files = new Set(audioList);
|
||||
const seen = new Set();
|
||||
for (const voice of voices) {
|
||||
if (voice.name !== MAIN) {
|
||||
if (seen.has(voice.name)) return { key: 'ttsEditorNameDuplicate', values: { name: voice.name } };
|
||||
seen.add(voice.name);
|
||||
if (!voice.main) return { key: 'ttsRefsNoMain', values: { name: voice.name } };
|
||||
}
|
||||
for (const path of [voice.main, ...voice.aux]) {
|
||||
if (path && !files.has(path)) return { key: 'ttsEditorUnknownAudio', values: { file: path } };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings to save: the previous ones with the voices and the fixed lines written in.
|
||||
* `aux` / `lines`: the node reads them (interface 18); without, they are left as they were.
|
||||
*/
|
||||
export function writeSettings(previous, voices, fixes, { aux = false, lines = false } = {}) {
|
||||
const next = { ...(isObject(previous) ? previous : {}), format: 1 };
|
||||
const entry = (voice) => {
|
||||
const out = { ...voice.kept };
|
||||
if (voice.main) out.audio = voice.main;
|
||||
if (aux && voice.aux.length) out.aux = [...voice.aux];
|
||||
return out;
|
||||
};
|
||||
const main = voices.find(voice => voice.name === MAIN);
|
||||
const reference = main ? entry(main) : {};
|
||||
if (!aux && Array.isArray(previous?.reference?.aux)) reference.aux = previous.reference.aux;
|
||||
if (Object.keys(reference).length) next.reference = reference; else delete next.reference;
|
||||
|
||||
const emotions = {};
|
||||
const before = isObject(previous?.emotions) ? previous.emotions : {};
|
||||
for (const voice of voices) {
|
||||
if (voice.name === MAIN || !voice.main || (voice.filename && !voice.touched)) continue;
|
||||
emotions[voice.name] = entry(voice);
|
||||
if (!aux && Array.isArray(before[voice.name]?.aux)) emotions[voice.name].aux = before[voice.name].aux;
|
||||
}
|
||||
if (Object.keys(emotions).length) next.emotions = emotions; else delete next.emotions;
|
||||
|
||||
if (lines) {
|
||||
const table = {};
|
||||
for (const [path, text] of fixes) if (text.trim()) table[path] = text.trim().slice(0, LINE_LIMIT);
|
||||
if (Object.keys(table).length) next.lines = table; else delete next.lines;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clips the list shows, best first for the voice picked: its main reference, its
|
||||
* auxiliary ones, then the others in folder order. `filter`: 'all' | 'here' (this voice) |
|
||||
* 'used' (any voice) | 'noline'; `query` matches the path or the line.
|
||||
*/
|
||||
export function listClips(audioList, { voice, voices, lineOf, filter = 'all', query = '' }) {
|
||||
const words = String(query).trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const matches = path => words.every(word => path.toLowerCase().includes(word) || lineOf(path).toLowerCase().includes(word));
|
||||
const keep = {
|
||||
all: () => true,
|
||||
here: path => Boolean(roleOf(voice, path)),
|
||||
used: path => voices.some(item => roleOf(item, path)),
|
||||
noline: path => !lineOf(path),
|
||||
}[filter] || (() => true);
|
||||
const rank = (path) => {
|
||||
const role = roleOf(voice, path);
|
||||
return role === 'main' ? 0 : role === 'aux' ? 1 : 2;
|
||||
};
|
||||
return audioList.filter(path => keep(path) && matches(path))
|
||||
.map((path, at) => ({ path, at, rank: rank(path) }))
|
||||
.sort((a, b) => a.rank - b.rank || a.at - b.at)
|
||||
.map(item => item.path);
|
||||
}
|
||||
|
||||
/** "1 main, 2 aux" for a voice tab, as `{ key, values }`. */
|
||||
export function voiceCount(voice) {
|
||||
if (!voice.main) return { key: voice.aux.length ? 'ttsRefsCountAutoAux' : 'ttsRefsCountAuto', values: { aux: voice.aux.length } };
|
||||
return { key: voice.aux.length ? 'ttsRefsCountAux' : 'ttsRefsCountMain', values: { aux: voice.aux.length } };
|
||||
}
|
||||
+341
-182
@@ -1,23 +1,32 @@
|
||||
import { t } from './interface_settings.js';
|
||||
import { createViewScope } from './ui_lifecycle.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
import { fetchGptSovitsCharacter, fetchReferenceLines, mergeGptSovitsSettings, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js';
|
||||
import { createClipSearch } from './ui_tts_clip_search.js';
|
||||
import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js';
|
||||
import { fetchGptSovitsDetail, fetchReferenceClips, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js';
|
||||
import {
|
||||
AUX_FORMAT, AUX_VERSIONS, LINE_LIMIT, MAIN, clearMain, fitsMain, listClips, nameProblem, readVoices, roleOf,
|
||||
setMain, toggleAux, usesOf, voiceCount, voicesProblem, writeSettings,
|
||||
} from './tts_voice_refs.js';
|
||||
|
||||
/**
|
||||
* GPT-SoVITS (Anomalous_TTS) emotion editor: pick one reference audio from the
|
||||
* character's folder for the main voice and for each emotion, typed or found by its
|
||||
* line in the clip search (ui_tts_clip_search.js). The node owns the
|
||||
* file; this modal only sends the new settings through its API, keeping every
|
||||
* field it does not edit.
|
||||
* GPT-SoVITS (Anomalous_TTS) reference audio editor, opened from a character card: the voices
|
||||
* on top (the main voice and each emotion, "+ New emotion"), and below every clip of the
|
||||
* character, one row each (listen, file and length, its line, where else it is used), where the
|
||||
* voice picked gets its **main reference** (its tone; one, with a line, 3~10 s) and its
|
||||
* **auxiliary references** (their timbre is averaged in; any number, no line needed). A clip's
|
||||
* line can be fixed right in its row. The rules are in tts_voice_refs.js; the node owns the
|
||||
* file, this window only sends the new settings through its API, keeping every field it does
|
||||
* not edit. Auxiliary references and fixed lines need Anomalous_TTS interface 18 (and
|
||||
* auxiliary references a v1 / v2 / v2Pro model); otherwise those controls are off and say why.
|
||||
*
|
||||
* The studio list carries no file lists (a folder can hold thousands of clips), so
|
||||
* the modal opens at once and fetches this character's files (and their lines) in the
|
||||
* background; saving waits until they are here.
|
||||
* The window opens at once and reads the character's clips, their lines and lengths in the
|
||||
* background (a folder can hold a thousand clips: the list shows the first ones, then more).
|
||||
*/
|
||||
|
||||
const SHOWN_FIRST = 60;
|
||||
const SHOW_MORE = 120;
|
||||
const FILTERS = ['all', 'here', 'used', 'noline'];
|
||||
|
||||
let activeScope = null;
|
||||
const FORBIDDEN_IN_NAME = /[{}[\]]/;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -34,41 +43,10 @@ function button(className, label, onClick, title) {
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** Audio path input with a searchable list of the character's files (folders can hold thousands). */
|
||||
function audioInput(listId, value) {
|
||||
const input = el('input', 'anomalous-tts-audio-input');
|
||||
input.type = 'text';
|
||||
input.setAttribute('list', listId);
|
||||
input.placeholder = t('ttsEditorAudioPlaceholder');
|
||||
input.value = value || '';
|
||||
// The browser lists only the files matching the box's text, so a filled box offered one or
|
||||
// two. Pressing it empties the box to list them all (the file shows greyed meanwhile);
|
||||
// leaving without a pick puts the file back, unless it was deleted with a key.
|
||||
let kept = '';
|
||||
input.addEventListener('mousedown', () => {
|
||||
if (!input.value) return;
|
||||
kept = input.value;
|
||||
input.placeholder = kept;
|
||||
input.value = '';
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') kept = '';
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
if (!input.value) input.value = kept;
|
||||
kept = '';
|
||||
input.placeholder = t('ttsEditorAudioPlaceholder');
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
function textInput(value) {
|
||||
const input = el('input', 'anomalous-tts-text-input');
|
||||
input.type = 'text';
|
||||
input.placeholder = t('ttsEditorTextPlaceholder');
|
||||
input.value = value || '';
|
||||
return input;
|
||||
}
|
||||
const say = ({ key, values }) => t(key, values);
|
||||
const voiceLabel = voice => (voice.name === MAIN ? t('ttsRefsMainVoice') : voice.name);
|
||||
const baseName = path => path.split('/').pop();
|
||||
const folderOf = path => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
|
||||
|
||||
/**
|
||||
* Open the editor for one GPT-SoVITS voice group (audio_engines.gptSovitsGroups shape).
|
||||
@@ -80,21 +58,40 @@ export function openGptSovitsEditor(group, { onSaved } = {}) {
|
||||
activeScope = scope;
|
||||
const raw = group.raw || {};
|
||||
const name = raw.name || group.character;
|
||||
let audioList = [];
|
||||
let settings = raw.settings && typeof raw.settings === 'object' ? raw.settings : {};
|
||||
let loaded = false;
|
||||
|
||||
const state = {
|
||||
loaded: false,
|
||||
format: 0,
|
||||
version: raw.version || null,
|
||||
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : {},
|
||||
audio: [],
|
||||
found: new Map(), // path -> the line the node finds (a fixed one first)
|
||||
seconds: new Map(), // path -> length (interface 18)
|
||||
voices: [],
|
||||
fixes: new Map(), // path -> the line fixed here
|
||||
cleared: new Set(), // fixed lines taken back here (the file's line comes back once saved)
|
||||
voiceKey: 'main',
|
||||
filter: 'all',
|
||||
query: '',
|
||||
shown: SHOWN_FIRST,
|
||||
dirty: false,
|
||||
playing: null, // { path, button }
|
||||
};
|
||||
let player = null;
|
||||
|
||||
const overlay = el('div', 'anomalous-voice-modal-overlay');
|
||||
const modal = el('div', 'anomalous-voice-modal anomalous-tts-editor');
|
||||
const modal = el('div', 'anomalous-voice-modal anomalous-tts-refs');
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
const close = () => scope.dispose();
|
||||
scope.onDispose(() => {
|
||||
player?.pause();
|
||||
overlay.remove();
|
||||
if (activeScope === scope) activeScope = null;
|
||||
});
|
||||
const close = async () => {
|
||||
if (state.dirty && !await anomalousConfirm(t('ttsRefsCloseUnsaved'))) return;
|
||||
scope.dispose();
|
||||
};
|
||||
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
||||
scope.listen(window, 'keydown', (e) => {
|
||||
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
|
||||
@@ -102,178 +99,340 @@ export function openGptSovitsEditor(group, { onSaved } = {}) {
|
||||
close();
|
||||
}, true);
|
||||
|
||||
// A picked file gets its own line: the one it had in this dialog, the line the node uses for
|
||||
// it when it is in use, else the one the node finds for it (blank with a node too old to
|
||||
// tell, which then reads it when it speaks). A blank box shows the line greyed, unsaved.
|
||||
const knownText = new Map([raw.reference, ...Object.values(raw.emotions || {})]
|
||||
.filter(ref => ref?.audio && ref.text).map(ref => [ref.audio, ref.text]));
|
||||
let lines = new Map();
|
||||
const lineOf = path => knownText.get(path) || lines.get(path) || '';
|
||||
const linkText = (audio, text) => {
|
||||
const start = { audio: audio.value.trim(), text: text.value };
|
||||
const showLine = () => { text.placeholder = lineOf(audio.value.trim()) || t('ttsEditorTextPlaceholder'); };
|
||||
showLine();
|
||||
audio.addEventListener('input', () => {
|
||||
const path = audio.value.trim();
|
||||
if (!audioList.includes(path)) return;
|
||||
text.value = path === start.audio ? start.text : lineOf(path);
|
||||
showLine();
|
||||
});
|
||||
const voice = () => state.voices.find(item => item.key === state.voiceKey) || state.voices[0];
|
||||
const lineOf = path => (state.fixes.has(path) ? state.fixes.get(path) : state.cleared.has(path) ? '' : state.found.get(path) || '');
|
||||
const canAux = () => state.format >= AUX_FORMAT && (!state.version || AUX_VERSIONS.has(state.version));
|
||||
const canFix = () => state.format >= AUX_FORMAT;
|
||||
const changed = () => {
|
||||
state.dirty = true;
|
||||
saveBtn.disabled = !state.loaded;
|
||||
};
|
||||
|
||||
const playPath = (path) => {
|
||||
// ---------- listening ----------
|
||||
|
||||
function stopPlaying() {
|
||||
player?.pause();
|
||||
if (!audioList.includes(path)) return;
|
||||
player = null;
|
||||
if (state.playing) state.playing.button.classList.remove('is-playing');
|
||||
state.playing = null;
|
||||
}
|
||||
|
||||
function play(path, btn) {
|
||||
const again = state.playing?.path === path;
|
||||
stopPlaying();
|
||||
if (again) return;
|
||||
player = new Audio(ttsAudioUrl(name, path));
|
||||
player.play().catch(() => {});
|
||||
};
|
||||
const play = (input) => playPath(input.value.trim());
|
||||
state.playing = { path, button: btn };
|
||||
btn.classList.add('is-playing');
|
||||
const done = () => { if (state.playing?.path === path) stopPlaying(); };
|
||||
player.onended = done;
|
||||
player.onerror = done;
|
||||
player.play().catch(done);
|
||||
}
|
||||
|
||||
// ---------- header ----------
|
||||
|
||||
// Header
|
||||
const header = el('div', 'anomalous-voice-modal-header');
|
||||
const heading = el('div', 'anomalous-voice-modal-heading');
|
||||
heading.append(
|
||||
el('div', 'anomalous-voice-modal-title', t('ttsEditorTitle', { name })),
|
||||
el('div', 'anomalous-voice-modal-subtitle', t('ttsEditorSubtitle')),
|
||||
el('div', 'anomalous-voice-modal-title', t('ttsRefsTitle', { name })),
|
||||
el('div', 'anomalous-voice-modal-subtitle', t('ttsRefsSubtitle')),
|
||||
);
|
||||
header.append(heading, button('anomalous-voice-modal-close', '×', close, t('close')));
|
||||
|
||||
const listId = `anomalous-tts-audio-${Math.random().toString(36).slice(2)}`;
|
||||
const datalist = el('datalist');
|
||||
datalist.id = listId;
|
||||
const status = el('div', 'anomalous-tts-hint anomalous-tts-status', t('ttsEditorLoadingFiles'));
|
||||
|
||||
// Main reference
|
||||
const mainRow = el('div', 'anomalous-tts-row is-main');
|
||||
const mainAudio = audioInput(listId, settings.reference?.audio);
|
||||
const mainText = textInput(settings.reference?.text);
|
||||
linkText(mainAudio, mainText);
|
||||
mainRow.append(
|
||||
el('span', 'anomalous-tts-row-name', '{main}'),
|
||||
mainAudio,
|
||||
button('anomalous-audio-play-btn', '▶', () => play(mainAudio), t('audioPlay')),
|
||||
mainText,
|
||||
);
|
||||
const mainHint = el('div', 'anomalous-tts-hint', raw.reference?.audio && !settings.reference?.audio
|
||||
? t('ttsEditorMainAuto', { file: raw.reference.audio })
|
||||
: t('ttsEditorMainHint'));
|
||||
// ---------- the voices ----------
|
||||
|
||||
// Emotion rows
|
||||
const rowsBox = el('div', 'anomalous-tts-rows');
|
||||
const addRow = (row = {}) => {
|
||||
const line = el('div', 'anomalous-tts-row');
|
||||
const nameInput = el('input', 'anomalous-tts-name-input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.placeholder = t('ttsEditorEmotionPlaceholder');
|
||||
nameInput.value = row.name || '';
|
||||
const audio = audioInput(listId, row.audio);
|
||||
const text = textInput(row.text);
|
||||
linkText(audio, text);
|
||||
line.append(
|
||||
nameInput,
|
||||
audio,
|
||||
button('anomalous-audio-play-btn', '▶', () => play(audio), t('audioPlay')),
|
||||
text,
|
||||
button('anomalous-tts-remove', '×', () => line.remove(), t('ttsEditorRemove')),
|
||||
);
|
||||
rowsBox.appendChild(line);
|
||||
return nameInput;
|
||||
};
|
||||
for (const [emotion, ref] of Object.entries(settings.emotions || {})) {
|
||||
addRow({ name: emotion, audio: ref?.audio, text: ref?.text });
|
||||
const voicesBar = el('div', 'anomalous-tts-refs-voices');
|
||||
const voiceTools = el('div', 'anomalous-tts-refs-voice-tools');
|
||||
|
||||
function renderVoices() {
|
||||
const tabs = state.voices.map((item) => {
|
||||
const tab = button(`anomalous-tts-refs-voice${item.key === state.voiceKey ? ' is-current' : ''}`, '', () => {
|
||||
state.voiceKey = item.key;
|
||||
state.shown = SHOWN_FIRST;
|
||||
renderAll();
|
||||
});
|
||||
tab.append(el('span', 'anomalous-tts-refs-voice-name', voiceLabel(item)), el('span', 'anomalous-tts-refs-voice-count', say(item.name !== MAIN && !item.main ? { key: 'ttsRefsCountNone' } : voiceCount(item))));
|
||||
if (item.name !== MAIN && !item.main) tab.classList.add('is-missing');
|
||||
return tab;
|
||||
});
|
||||
tabs.push(button('anomalous-tts-refs-voice is-new', `+ ${t('ttsRefsNewEmotion')}`, addEmotion));
|
||||
voicesBar.replaceChildren(...tabs);
|
||||
|
||||
const current = voice();
|
||||
const tools = [];
|
||||
if (current?.name === MAIN) {
|
||||
tools.push(el('span', '', current.main ? t('ttsRefsMainHint') : t('ttsRefsMainAuto', { file: raw.reference?.audio ? baseName(raw.reference.audio) : '—' })));
|
||||
if (current.main) tools.push(button('anomalous-sd-link-btn', t('ttsRefsBackToAuto'), () => { clearMain(current); changed(); renderAll(); }));
|
||||
} else if (current) {
|
||||
tools.push(el('span', '', current.filename ? t('ttsRefsFromFilename') : t('ttsRefsEmotionHint', { name: current.name })));
|
||||
if (!current.filename) {
|
||||
tools.push(
|
||||
button('anomalous-sd-link-btn', t('ttsRefsRename'), () => renameEmotion(current)),
|
||||
button('anomalous-sd-link-btn is-danger', t('ttsRefsDelete'), () => deleteEmotion(current)),
|
||||
);
|
||||
}
|
||||
}
|
||||
voiceTools.replaceChildren(...tools);
|
||||
}
|
||||
const addBtn = button('anomalous-tts-add', t('ttsEditorAdd'), () => addRow().focus());
|
||||
const search = createClipSearch({
|
||||
play: playPath,
|
||||
onMain: (path) => {
|
||||
mainAudio.value = path;
|
||||
mainAudio.dispatchEvent(new Event('input'));
|
||||
},
|
||||
onEmotion: (path) => addRow({ audio: path, text: lineOf(path) }).focus(),
|
||||
|
||||
async function addEmotion() {
|
||||
const value = await anomalousPrompt(t('ttsRefsNewEmotionPrompt'), '', t('ttsRefsNewEmotion'));
|
||||
if (value === null || value === undefined) return;
|
||||
const problem = nameProblem(value, state.voices);
|
||||
if (problem) return anomalousAlert(t(problem, { name: String(value).trim(), file: '' }));
|
||||
const item = { key: `e:${value.trim()}:${Date.now()}`, name: value.trim(), main: null, aux: [], kept: {}, filename: false, touched: true };
|
||||
state.voices.push(item);
|
||||
state.voiceKey = item.key;
|
||||
state.filter = 'all';
|
||||
changed();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function renameEmotion(item) {
|
||||
const value = await anomalousPrompt(t('ttsRefsRenamePrompt'), item.name, t('ttsRefsRename'));
|
||||
if (value === null || value === undefined || value.trim() === item.name) return;
|
||||
const problem = nameProblem(value, state.voices, item);
|
||||
if (problem) return anomalousAlert(t(problem, { name: String(value).trim(), file: '' }));
|
||||
item.name = value.trim();
|
||||
changed();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function deleteEmotion(item) {
|
||||
if (!await anomalousConfirm(t('ttsRefsDeleteConfirm', { name: item.name }))) return;
|
||||
state.voices = state.voices.filter(other => other !== item);
|
||||
state.voiceKey = 'main';
|
||||
changed();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// ---------- the clips ----------
|
||||
|
||||
const toolbar = el('div', 'anomalous-tts-refs-toolbar');
|
||||
const filters = el('div', 'anomalous-tts-refs-filters');
|
||||
const search = el('input', 'anomalous-tts-refs-search');
|
||||
search.type = 'search';
|
||||
search.placeholder = t('ttsRefsSearch');
|
||||
search.addEventListener('input', () => {
|
||||
state.query = search.value;
|
||||
state.shown = SHOWN_FIRST;
|
||||
renderList();
|
||||
});
|
||||
toolbar.append(filters, search);
|
||||
const list = el('div', 'anomalous-tts-refs-list');
|
||||
const note = el('div', 'anomalous-tts-hint anomalous-tts-refs-note');
|
||||
|
||||
const autoEmotions = Object.entries(raw.emotions || {}).filter(([, ref]) => ref?.source === 'filename').map(([emotion]) => `{${emotion}}`);
|
||||
const autoNote = el('div', 'anomalous-tts-hint', autoEmotions.length ? t('ttsEditorFilenameEmotions', { emotions: autoEmotions.join(' ') }) : '');
|
||||
autoNote.hidden = !autoEmotions.length;
|
||||
function renderFilters() {
|
||||
const current = voice();
|
||||
const count = {
|
||||
all: state.audio.length,
|
||||
here: current ? [current.main, ...current.aux].filter(Boolean).length : 0,
|
||||
used: state.audio.filter(path => state.voices.some(item => roleOf(item, path))).length,
|
||||
noline: state.audio.filter(path => !lineOf(path)).length,
|
||||
};
|
||||
filters.replaceChildren(...FILTERS.map(id => button(
|
||||
`anomalous-tts-refs-filter${state.filter === id ? ' is-on' : ''}`,
|
||||
t(`ttsRefsFilter_${id}`, { count: count[id] }),
|
||||
() => { state.filter = id; state.shown = SHOWN_FIRST; renderList(); renderFilters(); },
|
||||
)));
|
||||
}
|
||||
|
||||
function lineBox(path) {
|
||||
const input = el('input', 'anomalous-tts-refs-line');
|
||||
input.type = 'text';
|
||||
input.maxLength = LINE_LIMIT;
|
||||
input.value = lineOf(path);
|
||||
input.placeholder = state.cleared.has(path) ? t('ttsRefsLineBack') : t('ttsRefsNoLine');
|
||||
input.disabled = !canFix();
|
||||
if (!canFix()) input.title = t('ttsRefsNeedsUpdate');
|
||||
input.classList.toggle('is-fixed', state.fixes.has(path));
|
||||
input.addEventListener('change', () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) {
|
||||
if (state.fixes.delete(path)) state.cleared.add(path);
|
||||
} else if (text !== lineOf(path)) {
|
||||
state.fixes.set(path, text);
|
||||
state.cleared.delete(path);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
changed();
|
||||
renderRow(path);
|
||||
renderFilters();
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
const rows = new Map();
|
||||
|
||||
function rowFor(path) {
|
||||
const current = voice();
|
||||
const role = roleOf(current, path);
|
||||
const seconds = state.seconds.get(path) || 0;
|
||||
const row = el('div', `anomalous-tts-refs-row${role ? ` is-${role}` : ''}`);
|
||||
const playBtn = button('anomalous-audio-play-btn', '▶', () => play(path, playBtn), t('audioPlay'));
|
||||
if (state.playing?.path === path) {
|
||||
playBtn.classList.add('is-playing');
|
||||
state.playing.button = playBtn;
|
||||
}
|
||||
const file = el('div', 'anomalous-tts-refs-file');
|
||||
const nameLine = el('div', 'anomalous-tts-refs-file-name', baseName(path));
|
||||
nameLine.title = path;
|
||||
if (seconds) {
|
||||
const length = el('span', `anomalous-tts-refs-seconds${fitsMain(seconds) ? '' : ' is-off'}`, t('ttsRefsSeconds', { seconds: seconds.toFixed(1) }));
|
||||
if (!fitsMain(seconds)) length.title = t('ttsRefsTooLongShort');
|
||||
nameLine.append(length);
|
||||
}
|
||||
file.append(nameLine);
|
||||
if (folderOf(path)) file.append(el('div', 'anomalous-tts-refs-file-folder', folderOf(path)));
|
||||
|
||||
const middle = el('div', 'anomalous-tts-refs-middle');
|
||||
middle.append(lineBox(path));
|
||||
const uses = usesOf(state.voices, path, current);
|
||||
if (current?.name === MAIN && !current.main && raw.reference?.audio === path) uses.unshift({ name: MAIN, role: 'auto' });
|
||||
if (uses.length) {
|
||||
const tags = el('div', 'anomalous-tts-refs-uses');
|
||||
tags.append(...uses.map(use => el('span', `anomalous-tts-refs-use is-${use.role}`, t(`ttsRefsUse_${use.role}`, { name: use.name === MAIN ? t('ttsRefsMainVoice') : use.name }))));
|
||||
middle.append(tags);
|
||||
}
|
||||
|
||||
const roles = el('div', 'anomalous-tts-refs-roles');
|
||||
const mainBtn = button(`anomalous-tts-refs-role${role === 'main' ? ' is-on' : ''}`, role === 'main' ? `◉ ${t('ttsRefsMain')}` : `○ ${t('ttsRefsMain')}`, () => {
|
||||
if (role === 'main') {
|
||||
if (current.name !== MAIN) return;
|
||||
clearMain(current);
|
||||
} else {
|
||||
setMain(current, path);
|
||||
}
|
||||
changed();
|
||||
renderAll(false);
|
||||
}, role === 'main' && current?.name === MAIN ? t('ttsRefsMainOffHint') : t('ttsRefsMainHintRow'));
|
||||
mainBtn.disabled = !current || (!fitsMain(seconds) && role !== 'main');
|
||||
if (!fitsMain(seconds) && role !== 'main') mainBtn.title = t('ttsRefsTooLongShort');
|
||||
const auxBtn = button(`anomalous-tts-refs-role is-aux${role === 'aux' ? ' is-on' : ''}`, role === 'aux' ? `☑ ${t('ttsRefsAux')}` : `☐ ${t('ttsRefsAux')}`, () => {
|
||||
toggleAux(current, path);
|
||||
changed();
|
||||
renderAll(false);
|
||||
}, t('ttsRefsAuxHintRow'));
|
||||
auxBtn.disabled = !current || !canAux() || role === 'main';
|
||||
if (!canAux()) auxBtn.title = auxReason();
|
||||
roles.append(mainBtn, auxBtn);
|
||||
row.append(playBtn, file, middle, roles);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderRow(path) {
|
||||
const old = rows.get(path);
|
||||
if (!old) return;
|
||||
const next = rowFor(path);
|
||||
old.replaceWith(next);
|
||||
rows.set(path, next);
|
||||
}
|
||||
|
||||
// The order is set when the voice, the filter or the search changes, not on each click,
|
||||
// so a row does not move away under the pointer.
|
||||
let order = [];
|
||||
|
||||
function renderList(reorder = true) {
|
||||
if (!state.loaded) return;
|
||||
if (reorder) order = listClips(state.audio, { voice: voice(), voices: state.voices, lineOf, filter: state.filter, query: state.query });
|
||||
rows.clear();
|
||||
const shown = order.slice(0, state.shown);
|
||||
const items = shown.map((path) => {
|
||||
const row = rowFor(path);
|
||||
rows.set(path, row);
|
||||
return row;
|
||||
});
|
||||
if (!order.length) items.push(el('div', 'anomalous-tts-refs-empty', t('ttsRefsNothing')));
|
||||
if (order.length > shown.length) {
|
||||
items.push(button('anomalous-tts-refs-more', t('ttsRefsShowMore', { count: order.length - shown.length }), () => {
|
||||
state.shown += SHOW_MORE;
|
||||
renderList(false);
|
||||
}));
|
||||
}
|
||||
list.replaceChildren(...items);
|
||||
}
|
||||
|
||||
function auxReason() {
|
||||
if (state.format < AUX_FORMAT) return t('ttsRefsNeedsUpdate');
|
||||
return t('ttsRefsAuxVersion', { version: state.version });
|
||||
}
|
||||
|
||||
function renderNote() {
|
||||
const parts = [t('ttsRefsNote')];
|
||||
if (!canAux()) parts.push(auxReason());
|
||||
note.textContent = parts.join(' ');
|
||||
}
|
||||
|
||||
function renderAll(reorder = true) {
|
||||
renderVoices();
|
||||
renderFilters();
|
||||
renderList(reorder);
|
||||
renderNote();
|
||||
}
|
||||
|
||||
// ---------- saving ----------
|
||||
|
||||
// Footer
|
||||
const footer = el('div', 'anomalous-voice-modal-footer');
|
||||
const saveBtn = button('anomalous-voice-modal-submit', t('ttsEditorSave'), async () => {
|
||||
if (!loaded) return;
|
||||
const rows = [...rowsBox.querySelectorAll('.anomalous-tts-row')].map(line => {
|
||||
const [nameInput, audio, , text] = line.querySelectorAll('input, button');
|
||||
return { name: nameInput.value.trim(), audio: audio.value.trim(), text: text.value.trim() };
|
||||
}).filter(row => row.name || row.audio);
|
||||
const problem = validateRows(rows, mainAudio.value.trim(), audioList);
|
||||
if (!state.loaded) return;
|
||||
const problem = voicesProblem(state.voices, state.audio);
|
||||
if (problem) {
|
||||
await anomalousAlert(problem);
|
||||
const missing = state.voices.find(item => item.name === problem.values?.name && !item.main);
|
||||
if (missing) {
|
||||
state.voiceKey = missing.key;
|
||||
renderAll();
|
||||
}
|
||||
await anomalousAlert(say(problem));
|
||||
return;
|
||||
}
|
||||
const next = mergeGptSovitsSettings(settings, {
|
||||
main: mainAudio.value.trim() ? { audio: mainAudio.value.trim(), text: mainText.value.trim() } : null,
|
||||
emotions: rows,
|
||||
});
|
||||
const next = writeSettings(state.settings, state.voices, state.fixes, { aux: state.format >= AUX_FORMAT, lines: canFix() });
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const updated = await saveGptSovitsSettings(name, next);
|
||||
state.dirty = false;
|
||||
onSaved?.(updated);
|
||||
close();
|
||||
scope.dispose();
|
||||
} catch (e) {
|
||||
if (!scope.signal.aborted) await anomalousAlert(t('ttsEditorSaveFailed', { error: e.message }));
|
||||
} finally {
|
||||
if (!scope.signal.aborted) saveBtn.disabled = !loaded;
|
||||
if (!scope.signal.aborted) saveBtn.disabled = !state.loaded;
|
||||
}
|
||||
});
|
||||
footer.append(button('anomalous-voice-modal-cancel', t('dialogCancel'), close), saveBtn);
|
||||
|
||||
const status = el('div', 'anomalous-tts-hint anomalous-tts-status', t('ttsEditorLoadingFiles'));
|
||||
saveBtn.disabled = true;
|
||||
footer.append(note, button('anomalous-voice-modal-cancel', t('dialogCancel'), close), saveBtn);
|
||||
|
||||
Promise.all([
|
||||
fetchGptSovitsCharacter(name, scope.signal),
|
||||
fetchReferenceLines(name, scope.signal).catch(() => null), // the lines only help: never block the files
|
||||
]).then(([detail, found]) => {
|
||||
fetchGptSovitsDetail(name, scope.signal),
|
||||
fetchReferenceClips(name, scope.signal).catch(() => null), // the lines only help: never block the files
|
||||
]).then(([{ character, format }, clips]) => {
|
||||
if (scope.signal.aborted) return;
|
||||
audioList = Array.isArray(detail.audio) ? detail.audio : [];
|
||||
lines = found || new Map();
|
||||
search.setClips(audioList, found);
|
||||
state.format = format;
|
||||
state.version = character.version || state.version;
|
||||
state.audio = Array.isArray(character.audio) ? character.audio : [];
|
||||
// The detail is read from disk now: keep its unknown fields when saving.
|
||||
if (detail.settings && typeof detail.settings === 'object') settings = detail.settings;
|
||||
datalist.replaceChildren(...audioList.map(path => { const option = el('option'); option.value = path; return option; }));
|
||||
status.textContent = t('ttsEditorFileCount', { count: audioList.length });
|
||||
loaded = true;
|
||||
if (character.settings && typeof character.settings === 'object') state.settings = character.settings;
|
||||
state.found = clips?.lines || new Map();
|
||||
state.seconds = clips?.seconds || new Map();
|
||||
({ voices: state.voices, fixes: state.fixes } = readVoices(state.settings, character, { lineFixes: canFix(), found: state.found }));
|
||||
status.textContent = t('ttsEditorFileCount', { count: state.audio.length });
|
||||
state.loaded = true;
|
||||
saveBtn.disabled = false;
|
||||
renderAll();
|
||||
}).catch(e => {
|
||||
if (scope.signal.aborted) return;
|
||||
status.textContent = t('ttsEditorLoadFailed', { error: e.message || String(e) });
|
||||
status.classList.add('is-error');
|
||||
});
|
||||
|
||||
const body = el('div', 'anomalous-tts-editor-body');
|
||||
body.append(
|
||||
status,
|
||||
search.element,
|
||||
datalist,
|
||||
el('div', 'anomalous-tts-section', t('ttsEditorMainSection')), mainRow, mainHint,
|
||||
el('div', 'anomalous-tts-section', t('ttsEditorEmotionSection')), rowsBox, addBtn, autoNote,
|
||||
);
|
||||
modal.append(header, body, footer);
|
||||
const top = el('div', 'anomalous-tts-refs-top');
|
||||
top.append(status, voicesBar, voiceTools, toolbar);
|
||||
modal.append(header, top, list, footer);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
(mainAudio.value ? addBtn : mainAudio).focus();
|
||||
return close;
|
||||
}
|
||||
|
||||
/** First problem as a message, or '' when the rows can be saved. */
|
||||
export function validateRows(rows, mainAudio, audioList) {
|
||||
const files = new Set(audioList);
|
||||
if (mainAudio && !files.has(mainAudio)) return t('ttsEditorUnknownAudio', { file: mainAudio });
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
if (!row.name) return t('ttsEditorNameMissing', { file: row.audio });
|
||||
if (row.name === 'main' || FORBIDDEN_IN_NAME.test(row.name)) return t('ttsEditorNameInvalid', { name: row.name });
|
||||
if (seen.has(row.name)) return t('ttsEditorNameDuplicate', { name: row.name });
|
||||
seen.add(row.name);
|
||||
if (!row.audio) return t('ttsEditorAudioMissing', { name: row.name });
|
||||
if (!files.has(row.audio)) return t('ttsEditorUnknownAudio', { file: row.audio });
|
||||
}
|
||||
return '';
|
||||
search.focus();
|
||||
return () => scope.dispose();
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { t } from './interface_settings.js';
|
||||
|
||||
/**
|
||||
* Edit emotions' clip search: finds a character's clips by file name or by their line (the
|
||||
* lines come from Anomalous_TTS interface 14; with an older node only names are searched)
|
||||
* and lists the first matches to hear, to use as the main voice or to add as an emotion.
|
||||
*/
|
||||
|
||||
const SHOWN = 30;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(className, label, onClick, title) {
|
||||
const node = el('button', className, label);
|
||||
node.type = 'button';
|
||||
node.onclick = onClick;
|
||||
if (title) node.title = title;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** `play(path)`, `onMain(path)` and `onEmotion(path)` act on a match. Returns { element, setClips }. */
|
||||
export function createClipSearch({ play, onMain, onEmotion }) {
|
||||
const box = el('div', 'anomalous-tts-search');
|
||||
const input = el('input', 'anomalous-tts-search-input');
|
||||
input.type = 'search';
|
||||
input.placeholder = t('ttsSearchPlaceholder');
|
||||
input.disabled = true;
|
||||
const results = el('div', 'anomalous-tts-search-results');
|
||||
box.append(input, results);
|
||||
let clips = [];
|
||||
let lines = new Map();
|
||||
|
||||
const row = (path) => {
|
||||
const line = el('div', 'anomalous-tts-search-row');
|
||||
const copy = el('div', 'anomalous-tts-search-copy');
|
||||
copy.title = path;
|
||||
copy.append(
|
||||
el('span', 'anomalous-tts-search-name', path.split('/').pop()),
|
||||
el('span', 'anomalous-tts-search-line', lines.get(path) || t('ttsSearchNoLine')),
|
||||
);
|
||||
line.append(
|
||||
button('anomalous-audio-play-btn', '▶', () => play(path), t('audioPlay')),
|
||||
copy,
|
||||
button('anomalous-tts-search-use', t('ttsSearchUseMain'), () => onMain(path)),
|
||||
button('anomalous-tts-search-use', t('ttsSearchAddEmotion'), () => onEmotion(path)),
|
||||
);
|
||||
return line;
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const words = input.value.trim().toLowerCase().split(' ').filter(Boolean);
|
||||
if (!words.length) {
|
||||
results.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const found = clips.filter(path => {
|
||||
const hay = `${path} ${lines.get(path) || ''}`.toLowerCase();
|
||||
return words.every(word => hay.includes(word));
|
||||
});
|
||||
results.replaceChildren(...found.slice(0, SHOWN).map(row));
|
||||
if (!found.length) results.append(el('div', 'anomalous-tts-hint', t('ttsSearchNone')));
|
||||
else if (found.length > SHOWN) results.append(el('div', 'anomalous-tts-hint', t('ttsSearchMore', { count: found.length - SHOWN })));
|
||||
};
|
||||
input.addEventListener('input', render);
|
||||
|
||||
return {
|
||||
element: box,
|
||||
/** The character's clips and their lines (`null`: the node sends no lines, names only). */
|
||||
setClips(list, lineMap) {
|
||||
clips = list;
|
||||
lines = lineMap || new Map();
|
||||
input.placeholder = t(lineMap ? 'ttsSearchPlaceholder' : 'ttsSearchNamesOnly');
|
||||
input.disabled = false;
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -34,3 +34,4 @@
|
||||
@import url("./styles/33-kept-combos.css?v=20261019-hero");
|
||||
@import url("./styles/34-voice-over-panel.css?v=20261020-layers");
|
||||
@import url("./styles/35-voice-over-drafts.css?v=20261020-drafts");
|
||||
@import url("./styles/36-reference-audio.css?v=20261020-refs");
|
||||
|
||||
@@ -1255,7 +1255,7 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- GPT-SoVITS emotion editor ---------- */
|
||||
/* ---------- GPT-SoVITS editors (pronunciation, tuning) ---------- */
|
||||
.anomalous-tts-editor {
|
||||
width: 680px;
|
||||
}
|
||||
@@ -1290,15 +1290,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anomalous-tts-row.is-main {
|
||||
grid-template-columns: 110px minmax(0, 1.4fr) auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.anomalous-tts-row-name {
|
||||
color: var(--amb-link);
|
||||
font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.anomalous-tts-row input {
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
@@ -1339,79 +1330,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Clip search in Edit emotions (ui_tts_clip_search.js): box, then play, name and line, two uses */
|
||||
.anomalous-tts-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-input {
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--amb-border);
|
||||
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.25));
|
||||
color: var(--amb-text-main);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.03);
|
||||
}
|
||||
|
||||
.anomalous-tts-search-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-name,
|
||||
.anomalous-tts-search-line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-name {
|
||||
color: var(--amb-text-main);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-line {
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-use {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.04);
|
||||
color: var(--amb-text-soft);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-tts-search-use:hover {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 40%, transparent);
|
||||
color: var(--amb-link);
|
||||
}
|
||||
|
||||
/* Pronunciation table (ui_tts_pronunciation.js): from → to, two listen buttons, remove */
|
||||
.anomalous-tts-pronounce .anomalous-tts-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto auto auto;
|
||||
@@ -1449,7 +1367,6 @@
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.anomalous-tts-row,
|
||||
.anomalous-tts-row.is-main,
|
||||
.anomalous-tts-pronounce .anomalous-tts-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/* The reference audio editor (ui_audio_tts_editor.js): the voices on top, every clip below. */
|
||||
.anomalous-voice-modal.anomalous-tts-refs {
|
||||
width: min(1180px, 94vw);
|
||||
max-width: 94vw;
|
||||
height: 88vh;
|
||||
max-height: 88vh;
|
||||
overflow: hidden;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The voices: the main voice, each emotion, then "+ New emotion". */
|
||||
.anomalous-tts-refs-voices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.03);
|
||||
color: var(--amb-text-main);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice:hover {
|
||||
border-color: var(--amb-border-strong);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice.is-current {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--amb-link) 16%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice.is-missing {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--amb-warn) 60%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice.is-new {
|
||||
border-style: dashed;
|
||||
background: none;
|
||||
color: var(--amb-text-soft);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice-count {
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-voice-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 14px;
|
||||
min-height: 18px;
|
||||
color: var(--amb-text-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Filters and search over the list. */
|
||||
.anomalous-tts-refs-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--amb-border);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-filter {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--amb-text-soft);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-filter.is-on {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--amb-link) 14%, transparent);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-search {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 6px;
|
||||
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.25));
|
||||
color: var(--amb-text-main);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Every clip: listen, file, its line and uses, then main / auxiliary for the voice picked. */
|
||||
.anomalous-tts-refs-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-row {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(140px, 0.9fr) minmax(0, 2.2fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid var(--amb-border);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-row.is-main {
|
||||
background: color-mix(in srgb, var(--amb-link) 9%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-row.is-aux {
|
||||
background: color-mix(in srgb, var(--amb-ok) 8%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-file {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-file-name {
|
||||
overflow: hidden;
|
||||
color: var(--amb-text-main);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-file-folder {
|
||||
overflow: hidden;
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-seconds {
|
||||
margin-left: 6px;
|
||||
color: var(--amb-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-seconds.is-off {
|
||||
color: var(--amb-warn-text);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-middle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A line reads like text until it is pointed at or edited. */
|
||||
.anomalous-tts-refs-line {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--amb-text-main);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-line::placeholder {
|
||||
color: var(--amb-warn-text);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-line:hover:not(:disabled) {
|
||||
border-color: var(--amb-border);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-line:focus {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 60%, transparent);
|
||||
background: var(--amb-lt-sunken, rgba(0, 0, 0, 0.25));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-line.is-fixed {
|
||||
border-color: color-mix(in srgb, var(--amb-warn) 35%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-uses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-use {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.07);
|
||||
color: var(--amb-text-soft);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-use.is-main {
|
||||
background: color-mix(in srgb, var(--amb-link) 18%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-use.is-aux {
|
||||
background: color-mix(in srgb, var(--amb-ok) 18%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-roles {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-role {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--amb-border);
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--amb-ink-rgb), 0.03);
|
||||
color: var(--amb-text-soft);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-role:hover:not(:disabled) {
|
||||
border-color: var(--amb-border-strong);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-role.is-on {
|
||||
border-color: color-mix(in srgb, var(--amb-link) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--amb-link) 20%, transparent);
|
||||
color: var(--amb-text-main);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-role.is-aux.is-on {
|
||||
border-color: color-mix(in srgb, var(--amb-ok) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--amb-ok) 18%, transparent);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-role:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-more,
|
||||
.anomalous-tts-refs-empty {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-top: 1px solid var(--amb-border);
|
||||
background: none;
|
||||
color: var(--amb-link);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-more {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-empty {
|
||||
border-top: none;
|
||||
color: var(--amb-text-dim);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs .anomalous-voice-modal-footer {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-note {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.anomalous-tts-refs-row {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.anomalous-tts-refs-middle,
|
||||
.anomalous-tts-refs-roles {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user