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:
mARTin-B78 2026-07-08 21:05:45 +02:00
parent 46ef73bbae
commit bfd1bfb939
10 changed files with 152 additions and 16 deletions

View File

@ -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

View File

@ -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 ───────────────────────────────────────────────────────

View File

@ -1 +1 @@
1.14.2
1.14.3

View File

@ -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>

View File

@ -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() {

View File

@ -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>

View File

@ -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();
});
}

View File

@ -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 };
})();

View File

@ -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">

View File

@ -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);