diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0cba31a..a956f83 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`, diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cd9ac..78ee37f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/guides/audio-studio.md b/docs/guides/audio-studio.md index d42cd45..de7559c 100644 --- a/docs/guides/audio-studio.md +++ b/docs/guides/audio-studio.md @@ -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 | diff --git a/tests/audio_engines.mjs b/tests/audio_engines.mjs index 475cba9..1b21178 100644 --- a/tests/audio_engines.mjs +++ b/tests/audio_engines.mjs @@ -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'); diff --git a/tests/tts_voice_refs.mjs b/tests/tts_voice_refs.mjs new file mode 100644 index 0000000..f3290fe --- /dev/null +++ b/tests/tts_voice_refs.mjs @@ -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'); diff --git a/web/modules/audio_engines.js b/web/modules/audio_engines.js index d030b70..38c53b7 100644 --- a/web/modules/audio_engines.js +++ b/web/modules/audio_engines.js @@ -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/` 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; -} diff --git a/web/modules/locales.js b/web/modules/locales.js index a7fc9cd..e9ce3d3 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -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", diff --git a/web/modules/tts_voice_refs.js b/web/modules/tts_voice_refs.js new file mode 100644 index 0000000..fbe3d42 --- /dev/null +++ b/web/modules/tts_voice_refs.js @@ -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 } }; +} diff --git a/web/modules/ui_audio_tts_editor.js b/web/modules/ui_audio_tts_editor.js index b034f4f..7c042af 100644 --- a/web/modules/ui_audio_tts_editor.js +++ b/web/modules/ui_audio_tts_editor.js @@ -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(); } diff --git a/web/modules/ui_tts_clip_search.js b/web/modules/ui_tts_clip_search.js deleted file mode 100644 index 271ca31..0000000 --- a/web/modules/ui_tts_clip_search.js +++ /dev/null @@ -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(); - }, - }; -} diff --git a/web/styles.css b/web/styles.css index 3fb2dea..c94ddaa 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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"); diff --git a/web/styles/11-audio-studio.css b/web/styles/11-audio-studio.css index 5da431b..a2ccd7d 100644 --- a/web/styles/11-audio-studio.css +++ b/web/styles/11-audio-studio.css @@ -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; } diff --git a/web/styles/36-reference-audio.css b/web/styles/36-reference-audio.css new file mode 100644 index 0000000..31b470e --- /dev/null +++ b/web/styles/36-reference-audio.css @@ -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; + } +}