feat(voices): archetype gallery UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
281869709b
commit
b33559884c
@ -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; } }
|
||||||
|
|||||||
3
static/dist/main.min.js
vendored
3
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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',
|
||||||
|
|||||||
153
static/js/voice-archetypes.js
Normal file
153
static/js/voice-archetypes.js
Normal 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();
|
||||||
|
})();
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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…" 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>
|
||||||
|
|||||||
55
tests/archetype-gallery.spec.js
Normal file
55
tests/archetype-gallery.spec.js
Normal 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/);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user