Add routing voice-picker, restore voice filters, fix pedalboard (v1.14.3)
- App Routing's output-voice field gets the same searchable avatar-thumbnail dropdown used elsewhere, as a browse button alongside the existing free-text input (which must stay editable to target vd_ Voice Design presets not in the voice library). - My Voices table: Gender and Rating filter dropdowns existed in the JS (populateLibraryFilters, libraryFilterMatch) but their <select> elements had been dropped from the visible layout after an earlier redesign, replaced with hidden dead placeholders just to keep the code from erroring - and since populateLibraryFilters() early-returns if any of the three elements are missing, this silently broke the already-visible Language/Type dropdowns too. Restored the real elements and removed the hidden scaffold; added new Tag and Group dropdowns wired to the same filter state the sidebar chips use. - Audio effects failing with "pedalboard is not installed" despite requirements.txt listing it: the package WAS installed, but its native extension (pedalboard_native) links against libatomic.so.1, an OS-level shared library missing from the python:3.11-slim-bookworm base image. Added libatomic1 to the Dockerfile and rebuilt - verified `import pedalboard` now succeeds in the running container. - Relabeled "edit ID"/"copy ID" to "rename filename"/"copy filename" in the voice inspector - the feature already renamed the underlying .wav/.meta.json/.reference.txt/picture files via the existing /api/voice/rename endpoint, it just wasn't obvious "ID" meant "filename." Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
46ef73bbae
commit
bfd1bfb939
14
CHANGELOG.md
14
CHANGELOG.md
@ -9,6 +9,20 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.14.3] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- **Rich voice-picker dropdown for App Routing's output voice field** — same searchable, avatar-thumbnail dropdown used elsewhere in the app, attached as a browse button next to the field. The field itself stays free-text so it can still target `vd_` Voice Design presets that aren't part of the voice library.
|
||||
- **Gender, Tag, Rating, and Group filters restored/added to the My Voices table** — the Gender and Rating filtering logic already existed in the code but its `<select>` elements had been left out of the visible layout after an earlier redesign (silently no-op'ing the whole filter-population function, which also broke the already-visible Language/Type dropdowns). Added the missing controls plus new Tag and Group dropdowns wired to the same filter state the sidebar's group/tag chips already use.
|
||||
|
||||
### Fixed
|
||||
- **Audio effects (reverb/compressor/chorus/pitch) failed with "pedalboard is not installed"** even though it was — `pedalboard`'s native extension links against `libatomic.so.1`, an OS-level shared library missing from the container image. Added `libatomic1` to the Dockerfile and rebuilt.
|
||||
|
||||
### Changed
|
||||
- **"edit ID" relabeled to make clear it's literally the filename** — the voice inspector's ID rename control already renamed the underlying `.wav`/`.meta.json`/`.reference.txt`/picture files on disk (via the existing `/api/voice/rename` endpoint); it just wasn't obvious "ID" meant "filename." Relabeled to "rename filename" / "copy filename" with clearer tooltips.
|
||||
|
||||
---
|
||||
|
||||
## [1.14.2] — 2026-07-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -10,10 +10,14 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# ── System packages ───────────────────────────────────────────────────────────
|
||||
# libatomic1: pedalboard's native extension (pedalboard_native) links against
|
||||
# libatomic.so.1 and fails to import without it — pip installs the Python
|
||||
# package fine, but the C extension silently can't load at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libatomic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Python dependencies ───────────────────────────────────────────────────────
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.14.2">
|
||||
<meta name="app-version" content="1.14.3">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.14.2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.14.3">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.14.2"></script>
|
||||
<script src="/static/loader.js?v=1.14.3"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -200,7 +200,7 @@ function renderRoutingList() {
|
||||
<input class="route-input" aria-label="Input voice" value="${escHtml(r.input_voice || 'default')}" placeholder="default">
|
||||
<select class="route-lang" aria-label="Route language">${routeSelectOptions(String(r.language || '*').toUpperCase())}</select>
|
||||
<select class="route-backend" aria-label="Route backend" title="Voice Clone uses the normal TTS URL, Streaming uses the streaming URL, Voice Design uses vd_ presets, NVIDIA Magpie uses fixed NVIDIA voices, NVIDIA Zeroshot/Flow use saved library WAVs as audio prompts.">${routeBackendOptions(r.backend || 'voice_clone')}</select>
|
||||
<input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset">
|
||||
<span class="route-output-wrap"><input class="route-output" value="${escHtml(r.output_voice || '')}" list="routing-voice-options" placeholder="EN_F_VoiceName or vd_Preset"></span>
|
||||
<div class="route-sound-cell">
|
||||
<input class="route-before-sound" value="${escHtml(r.before_sound || '')}" placeholder="sounds/start.wav" list="routing-sound-options">
|
||||
<button class="btn-secondary route-sound-pick" data-target="before" title="Browse and preview uploaded sounds">Pick</button>
|
||||
@ -214,6 +214,12 @@ function renderRoutingList() {
|
||||
<button class="btn-secondary routing-delete" title="Delete route">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
// Same searchable avatar dropdown used for voice selection elsewhere in the
|
||||
// app — the field itself stays a free-text input (routes can also target
|
||||
// vd_ Voice Design presets, which aren't part of the voice library).
|
||||
if (typeof VoicePicker !== 'undefined') {
|
||||
document.querySelectorAll('.route-output').forEach(inp => VoicePicker.attachTextPicker(inp));
|
||||
}
|
||||
}
|
||||
|
||||
function readRoutingForm() {
|
||||
|
||||
@ -98,9 +98,9 @@ function selectVoice(wrap) {
|
||||
</div>
|
||||
<div class="insp-hd-row2">
|
||||
<div class="insp-id-block">
|
||||
<span class="insp-full-id" title="Click to rename the voice ID">${escHtml(voiceId)}</span>
|
||||
<button class="insp-edit-id-btn" type="button" title="Rename voice ID"><span class="mdi mdi-pencil-outline"></span> edit ID</button>
|
||||
<button class="insp-copy-id-btn" type="button" title="Copy voice ID">copy ID</button>
|
||||
<span class="insp-full-id" title="This is also the voice's filename on disk — click to rename it (updates the .wav, .json and .txt files too)">${escHtml(voiceId)}</span>
|
||||
<button class="insp-edit-id-btn" type="button" title="Rename this voice's files (.wav, .json, .txt) — the voice ID is its filename"><span class="mdi mdi-pencil-outline"></span> rename filename</button>
|
||||
<button class="insp-copy-id-btn" type="button" title="Copy filename">copy filename</button>
|
||||
</div>
|
||||
<div class="insp-hd-row2-right" style="display:flex; flex-direction:column; align-items:flex-end; gap:4px;">
|
||||
<div class="insp-actions-active"></div>
|
||||
|
||||
@ -308,32 +308,45 @@ function populateLibraryFilters() {
|
||||
const langSel = $('library-filter-lang');
|
||||
const sexSel = $('library-filter-sex');
|
||||
const typeSel = $('library-filter-type');
|
||||
const tagSel = $('library-filter-tag');
|
||||
const groupSel = $('library-filter-group');
|
||||
if (!langSel || !sexSel || !typeSel) return;
|
||||
const langSet = new Set();
|
||||
const sexSet = new Set();
|
||||
const typeSet = new Set();
|
||||
const tagSet = new Set();
|
||||
const groupSet = new Set();
|
||||
(_voices || []).forEach(v => {
|
||||
const lang = libraryVoiceLang(v);
|
||||
if (lang) langSet.add(lang);
|
||||
if (v.gender) sexSet.add(v.gender);
|
||||
const type = voiceFileType(v);
|
||||
if (type) typeSet.add(type);
|
||||
String(v.tag || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => tagSet.add(t));
|
||||
const g = (v.group || '').trim();
|
||||
if (g) groupSet.add(g);
|
||||
});
|
||||
const langs = [...langSet].sort((a,b) => libraryLanguageLabel(a).localeCompare(libraryLanguageLabel(b)));
|
||||
const sexOrder = ['F','M','N'];
|
||||
const sexes = [...sexSet]
|
||||
.sort((a, b) => (sexOrder.indexOf(a) < 0 ? 99 : sexOrder.indexOf(a)) - (sexOrder.indexOf(b) < 0 ? 99 : sexOrder.indexOf(b)));
|
||||
const types = [...typeSet].sort();
|
||||
const sig = JSON.stringify([langs, sexes, types]);
|
||||
const tags = [...tagSet].sort((a, b) => a.localeCompare(b));
|
||||
const groups = [...groupSet].sort((a, b) => a.localeCompare(b));
|
||||
const sig = JSON.stringify([langs, sexes, types, tags, groups]);
|
||||
if (sig === _libraryFilterOptionsSig) return;
|
||||
_libraryFilterOptionsSig = sig;
|
||||
const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value};
|
||||
const keep = {lang: langSel.value, sex: sexSel.value, type: typeSel.value, tag: tagSel?.value, group: groupSel?.value};
|
||||
langSel.innerHTML = '<option value="">All languages</option>' + langs.map(x => `<option value="${escHtml(x)}">${escHtml(libraryLanguageLabel(x))} (${escHtml(x)})</option>`).join('');
|
||||
sexSel.innerHTML = '<option value="">All</option>' + sexes.map(x => `<option value="${escHtml(x)}">${escHtml(SEX_FILTER_LABELS[x] || x)}</option>`).join('');
|
||||
typeSel.innerHTML = '<option value="">All</option>' + types.map(x => `<option value="${escHtml(x)}">${escHtml(x.toUpperCase())}</option>`).join('');
|
||||
if (tagSel) tagSel.innerHTML = '<option value="">All tags</option>' + tags.map(x => `<option value="${escHtml(x)}">${escHtml(x)}</option>`).join('');
|
||||
if (groupSel) groupSel.innerHTML = '<option value="">All groups</option>' + groups.map(x => `<option value="${escHtml(x)}">${escHtml(x)}</option>`).join('');
|
||||
langSel.value = langs.includes(keep.lang) ? keep.lang : '';
|
||||
sexSel.value = sexes.includes(keep.sex) ? keep.sex : '';
|
||||
typeSel.value = types.includes(keep.type) ? keep.type : '';
|
||||
if (tagSel) tagSel.value = tags.includes(keep.tag) ? keep.tag : '';
|
||||
if (groupSel) groupSel.value = groups.includes(keep.group) ? keep.group : '';
|
||||
}
|
||||
|
||||
function readLibraryFilters() {
|
||||
@ -1644,7 +1657,16 @@ $('show-disabled-cb').addEventListener('change', () => {
|
||||
$(id)?.addEventListener('change', () => { readLibraryFilters(); renderVoiceList(); });
|
||||
});
|
||||
$('library-filter-text')?.addEventListener('input', debounce(() => { readLibraryFilters(); renderVoiceList(); }, 180));
|
||||
$('library-clear-filters')?.addEventListener('click', clearLibraryFilters);
|
||||
// Tag/group use the same globals the sidebar chips already set (window._voiceTagFilter /
|
||||
// _voiceGroupFilter), so both entry points stay in sync with whichever was used last.
|
||||
$('library-filter-tag')?.addEventListener('change', function () { window._voiceTagFilter = this.value || null; renderVoiceList(); });
|
||||
$('library-filter-group')?.addEventListener('change', function () { window._voiceGroupFilter = this.value || null; renderVoiceList(); });
|
||||
$('library-clear-filters')?.addEventListener('click', () => {
|
||||
window._voiceTagFilter = null; window._voiceGroupFilter = null;
|
||||
const tagSel = $('library-filter-tag'), groupSel = $('library-filter-group');
|
||||
if (tagSel) tagSel.value = ''; if (groupSel) groupSel.value = '';
|
||||
clearLibraryFilters();
|
||||
});
|
||||
$('library-tts-backend-select')?.addEventListener('change', () => { status('Library TTS engine: ' + (backendById(libraryTtsBackend())?.label || libraryTtsBackend())); });
|
||||
|
||||
function renderVoiceList() {
|
||||
@ -3231,6 +3253,7 @@ function renderVoiceGroupsBar() {
|
||||
if (e.target.closest('.vg-del')) return;
|
||||
const g = chip.dataset.group;
|
||||
window._voiceGroupFilter = (window._voiceGroupFilter === g) ? '' : g;
|
||||
const groupSel = $('library-filter-group'); if (groupSel) groupSel.value = window._voiceGroupFilter || '';
|
||||
renderVoiceList();
|
||||
});
|
||||
});
|
||||
@ -3238,7 +3261,9 @@ function renderVoiceGroupsBar() {
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); });
|
||||
});
|
||||
bar.querySelector('.vg-clear')?.addEventListener('click', () => {
|
||||
window._voiceGroupFilter = ''; renderVoiceList();
|
||||
window._voiceGroupFilter = '';
|
||||
const groupSel = $('library-filter-group'); if (groupSel) groupSel.value = '';
|
||||
renderVoiceList();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -266,5 +266,71 @@
|
||||
_pickers[selectId]?.syncTrigger();
|
||||
}
|
||||
|
||||
window.VoicePicker = { upgrade, populate, getValue, setValue };
|
||||
// Same searchable avatar dropdown as upgrade(), but anchored to a plain
|
||||
// text <input> instead of replacing a <select> — for fields (like App
|
||||
// Routing's output voice) that must still accept free-typed values the
|
||||
// library doesn't know about (vd_ Voice Design presets), so a strict
|
||||
// select-only picker would regress existing functionality. Picking an
|
||||
// item just writes into the input; typing freely still works as before.
|
||||
function attachTextPicker(input) {
|
||||
if (!input || input.dataset.vpTextAttached) return;
|
||||
input.dataset.vpTextAttached = '1';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'vp-text-browse';
|
||||
btn.title = 'Browse voices';
|
||||
btn.innerHTML = '<span class="mdi mdi-chevron-down"></span>';
|
||||
input.insertAdjacentElement('afterend', btn);
|
||||
|
||||
const drop = document.createElement('div');
|
||||
drop.className = 'vp-drop vp-drop-floating';
|
||||
drop.hidden = true;
|
||||
drop.innerHTML = `<div class="vp-search-wrap"><span class="mdi mdi-magnify vp-search-icon"></span><input class="vp-search" placeholder="Search voices…" autocomplete="off" spellcheck="false"></div><div class="vp-list"></div>`;
|
||||
document.body.appendChild(drop);
|
||||
const searchInp = drop.querySelector('.vp-search');
|
||||
const list = drop.querySelector('.vp-list');
|
||||
|
||||
function _voices() { return (window._voices || []).filter(v => v.enabled !== false); }
|
||||
|
||||
function _renderList(filter) {
|
||||
const q = (filter || '').toLowerCase();
|
||||
const filtered = q ? _voices().filter(v => v.id.toLowerCase().includes(q)) : _voices();
|
||||
if (!filtered.length) { list.innerHTML = '<div class="vp-empty">No voices found</div>'; return; }
|
||||
list.innerHTML = filtered.map(v => _buildItem(v.id, v.id)).join('');
|
||||
list.querySelectorAll('.vp-item').forEach(el => el.classList.toggle('selected', el.dataset.value === input.value));
|
||||
}
|
||||
|
||||
function _position() {
|
||||
const r = input.getBoundingClientRect();
|
||||
drop.style.left = r.left + 'px';
|
||||
drop.style.top = (r.bottom + 4) + 'px';
|
||||
drop.style.width = Math.max(240, r.width + 32) + 'px';
|
||||
}
|
||||
|
||||
function _open() {
|
||||
_position();
|
||||
drop.hidden = false;
|
||||
searchInp.value = '';
|
||||
_renderList('');
|
||||
searchInp.focus();
|
||||
}
|
||||
function _close() { drop.hidden = true; }
|
||||
|
||||
function _select(value) {
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
_close();
|
||||
}
|
||||
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); drop.hidden ? _open() : _close(); });
|
||||
searchInp.addEventListener('input', () => _renderList(searchInp.value));
|
||||
searchInp.addEventListener('keydown', e => { if (e.key === 'Escape') { e.stopPropagation(); _close(); } });
|
||||
list.addEventListener('click', e => { const item = e.target.closest('.vp-item'); if (item) _select(item.dataset.value); });
|
||||
document.addEventListener('click', e => { if (!drop.contains(e.target) && e.target !== btn) _close(); }, true);
|
||||
window.addEventListener('resize', () => { if (!drop.hidden) _position(); });
|
||||
}
|
||||
|
||||
window.VoicePicker = { upgrade, populate, getValue, setValue, attachTextPicker };
|
||||
})();
|
||||
|
||||
@ -51,7 +51,20 @@
|
||||
<div class="vl-card vl-card-list">
|
||||
<div class="vl-filters">
|
||||
<select id="library-filter-lang" title="Language"><option value="">All</option></select>
|
||||
<select id="library-filter-sex" title="Gender"><option value="">All</option></select>
|
||||
<select id="library-filter-type" title="Type"><option value="">All types</option></select>
|
||||
<select id="library-filter-tag" title="Tag"><option value="">All tags</option></select>
|
||||
<select id="library-filter-rating" title="Rating">
|
||||
<option value="">All ratings</option>
|
||||
<option value="5">★★★★★</option>
|
||||
<option value="4">★★★★+</option>
|
||||
<option value="3">★★★+</option>
|
||||
<option value="2">★★+</option>
|
||||
<option value="1">★+</option>
|
||||
<option value="0">Unrated</option>
|
||||
</select>
|
||||
<select id="library-filter-group" title="Group"><option value="">All groups</option></select>
|
||||
<button id="library-clear-filters" class="vl-sort-dir-btn" title="Clear all filters"><span class="mdi mdi-filter-off-outline"></span></button>
|
||||
<label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label>
|
||||
</div>
|
||||
<div class="vl-sort-bar">
|
||||
@ -151,9 +164,6 @@
|
||||
<!-- Hidden scaffold — IDs required by voice-library.js but not shown in new layout -->
|
||||
<div id="vl-scaffold" style="display:none" aria-hidden="true">
|
||||
|
||||
<select id="library-filter-sex"><option value="">All</option><option value="F">F</option><option value="M">M</option><option value="N">N</option></select>
|
||||
<select id="library-filter-rating"><option value="">Any</option><option value="5">5 stars</option><option value="4">4+ stars</option><option value="3">3+ stars</option><option value="1">Rated</option><option value="0">Unrated</option></select>
|
||||
<button id="library-clear-filters" type="button">Clear filters</button>
|
||||
<div id="library-filter-panel"></div>
|
||||
<div class="library-insights" id="library-insights"></div>
|
||||
<div class="disabled-info" id="disabled-info">
|
||||
|
||||
@ -3454,6 +3454,17 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.vp-avatar { flex-shrink: 0; }
|
||||
.vp-flag { font-size: 13px; flex-shrink: 0; }
|
||||
.vp-drop[hidden] { display: none !important; }
|
||||
/* attachTextPicker's floating variant — appended to <body> and positioned in
|
||||
JS (next to whatever text input it's browsing for), not inside a .vp-root. */
|
||||
.vp-drop-floating { position: fixed; z-index: 2001; }
|
||||
.route-output-wrap { position: relative; display: inline-flex; align-items: center; width: 100%; }
|
||||
.route-output-wrap .route-output { flex: 1; min-width: 0; padding-right: 24px; }
|
||||
.vp-text-browse {
|
||||
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
||||
border: none; background: none; color: var(--subtext); cursor: pointer;
|
||||
padding: 2px; line-height: 1; border-radius: 4px;
|
||||
}
|
||||
.vp-text-browse:hover { color: var(--accent); background: var(--panel); }
|
||||
.vp-drop {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 200;
|
||||
background: var(--card-bg, var(--panel)); border: 1px solid var(--border);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user