feat(voices): archetype gallery UI

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 18:23:14 +02:00
parent 281869709b
commit b33559884c
7 changed files with 289 additions and 2 deletions

View File

@ -56,3 +56,16 @@
.design-chip { min-height: 44px; min-width: 44px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 13px; cursor: pointer; } .design-chip { min-height: 44px; min-width: 44px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 13px; cursor: pointer; }
.design-chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; } .design-chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
.design-chip:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } .design-chip:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
/* Voice archetype gallery */
.arch-bar { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
.arch-bar input, .arch-bar select { min-height: 44px; flex: 1 1 200px; }
.arch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; margin: 10px 0; }
.arch-card { border: 1px solid var(--border); border-radius: 10px; padding: 12px; background: var(--surface); display: flex; flex-direction: column; gap: 6px; }
.arch-card h3 { margin: 0; font-size: 15px; }
.arch-card p { margin: 0; font-size: 12px; color: var(--subtext); }
.arch-facets { font-size: 12px; color: var(--subtext); }
.arch-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
.arch-actions button { min-height: 44px; min-width: 44px; }
.arch-fav[aria-pressed="true"] { color: var(--accent); }
@media (prefers-reduced-motion: reduce) { .arch-card, .arch-actions button { transition: none; animation: none; } }

File diff suppressed because one or more lines are too long

View File

@ -21,6 +21,54 @@ const I18N_LANGS = {
// English for languages that don't have it yet (see window.t / applyI18n). // English for languages that don't have it yet (see window.t / applyI18n).
const I18N_DICT = { const I18N_DICT = {
de: { de: {
// Voice archetype gallery
'Voice gallery': 'Stimmengalerie',
'40 ready-made German-first voices. Preview, save or open in the editor below.': '40 fertige Stimmen, Deutsch zuerst. Anhören, speichern oder unten im Editor öffnen.',
'Favourites only': 'Nur Favoriten',
'Use voice': 'Stimme verwenden',
'Favourite': 'Favorit',
'Open in Design': 'In Design öffnen',
'Loading…': 'Lädt…',
'No voices match.': 'Keine passende Stimme.',
'Could not load the gallery.': 'Galerie konnte nicht geladen werden.',
'Rendering preview…': 'Vorschau wird erzeugt…',
'Preview ready': 'Vorschau bereit',
'Preview failed': 'Vorschau fehlgeschlagen',
'Saving voice…': 'Stimme wird gespeichert…',
'Voice saved': 'Stimme gespeichert',
'Save failed': 'Speichern fehlgeschlagen',
'All categories': 'Alle Kategorien',
'Category': 'Kategorie',
'Load more': 'Mehr laden',
'Age': 'Alter',
'Language': 'Sprache',
'male': 'männlich',
'female': 'weiblich',
'neutral': 'neutral',
'child': 'Kind',
'teenager': 'Jugendlich',
'young adult': 'jung',
'middle-aged': 'mittleren Alters',
'elderly': 'älter',
'high': 'hoch',
'mid': 'mittel',
'low': 'tief',
'none': 'ohne',
'british': 'britisch',
'american': 'amerikanisch',
'australian': 'australisch',
'irish': 'irisch',
'german': 'deutsch',
'southern': 'südlich',
'German': 'Deutsch',
'English': 'Englisch',
'warm': 'warm',
'husky': 'rau',
'crisp': 'klar',
'smooth': 'weich',
'rich': 'voll',
'magnetic': 'magnetisch',
'authoritative': 'autoritär',
// Seed Finder auto-pick // Seed Finder auto-pick
'Generates up to 8 candidates, scores them and highlights the best. Nothing is saved until you click Use seed.': 'Erzeugt bis zu 8 Kandidaten, bewertet sie und hebt den besten hervor. Gespeichert wird erst nach Klick auf „Seed verwenden“.', 'Generates up to 8 candidates, scores them and highlights the best. Nothing is saved until you click Use seed.': 'Erzeugt bis zu 8 Kandidaten, bewertet sie und hebt den besten hervor. Gespeichert wird erst nach Klick auf „Seed verwenden“.',
'Auto-pick best': 'Besten automatisch wählen', 'Auto-pick best': 'Besten automatisch wählen',

View File

@ -0,0 +1,153 @@
// -- Voice archetype gallery (Design a Voice) ---------------------------------
// Lazy: nothing is fetched or rendered until the <details> is opened.
(function () {
const det = document.getElementById('archetype-gallery');
if (!det) return;
const tr = s => (window.t ? window.t(s) : s);
const $a = id => document.getElementById(id);
const PAGE = 24, FAV_KEY = 'ttsvc-archetype-favs';
const FACETS = [
['gender', 'Gender', ['male', 'female', 'neutral']],
['age', 'Age', ['child', 'teenager', 'young adult', 'middle-aged', 'elderly']],
['pitch', 'Pitch', ['high', 'mid', 'low']],
['accent', 'Accent / region', ['none', 'british', 'american', 'australian', 'irish', 'german', 'southern']],
['language', 'Language', ['German', 'English']],
];
const st = { gender: '', age: '', pitch: '', accent: '', language: '', category: '', q: '', favOnly: false, offset: 0, total: 0 };
let favs = new Set(), booted = false, token = 0, objUrl = '';
try { favs = new Set(JSON.parse(localStorage.getItem(FAV_KEY) || '[]')); } catch (_) {}
const saveFavs = () => { try { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])); } catch (_) {} };
const say = m => { $a('arch-status').textContent = m; };
const lang = () => (window.__APP_LANG__ === 'de' ? 'de' : 'en');
function buildChips() {
const box = $a('arch-chips');
FACETS.forEach(([key, label, vals]) => {
const g = document.createElement('div');
g.className = 'design-chip-group'; g.setAttribute('role', 'group'); g.setAttribute('aria-label', tr(label));
const l = document.createElement('span'); l.className = 'design-chip-label'; l.textContent = tr(label); g.appendChild(l);
vals.forEach(v => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'design-chip'; b.dataset.k = key; b.dataset.v = v;
b.setAttribute('aria-pressed', 'false'); b.textContent = tr(v);
g.appendChild(b);
});
box.appendChild(g);
});
const f = document.createElement('button');
f.type = 'button'; f.className = 'design-chip'; f.id = 'arch-fav-only'; f.setAttribute('aria-pressed', 'false');
f.textContent = tr('Favourites only'); box.appendChild(f);
box.addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
if (b.id === 'arch-fav-only') st.favOnly = !st.favOnly;
else st[b.dataset.k] = st[b.dataset.k] === b.dataset.v ? '' : b.dataset.v;
box.querySelectorAll('[data-k]').forEach(x => x.setAttribute('aria-pressed', String(st[x.dataset.k] === x.dataset.v)));
f.setAttribute('aria-pressed', String(st.favOnly));
load(true);
});
}
function card(a) {
const name = lang() === 'de' ? a.name_de : a.name_en;
const el = document.createElement('article');
el.className = 'arch-card'; el.dataset.id = a.id;
const h = document.createElement('h3'); h.textContent = name;
const f = document.createElement('div'); f.className = 'arch-facets';
f.textContent = [a.language, ...Object.values(a.facets)].map(tr).join(' · ');
const p = document.createElement('p'); p.textContent = a.category;
const row = document.createElement('div'); row.className = 'arch-actions';
const mk = (cls, text, label) => {
const b = document.createElement('button'); b.type = 'button'; b.className = cls;
b.textContent = text; if (label) b.setAttribute('aria-label', label + ': ' + name); row.appendChild(b); return b;
};
mk('btn-primary arch-play', tr('Preview'), tr('Preview'));
mk('btn-secondary arch-use', tr('Use voice'), tr('Use voice'));
const fav = mk('btn-secondary arch-fav', favs.has(a.id) ? '★' : '☆', tr('Favourite'));
fav.setAttribute('aria-pressed', String(favs.has(a.id)));
el.append(h, f, p, row);
return el;
}
async function load(reset) {
const my = ++token;
if (reset) { st.offset = 0; $a('arch-grid').textContent = ''; }
const q = new URLSearchParams({ offset: st.offset, limit: st.favOnly ? 100 : PAGE });
['gender', 'age', 'pitch', 'accent', 'language', 'category', 'q'].forEach(k => st[k] && q.set(k, st[k]));
say(tr('Loading…'));
try {
const r = await fetch('/api/archetypes?' + q);
if (!r.ok) throw new Error(r.statusText);
const d = await r.json();
if (my !== token) return;
if (!$a('arch-category').options.length) {
const sel = $a('arch-category');
[['', tr('All categories')], ...d.categories.map(c => [c, c])].forEach(([v, t]) => sel.add(new Option(t, v)));
}
const items = st.favOnly ? d.items.filter(a => favs.has(a.id)) : d.items;
const grid = $a('arch-grid');
items.forEach(a => grid.appendChild(card(a)));
st.offset += d.items.length; st.total = d.total;
$a('arch-more').hidden = st.favOnly || st.offset >= d.total;
say(grid.children.length ? grid.children.length + ' / ' + (st.favOnly ? grid.children.length : d.total) : tr('No voices match.'));
} catch (e) { if (my === token) say(tr('Could not load the gallery.')); }
}
async function play(a, btn) {
btn.disabled = true; say(tr('Rendering preview…'));
try {
const r = await fetch('/api/archetypes/' + encodeURIComponent(a) + '/preview', { method: 'POST' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
if (objUrl) URL.revokeObjectURL(objUrl);
objUrl = URL.createObjectURL(await r.blob());
const au = $a('arch-audio'); au.src = objUrl; await au.play().catch(() => {});
say(tr('Preview ready'));
} catch (e) { say(tr('Preview failed') + ': ' + e.message); }
finally { btn.disabled = false; }
}
async function use(id, btn) {
btn.disabled = true; say(tr('Saving voice…'));
try {
const r = await fetch('/api/archetypes/' + encodeURIComponent(id) + '/use', { method: 'POST' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
say(tr('Voice saved') + ': ' + d.voice_id);
if (typeof toast === 'function') toast(tr('Voice saved') + ': ' + d.voice_id, 'success');
if (!btn.parentNode.querySelector('.arch-open')) {
const o = document.createElement('button'); o.type = 'button'; o.className = 'btn-secondary arch-open';
o.textContent = tr('Open in Design'); o.dataset.instruct = d.instruct; o.dataset.language = d.language;
btn.parentNode.insertBefore(o, btn.nextSibling); o.focus();
}
if (typeof loadVoiceLibrary === 'function') loadVoiceLibrary().catch(() => {});
} catch (e) { say(tr('Save failed') + ': ' + e.message); if (typeof toast === 'function') toast(e.message, 'error'); }
finally { btn.disabled = false; }
}
function boot() {
if (booted) return; booted = true;
buildChips();
let t;
$a('arch-q').addEventListener('input', e => { clearTimeout(t); t = setTimeout(() => { st.q = e.target.value.trim(); load(true); }, 250); });
$a('arch-category').addEventListener('change', e => { st.category = e.target.value; load(true); });
$a('arch-more').addEventListener('click', () => load(false));
$a('arch-grid').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
const id = b.closest('.arch-card').dataset.id;
if (b.classList.contains('arch-play')) play(id, b);
else if (b.classList.contains('arch-use')) use(id, b);
else if (b.classList.contains('arch-fav')) {
favs.has(id) ? favs.delete(id) : favs.add(id); saveFavs();
b.textContent = favs.has(id) ? '★' : '☆'; b.setAttribute('aria-pressed', String(favs.has(id)));
} else if (b.classList.contains('arch-open')) {
const ta = $a('design-instruct');
ta.value = b.dataset.instruct; ta.dispatchEvent(new Event('input', { bubbles: true }));
const sel = $a('d-lang'); if (sel) for (const o of sel.options) if (o.value === b.dataset.language) sel.value = o.value;
ta.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
ta.focus();
}
});
load(true);
}
det.addEventListener('toggle', () => { if (det.open) boot(); });
if (det.open) boot();
})();

View File

@ -13,6 +13,7 @@
"routing", "routing",
"vocal-isolation", "vocal-isolation",
"voice-clone", "voice-clone",
"voice-archetypes",
"voice-library", "voice-library",
"tts-preview", "tts-preview",
"generation", "generation",

View File

@ -21,6 +21,24 @@
</details> </details>
</div> </div>
<div class="card">
<details id="archetype-gallery" class="design-samples-details">
<summary>
<span>Voice gallery</span>
<span class="note">40 ready-made German-first voices. Preview, save or open in the editor below.</span>
</summary>
<div class="arch-bar">
<input type="search" id="arch-q" class="section-search" placeholder="Search voices&#x2026;" aria-label="Search voices" autocomplete="off">
<select id="arch-category" class="lang-select" aria-label="Category"></select>
</div>
<div id="arch-chips" class="design-chips"></div>
<p id="arch-status" class="note" role="status" aria-live="polite"></p>
<div id="arch-grid" class="arch-grid"></div>
<button type="button" id="arch-more" class="btn-secondary" hidden>Load more</button>
<audio id="arch-audio" hidden></audio>
</details>
</div>
<div class="card"> <div class="card">
<h2>Describe the voice</h2> <h2>Describe the voice</h2>
<p class="card-subtitle">Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.</p> <p class="card-subtitle">Describe the voice you want. Qwen3-TTS VoiceDesign will synthesise it from the description and sample text.</p>

View File

@ -0,0 +1,55 @@
// @ts-check
// Voice archetype gallery; every /api route is mocked (no live writes, no generation).
const { test, expect } = require('@playwright/test');
const items = Array.from({ length: 24 }, (_, i) => ({
id: 'v' + i, name_de: 'Stimme ' + i, name_en: 'Voice ' + i, instruct: 'Tiefe Stimme ' + i,
category: 'Narration & Story', language: 'German',
facets: { gender: i % 2 ? 'female' : 'male', age: 'elderly', pitch: 'low', accent: 'none', tone: 'warm' },
}));
test('gallery lazy-renders, filters, previews, uses and opens in design', async ({ page }) => {
const calls = [];
await page.route('**/api/archetypes**', route => {
const req = route.request(), url = new URL(req.url());
calls.push(req.method() + ' ' + url.pathname + url.search);
if (req.method() === 'GET') {
const g = url.searchParams.get('gender');
const rows = g ? items.filter(i => i.facets.gender === g) : items;
return route.fulfill({ json: { total: 30, offset: 0, limit: 24, items: rows, categories: ['Narration & Story'], cached: [] } });
}
if (url.pathname.endsWith('/preview')) return route.fulfill({ status: 502, json: { detail: 'mocked engine down' } });
return route.fulfill({ json: { voice_id: 'archetype_v0', created: true, instruct: 'Tiefe Stimme 0', language: 'German' } });
});
await page.route(/\/api\/(?!archetypes)/, route => route.request().method() === 'GET'
? route.fulfill({ json: [] }) : route.fulfill({ json: { ok: true } }));
await page.route('**/static/dist/main.min.js*', r => r.abort());
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-design'));
await expect(page.locator('.arch-card')).toHaveCount(0); // lazy: nothing before opening
expect(calls).toEqual([]);
await page.locator('#archetype-gallery > summary').click();
await expect(page.locator('.arch-card')).toHaveCount(24);
await page.locator('#arch-chips [data-k="gender"][data-v="female"]').click();
await expect(page.locator('.arch-card')).toHaveCount(12);
await expect(page.locator('#arch-chips [data-v="female"]')).toHaveAttribute('aria-pressed', 'true');
const play = page.locator('.arch-play').first();
const box = await play.boundingBox();
expect(box && box.height).toBeGreaterThanOrEqual(44);
await play.click();
await expect(page.locator('#arch-status')).toContainText('mocked engine down');
await page.locator('.arch-fav').first().click();
const stored = await page.evaluate(() => localStorage.getItem('ttsvc-archetype-favs'));
expect(JSON.parse(stored || '[]')).toHaveLength(1);
await page.locator('.arch-use').first().click();
await expect(page.locator('.arch-open').first()).toBeVisible();
await page.locator('.arch-open').first().click();
await expect(page.locator('#design-instruct')).toHaveValue(/Tiefe Stimme/);
});