diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a956f83..f16466c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -302,7 +302,9 @@ covered by the `styles.css` manifest. 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 + them), and keeps settings fields it does not know; 🗑 on a row moves that clip to the + Recycle Bin through the node (`removeGptSovitsClips`, which also takes it out of the saved + settings) and out of the window's voices. `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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ee37f..ee8b859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,9 @@ - **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*. +- **🗑 on a row moves that clip to the Recycle Bin** (删除参考音频), with its own .txt / .lab, after saying where it is used; it is taken out of those voices and the settings. Never a permanent delete: where there is no Recycle Bin (a USB stick, a network drive) nothing is deleted and the window says so. - 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. +- Auxiliary references, fixed lines, lengths and 🗑 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. diff --git a/docs/guides/audio-studio.md b/docs/guides/audio-studio.md index de7559c..85d23ce 100644 --- a/docs/guides/audio-studio.md +++ b/docs/guides/audio-studio.md @@ -214,9 +214,10 @@ 3. **主参考**:决定这个声音的语气和念法。每个声音一条,要 3~10 秒、有台词(太长太短的会标黄,不能当主参考)。主声音也可以 **改回自动挑**,让节点自己选。 4. **副参考**:只用来融合音色,让声音更稳、更像本人。可以勾好几条,不需要台词,建议同一个人、音质干净的 2~4 条。只有 v1 / v2 / v2Pro 模型用得上,v3 / v4 的角色这一列是灰的。 5. **改台词**:直接点一行的台词就能改;找不到台词的会写“没找到台词,点这里填”。改过的台词存在角色的设置里,比 `.txt` / 标注文件优先,原来的文件不动。清空就换回文件里的台词。 -6. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。有问题(比如某个情绪还没选主参考)会提示,什么都不会改。没保存就关窗口会先问一句。 +6. **删除**:点一行最右边的 🗑,确认后这条音频和它同名的 `.txt` / `.lab` 一起**移到系统回收站**(随时能还原,从不永久删除;U 盘、网络盘上没有回收站就不删,会说明)。它用在哪些声音里,确认框里会写,删掉后自动从这些声音和设置里去掉。 +7. 点 **保存**。设置会写进角色文件夹里的 `anomalous_tts.json`,由节点检查后保存。有问题(比如某个情绪还没选主参考)会提示,什么都不会改。没保存就关窗口会先问一句。 -副参考、改台词和音频长度需要 Anomalous_TTS 接口版本 18(1.1.0);旧版本只能选主参考,窗口里会说明。 +副参考、改台词、音频长度和删除需要 Anomalous_TTS 接口版本 18(1.1.0);旧版本只能选主参考,窗口里会说明。 文件名本身带情绪的音频(例如 `Arona_Talk.开心.wav`)会被自动识别,不用再填一遍。只是扩展名的部分不算情绪,例如 `Arona_Talk.ogg.wav`、`Arona_Talk.ogg (1).ogg`。 diff --git a/web/modules/audio_engines.js b/web/modules/audio_engines.js index 38c53b7..b9404e4 100644 --- a/web/modules/audio_engines.js +++ b/web/modules/audio_engines.js @@ -208,6 +208,25 @@ export async function fetchGptSovitsDetail(name, signal) { // ---------- GPT-SoVITS settings (Anomalous writes, the node reads) ---------- +/** + * Move a character's reference clips (and their own .txt / .lab) to the Recycle Bin through + * the node (interface 18); it also takes them out of the settings. Returns the node's answer + * (`removed`, `text_files`, `character`: the detail after). + */ +export async function removeGptSovitsClips(name, paths) { + const resp = await fetch('/anomalous_tts/audio/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ character: name, paths }), + }); + if (!resp.ok) { + const message = await resp.text().catch(() => ''); + throw new Error(message || `HTTP ${resp.status}`); + } + invalidateEngineCache(); + return resp.json(); +} + /** * Replace a character's anomalous_tts.json through the node's API. `settings` must be the * full object (unknown fields preserved by the caller). Returns the refreshed group. diff --git a/web/modules/locales.js b/web/modules/locales.js index e9ce3d3..76946b0 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -2154,6 +2154,11 @@ export const i18n = { ttsRefsShowMore: "再显示一些(还有 {count} 条)", ttsRefsNoMain: "情绪“{name}”还没选主参考。", ttsRefsCloseUnsaved: "有改动还没保存,确定关闭吗?", + ttsRefsRemove: "移到回收站", + ttsRefsRemoveConfirm: "把 {file} 移到回收站?它同名的台词文件(.txt / .lab)也会一起移走,需要时可以从回收站还原。", + ttsRefsRemoveUses: "它现在是 {uses} 的参考,会从这些声音里去掉。", + ttsRefsRemoveFailed: "没有删除:{error}", + ttsRefsRemoved: "已把 {file} 移到回收站。这个角色还有 {count} 条音频。", ttsEditorRemove: "删除这一行", ttsEditorSave: "保存", ttsEditorSaveFailed: "保存失败:{error}", @@ -4846,6 +4851,11 @@ export const i18n = { ttsRefsShowMore: "Show more ({count} left)", ttsRefsNoMain: "The emotion “{name}” has no main reference yet.", ttsRefsCloseUnsaved: "There are unsaved changes. Close anyway?", + ttsRefsRemove: "Move to the Recycle Bin", + ttsRefsRemoveConfirm: "Move {file} to the Recycle Bin? Its own line file (.txt / .lab) goes with it; both can be restored from the Recycle Bin.", + ttsRefsRemoveUses: "It is now a reference of {uses}, and is taken out of those voices.", + ttsRefsRemoveFailed: "Nothing was deleted: {error}", + ttsRefsRemoved: "{file} is in the Recycle Bin. {count} audio files left in this character.", ttsEditorRemove: "Remove this row", ttsEditorSave: "Save", ttsEditorSaveFailed: "Could not save: {error}", diff --git a/web/modules/ui_audio_tts_editor.js b/web/modules/ui_audio_tts_editor.js index 7c042af..8384e0a 100644 --- a/web/modules/ui_audio_tts_editor.js +++ b/web/modules/ui_audio_tts_editor.js @@ -1,7 +1,7 @@ import { t } from './interface_settings.js'; import { createViewScope } from './ui_lifecycle.js'; import { anomalousAlert, anomalousConfirm, anomalousPrompt } from './ui_dialog.js'; -import { fetchGptSovitsDetail, fetchReferenceClips, saveGptSovitsSettings, ttsAudioUrl } from './audio_engines.js'; +import { fetchGptSovitsDetail, fetchReferenceClips, gptSovitsGroups, removeGptSovitsClips, 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, @@ -15,8 +15,10 @@ import { * **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. + * not edit. A clip can be moved to the Recycle Bin from its row (the node moves it with its own + * .txt / .lab and takes it out of the settings). Auxiliary references, fixed lines and removing + * need Anomalous_TTS interface 18 (and auxiliary references a v1 / v2 / v2Pro model); otherwise + * those controls are off or hidden and say why. * * 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). @@ -320,10 +322,52 @@ export function openGptSovitsEditor(group, { onSaved } = {}) { auxBtn.disabled = !current || !canAux() || role === 'main'; if (!canAux()) auxBtn.title = auxReason(); roles.append(mainBtn, auxBtn); + if (canFix()) { + roles.append(button('anomalous-tts-refs-remove', '🗑', () => removeClip(path), t('ttsRefsRemove'))); + } row.append(playBtn, file, middle, roles); return row; } + /** One clip to the Recycle Bin (after asking), then out of this window's voices too. */ + async function removeClip(path) { + const uses = usesOf(state.voices, path).map(use => t(`ttsRefsUse_${use.role}`, { name: use.name === MAIN ? t('ttsRefsMainVoice') : use.name })); + const message = [ + t('ttsRefsRemoveConfirm', { file: baseName(path) }), + uses.length ? t('ttsRefsRemoveUses', { uses: uses.join('、') }) : '', + ].filter(Boolean).join('\n'); + if (!await anomalousConfirm(message, t('ttsRefsRemove'))) return; + if (state.playing?.path === path) stopPlaying(); + let answer; + try { + answer = await removeGptSovitsClips(name, [path]); + } catch (e) { + if (!scope.signal.aborted) await anomalousAlert(t('ttsRefsRemoveFailed', { error: e.message })); + return; + } + if (scope.signal.aborted) return; + state.audio = state.audio.filter(item => item !== path); + state.found.delete(path); + state.seconds.delete(path); + state.fixes.delete(path); + state.cleared.delete(path); + // The node took it out of the saved settings; an emotion it was the main reference of went + // there, and goes here too unless it was changed in this window (then it waits for another). + state.voices = state.voices.filter(item => !(item.main === path && item.name !== MAIN && !item.touched)); + for (const item of state.voices) { + item.aux = item.aux.filter(other => other !== path); + if (item.main === path) item.main = null; + } + if (!state.voices.some(item => item.key === state.voiceKey)) state.voiceKey = 'main'; + const after = answer?.character; + if (after?.settings && typeof after.settings === 'object') state.settings = after.settings; + if (after) raw.reference = after.reference; + status.textContent = t('ttsRefsRemoved', { file: baseName(path), count: state.audio.length }); + order = order.filter(item => item !== path); + renderAll(false); + onSaved?.(after ? gptSovitsGroups({ characters: [after] })[0] : null); + } + function renderRow(path) { const old = rows.get(path); if (!old) return; diff --git a/web/styles.css b/web/styles.css index c94ddaa..e74946a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -34,4 +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"); +@import url("./styles/36-reference-audio.css?v=20261020-remove"); diff --git a/web/styles/36-reference-audio.css b/web/styles/36-reference-audio.css index 31b470e..d6d073a 100644 --- a/web/styles/36-reference-audio.css +++ b/web/styles/36-reference-audio.css @@ -279,6 +279,29 @@ cursor: not-allowed; } +/* To the Recycle Bin: quiet until pointed at. */ +.anomalous-tts-refs-remove { + width: 30px; + padding: 4px 0; + border: 1px solid transparent; + border-radius: 6px; + background: none; + color: var(--amb-text-dim); + font-size: 13px; + cursor: pointer; + opacity: 0.55; +} + +.anomalous-tts-refs-row:hover .anomalous-tts-refs-remove { + opacity: 1; +} + +.anomalous-tts-refs-remove:hover { + border-color: color-mix(in srgb, var(--amb-danger) 50%, transparent); + background: color-mix(in srgb, var(--amb-danger) 14%, transparent); + color: var(--amb-danger-text); +} + .anomalous-tts-refs-more, .anomalous-tts-refs-empty { display: block;