feat: editable Source field + fish-audio fallback + bulk set source (v1.12.7)

- Add Source input to voice inspector panel (saves to origin in meta.json)
- Auto-detect fish-audio voices via tag — SOURCE column now shows
  "fish-audio" for tagged voices even without an explicit origin field
- Bulk "Set source" action in the multi-select toolbar
- Source cell in table reflects the live-displayed value and updates
  immediately when changed via the inspector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 20:28:35 +02:00
parent 04dee87710
commit 0bc3acc2ee
5 changed files with 82 additions and 12 deletions

View File

@ -9,6 +9,15 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.7] — 2026-06-27
### Added
- **Editable Source field** in the voice inspector panel (below Note) — type a source label (e.g. `fish-audio`, `cloned`) and it saves immediately to the voice's meta.json. Changes now persist across refreshes.
- **Bulk "Set source"** button in the multi-select toolbar — select any number of voices and apply a source label to all at once.
- **Fish-audio tag fallback** in the SOURCE column: voices tagged `fish-audio` automatically show `fish-audio` as their source even without an explicit origin field, so the column is populated correctly without having to edit every voice.
---
## [1.12.6] — 2026-06-27 ## [1.12.6] — 2026-06-27
### Added ### Added

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.6"> <link rel="stylesheet" href="/static/style.css?v=1.12.7">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -336,7 +336,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.6"></script> <script src="/static/loader.js?v=1.12.7"></script>
</body> </body>
</html> </html>

View File

@ -72,6 +72,17 @@ function initBenchmarkSampleControls() {
}); });
} }
// Returns the display-level source label for a voice.
// Explicit origin field wins; fallback to any recognised tag value.
function _displaySource(v) {
if (v.origin) return v.origin;
if (v.tag) {
const tags = v.tag.split(',').map(t => t.trim().toLowerCase());
if (tags.includes('fish-audio') || tags.includes('fishaudio')) return 'fish-audio';
}
return '';
}
function getSortValue(v, field) { function getSortValue(v, field) {
switch(field) { switch(field) {
case 'has_picture': return v.has_picture ? 1 : 0; case 'has_picture': return v.has_picture ? 1 : 0;
@ -84,7 +95,7 @@ function getSortValue(v, field) {
case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999; case 'benchmark': return voiceBenchmarkElapsed(v) ?? 999999;
case 'transcript': return (v.transcript || '').toLowerCase(); case 'transcript': return (v.transcript || '').toLowerCase();
case 'note': return (v.note || '').toLowerCase(); case 'note': return (v.note || '').toLowerCase();
case 'source': return (v.origin || '').toLowerCase(); case 'source': return (_displaySource(v) || '').toLowerCase();
case 'seed': return v.seed != null ? v.seed : 9999999; case 'seed': return v.seed != null ? v.seed : 9999999;
case 'tag': return (v.tag || '').toLowerCase(); case 'tag': return (v.tag || '').toLowerCase();
case 'rating': return v.rating || 0; case 'rating': return v.rating || 0;
@ -1940,7 +1951,7 @@ function makeVoiceRow(v) {
? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">` ? `<input type="number" min="0" max="5" class="vl-inline-edit" data-field="rating" value="${v.rating||0}" style="width:40px">`
: starsHtml} : starsHtml}
</div> </div>
<div class="vl-tbl-source">${escHtml(v.origin || '-')}</div> <div class="vl-tbl-source" title="Click to edit source">${escHtml(_displaySource(v) || '-')}</div>
<div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div> <div class="vl-tbl-seed">${v.seed != null ? '#' + v.seed : 'Auto'}</div>
<div class="vl-tbl-note" title="${escHtml(v.note||'')}"> <div class="vl-tbl-note" title="${escHtml(v.note||'')}">
${window._voiceTableEditMode ${window._voiceTableEditMode
@ -2035,6 +2046,13 @@ function makeVoiceRow(v) {
</div> </div>
</div> </div>
<div class="vr-source">
<label>Source</label>
<div class="vr-inline">
<input type="text" placeholder="e.g. fish-audio, cloned, designed…" value="${escHtml(v.origin||'')}">
</div>
</div>
<div class="vr-detail-active"> <div class="vr-detail-active">
<label>Active</label> <label>Active</label>
<div class="vr-active-tools"> <div class="vr-active-tools">
@ -2839,6 +2857,16 @@ function makeVoiceRow(v) {
await saveMeta(v.id, { note: v.note }); await saveMeta(v.id, { note: v.note });
}, 800)); }, 800));
// Source / origin (debounced save — also updates the table cell live)
const sourceInput = wrap.querySelector('.vr-source input');
sourceInput.addEventListener('input', debounce(async () => {
v.origin = sourceInput.value.trim();
await saveMeta(v.id, { origin: v.origin });
// Reflect in table row immediately
const tblCell = document.querySelector(`.vl-row[data-id="${CSS.escape(v.id)}"] .vl-tbl-source`);
if (tblCell) tblCell.textContent = _displaySource(v) || '-';
}, 800));
// Stars // Stars
const starSpans = wrap.querySelectorAll('.star'); const starSpans = wrap.querySelectorAll('.star');
starSpans.forEach(s => { starSpans.forEach(s => {
@ -3223,6 +3251,38 @@ $('vl-bulk-tag')?.addEventListener('click', () => {
inp.focus(); inp.focus();
}); });
$('vl-bulk-source')?.addEventListener('click', () => {
if (!_bulkSelected.size) return;
const ids = [..._bulkSelected];
const bar = $('vl-bulk-bar');
let inp = bar.querySelector('.vl-bulk-source-inp');
if (!inp) {
inp = document.createElement('input');
inp.type = 'text';
inp.className = 'vl-bulk-source-inp';
inp.placeholder = 'e.g. fish-audio, cloned…';
inp.autocomplete = 'off';
const applyBtn = document.createElement('button');
applyBtn.className = 'vl-bulk-btn';
applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
applyBtn.addEventListener('click', async () => {
const val = inp.value.trim();
let done = 0;
for (const id of ids) {
await saveMeta(id, { origin: val }).catch(() => {});
done++;
}
toast(`Source set on ${done} voice${done !== 1 ? 's' : ''}`, 'success');
inp.remove(); applyBtn.remove();
_bulkSelected.clear();
await loadVoiceLibrary();
});
bar.appendChild(inp);
bar.appendChild(applyBtn);
}
inp.focus();
});
$('vl-bulk-rating')?.addEventListener('click', () => { $('vl-bulk-rating')?.addEventListener('click', () => {
if (!_bulkSelected.size) return; if (!_bulkSelected.size) return;
const ids = [..._bulkSelected]; const ids = [..._bulkSelected];

View File

@ -93,6 +93,7 @@
<button class="vl-bulk-btn" id="vl-bulk-deselect" title="Clear selection"><span class="mdi mdi-checkbox-multiple-blank-outline"></span> None</button> <button class="vl-bulk-btn" id="vl-bulk-deselect" title="Clear selection"><span class="mdi mdi-checkbox-multiple-blank-outline"></span> None</button>
<div class="vl-bulk-sep"></div> <div class="vl-bulk-sep"></div>
<button class="vl-bulk-btn" id="vl-bulk-tag" title="Add / replace tag on selected voices"><span class="mdi mdi-tag-outline"></span> Set tag</button> <button class="vl-bulk-btn" id="vl-bulk-tag" title="Add / replace tag on selected voices"><span class="mdi mdi-tag-outline"></span> Set tag</button>
<button class="vl-bulk-btn" id="vl-bulk-source" title="Set source / origin on selected voices"><span class="mdi mdi-database-outline"></span> Set source</button>
<button class="vl-bulk-btn" id="vl-bulk-hide" title="Disable (hide) selected voices"><span class="mdi mdi-eye-off-outline"></span> Hide</button> <button class="vl-bulk-btn" id="vl-bulk-hide" title="Disable (hide) selected voices"><span class="mdi mdi-eye-off-outline"></span> Hide</button>
<button class="vl-bulk-btn" id="vl-bulk-unhide" title="Enable (unhide) selected voices"><span class="mdi mdi-eye-outline"></span> Unhide</button> <button class="vl-bulk-btn" id="vl-bulk-unhide" title="Enable (unhide) selected voices"><span class="mdi mdi-eye-outline"></span> Unhide</button>
<button class="vl-bulk-btn" id="vl-bulk-rating" title="Set star rating on selected"><span class="mdi mdi-star-outline"></span> Rate</button> <button class="vl-bulk-btn" id="vl-bulk-rating" title="Set star rating on selected"><span class="mdi mdi-star-outline"></span> Rate</button>

View File

@ -802,16 +802,16 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vr-detail-active .vr-active-tools { justify-content: flex-end; width: 100%; } .vr-detail-active .vr-active-tools { justify-content: flex-end; width: 100%; }
.vr-detail-delete { display: flex; align-items: start; justify-content: flex-end; min-height: 31px; padding-top: 18px; } .vr-detail-delete { display: flex; align-items: start; justify-content: flex-end; min-height: 31px; padding-top: 18px; }
.vr-active-tools { display: flex; gap: 10px; align-items: center; } .vr-active-tools { display: flex; gap: 10px; align-items: center; }
.vr-ref, .vr-note { display: flex; flex-direction: column; align-items: stretch; gap: 2px; } .vr-ref, .vr-note, .vr-source { display: flex; flex-direction: column; align-items: stretch; gap: 2px; }
.vr-ref .vr-inline, .vr-note .vr-inline { display: flex; gap: 6px; align-items: center; min-width: 0; } .vr-ref .vr-inline, .vr-note .vr-inline, .vr-source .vr-inline { display: flex; gap: 6px; align-items: center; min-width: 0; }
.vr-ref input { width: 100%; min-width: 0; background: transparent; border: none; border-bottom: 1px solid transparent; color: var(--subtext); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; } .vr-ref input { width: 100%; min-width: 0; background: transparent; border: none; border-bottom: 1px solid transparent; color: var(--subtext); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; }
.vr-ref input:focus { border-bottom-color: var(--accent); color: var(--text); } .vr-ref input:focus { border-bottom-color: var(--accent); color: var(--text); }
.vr-ref input::placeholder { color: var(--placehld); font-style: italic; } .vr-ref input::placeholder { color: var(--placehld); font-style: italic; }
.ref-transcribe-btn { width: 28px; height: 28px; padding: 0; border-radius: 6px; background: none; border: 1px solid transparent; color: var(--subtext); flex-shrink: 0; font-size: 14px; } .ref-transcribe-btn { width: 28px; height: 28px; padding: 0; border-radius: 6px; background: none; border: 1px solid transparent; color: var(--subtext); flex-shrink: 0; font-size: 14px; }
.ref-transcribe-btn:hover { background: var(--border); color: var(--text); filter: none; } .ref-transcribe-btn:hover { background: var(--border); color: var(--text); filter: none; }
.vr-note input { width: 100%; background: transparent; border: none; border-bottom: 1px solid var(--border); color: var(--text); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; } .vr-note input, .vr-source input { width: 100%; background: transparent; border: none; border-bottom: 1px solid var(--border); color: var(--text); font-size: 13px; padding: 3px 5px; font-family: inherit; outline: none; }
.vr-note input:focus { border-bottom-color: var(--accent); } .vr-note input:focus, .vr-source input:focus { border-bottom-color: var(--accent); }
.vr-note input::placeholder { color: var(--placehld); } .vr-note input::placeholder, .vr-source input::placeholder { color: var(--placehld); }
.vr-row-subtle { color: var(--subtext); font-size: 14px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .vr-row-subtle { color: var(--subtext); font-size: 14px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vr-optimizer { grid-column: auto; display: none; margin: 6px 0 0; width: 100%; box-sizing: border-box; } .vr-optimizer { grid-column: auto; display: none; margin: 6px 0 0; width: 100%; box-sizing: border-box; }
.vl-row.edit-open .vr-optimizer { display: block; } .vl-row.edit-open .vr-optimizer { display: block; }
@ -1952,12 +1952,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.inspector-body .vr-detail-row .vr-detail-active, .inspector-body .vr-detail-row .vr-detail-active,
.inspector-body .vr-detail-row .vr-detail-delete { display: none !important; } .inspector-body .vr-detail-row .vr-detail-delete { display: none !important; }
.inspector-body .vr-detail-row .vr-ref { display: none !important; } .inspector-body .vr-detail-row .vr-ref { display: none !important; }
.inspector-body .vr-note { padding: 10px 14px; display: flex; flex-direction: column; gap: 5px; } .inspector-body .vr-note, .inspector-body .vr-source { padding: 10px 14px; display: flex; flex-direction: column; gap: 5px; }
.inspector-body .vr-note label { .inspector-body .vr-note label, .inspector-body .vr-source label {
font-size: 10px; font-weight: 700; color: var(--subtext); font-size: 10px; font-weight: 700; color: var(--subtext);
text-transform: uppercase; letter-spacing: .07em; text-transform: uppercase; letter-spacing: .07em;
} }
.inspector-body .vr-note input { .inspector-body .vr-note input, .inspector-body .vr-source input {
background: transparent; border: none; outline: none; background: transparent; border: none; outline: none;
font-size: 13.5px; color: var(--text); width: 100%; padding: 0; font-size: 13.5px; color: var(--text); width: 100%; padding: 0;
} }