feat(design): attribute chips for voice-design prompts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
19283a53a4
commit
67537310d0
@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- **Voice Design: attribute chips.** Toggle chips (gender, age, pitch, pace, accent/region, tone/timbre, emotion; German + English labels) above the design prompt add or remove only their own phrase in the free-text description. The textarea stays fully free-form. Test: `tests/design-chips.spec.js`.
|
||||
- **Voice health: timestamped audio problems.** `core.audio.audio_problems` flags long interior silence, clipping and sudden level jumps (`problems` list with `kind`, `start_s`, `end_s`, `detail`, plus one warning per kind) in the route-test voice health; existing keys unchanged. Computed on demand and cached by file mtime, not in the voice list. Shown in the routing test result. Tests: `tests/test_audio_problems.py`.
|
||||
|
||||
### Changed
|
||||
|
||||
@ -8,6 +8,12 @@ Living manual. Every feature updates this file in the same commit as the code.
|
||||
|
||||
---
|
||||
|
||||
## Design a Voice: attribute chips
|
||||
|
||||
**Purpose:** build a voice description quickly. **Use:** open Design a Voice, press chips (gender, age, pitch, pace, accent/region, tone/timbre, emotion). Each chip adds its phrase to the description; pressing it again removes only that phrase. Gender, age, pitch and pace allow one choice each. **Free text:** type anything in the box; chips never overwrite your text and are not a limit on what you can describe. **Keyboard/touch:** chips are buttons (Tab, Space/Enter, `aria-pressed`, 44 px targets). **Languages:** chip labels follow the UI language (German/English); the inserted phrase is English. **Offline/privacy:** purely local, nothing is sent until you generate.
|
||||
|
||||
---
|
||||
|
||||
## Voice health: audio problems
|
||||
|
||||
**Purpose:** the routing test (Settings, Routing, Test) checks the routed voice's reference audio and lists problems with timestamps: `silence` (over 1.5 s of silence inside the clip), `clipping` (peaks at full scale for 0.1 s or more), `level jump` (15 dB or more change between adjacent half-second windows).
|
||||
|
||||
@ -48,3 +48,11 @@
|
||||
.preview-match-panel audio { width: 100%; min-width: 0; }
|
||||
.preview-match-transcript { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--subtext); font-family: monospace; font-size: 12px; line-height: 1.4; max-height: 88px; overflow: auto; white-space: pre-wrap; }
|
||||
@media (max-width: 900px) { .preview-match-panel { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Attribute chips (toggle buttons that edit their own phrase in the prompt) */
|
||||
.design-chips { display: flex; flex-direction: column; gap: 8px; margin: 12px 0; }
|
||||
.design-chip-group { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.design-chip-label { flex: 0 0 100%; font-size: 12px; font-weight: 600; color: var(--subtext); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.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:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
@ -77,6 +77,8 @@ const I18N_DICT = {
|
||||
'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen',
|
||||
'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript',
|
||||
'Preview': 'Vorschau', 'Trim your sample': 'Probe zuschneiden', 'Language': 'Sprache',
|
||||
// Voice-design attribute chips
|
||||
'Pitch': 'Tonhöhe', 'Pace': 'Tempo', 'Age': 'Alter', 'Accent / region': 'Akzent / Region', 'Tone / timbre': 'Klang / Timbre', 'Emotion': 'Emotion', 'male voice': 'männliche Stimme', 'female voice': 'weibliche Stimme', 'neutral voice': 'neutrale Stimme', 'child': 'Kind', 'teenager': 'Teenager', 'young adult': 'junger Erwachsener', 'middle-aged': 'mittleren Alters', 'elderly': 'älter', 'high pitch': 'hohe Tonlage', 'mid pitch': 'mittlere Tonlage', 'low pitch': 'tiefe Tonlage', 'fast pace': 'schnell', 'moderate pace': 'mittleres Tempo', 'slow pace': 'langsam', 'British accent': 'britischer Akzent', 'American accent': 'amerikanischer Akzent', 'Australian accent': 'australischer Akzent', 'Irish accent': 'irischer Akzent', 'German accent': 'deutscher Akzent', 'Southern accent': 'Südstaaten-Akzent', 'warm': 'warm', 'husky': 'rauchig', 'crisp': 'klar', 'smooth': 'weich', 'rich': 'voll', 'magnetic': 'magnetisch', 'authoritative': 'autoritär', 'cheerful': 'fröhlich', 'calm': 'ruhig', 'serious': 'ernst', 'gentle': 'sanft', 'enthusiastic': 'begeistert', 'sad': 'traurig',
|
||||
'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
|
||||
// Common placeholders
|
||||
'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...',
|
||||
|
||||
@ -815,6 +815,67 @@ $('design-preset-delete').addEventListener('click', () => {
|
||||
if (id === 'design-sample-text') $('d-transcript').value = $('design-sample-text').value;
|
||||
}));
|
||||
|
||||
// ── Attribute chips: each chip owns one phrase in #design-instruct ───────────
|
||||
// Toggling edits only that phrase; free text stays authoritative (any description works).
|
||||
const DESIGN_CHIPS = [
|
||||
['Gender', true, ['male voice', 'female voice', 'neutral voice']],
|
||||
['Age', true, ['child', 'teenager', 'young adult', 'middle-aged', 'elderly']],
|
||||
['Pitch', true, ['high pitch', 'mid pitch', 'low pitch']],
|
||||
['Pace', true, ['fast pace', 'moderate pace', 'slow pace']],
|
||||
['Accent / region', false, ['British accent', 'American accent', 'Australian accent', 'Irish accent', 'German accent', 'Southern accent']],
|
||||
['Tone / timbre', false, ['warm', 'husky', 'crisp', 'smooth', 'rich', 'magnetic', 'authoritative']],
|
||||
['Emotion', false, ['cheerful', 'calm', 'serious', 'gentle', 'enthusiastic', 'sad']],
|
||||
];
|
||||
const _chipRe = p => new RegExp('(^|[,.;]\\s*)' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=\\s*(?:[,.;]|$))', 'i');
|
||||
const chipHas = (text, p) => _chipRe(p).test(text);
|
||||
function chipRemove(text, p) {
|
||||
return text.replace(_chipRe(p), '').replace(/^\s*[,;]\s*/, '');
|
||||
}
|
||||
function chipAdd(text, p) {
|
||||
const t = text.trimEnd();
|
||||
return !t ? p : /[.,;]$/.test(t) ? t + ' ' + p : t + ', ' + p;
|
||||
}
|
||||
function syncDesignChips() {
|
||||
const v = $('design-instruct').value;
|
||||
document.querySelectorAll('#design-chips [data-phrase]').forEach(b =>
|
||||
b.setAttribute('aria-pressed', String(chipHas(v, b.dataset.phrase))));
|
||||
}
|
||||
function renderDesignChips() {
|
||||
const tr = window.t || (x => x), box = $('design-chips');
|
||||
DESIGN_CHIPS.forEach(([label, exclusive, phrases], i) => {
|
||||
const g = document.createElement('div');
|
||||
g.className = 'design-chip-group'; g.setAttribute('role', 'group');
|
||||
g.setAttribute('aria-label', tr(label));
|
||||
g.innerHTML = '<span class="design-chip-label"></span>';
|
||||
g.firstChild.textContent = tr(label);
|
||||
phrases.forEach(p => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'design-chip'; b.dataset.phrase = p; b.dataset.group = i;
|
||||
b.setAttribute('aria-pressed', 'false'); b.textContent = tr(p);
|
||||
g.appendChild(b);
|
||||
});
|
||||
box.appendChild(g);
|
||||
});
|
||||
box.addEventListener('click', e => {
|
||||
const b = e.target.closest('[data-phrase]'); if (!b) return;
|
||||
const ta = $('design-instruct'); let v = ta.value;
|
||||
const on = chipHas(v, b.dataset.phrase);
|
||||
if (on) v = chipRemove(v, b.dataset.phrase);
|
||||
else {
|
||||
if (DESIGN_CHIPS[b.dataset.group][1])
|
||||
DESIGN_CHIPS[b.dataset.group][2].forEach(o => { v = chipRemove(v, o); });
|
||||
v = chipAdd(v, b.dataset.phrase);
|
||||
}
|
||||
ta.value = v; ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
// Programmatic value changes (presets, samples) don't fire input; resync on focus/hover.
|
||||
box.addEventListener('focusin', syncDesignChips);
|
||||
box.addEventListener('pointerenter', syncDesignChips);
|
||||
$('design-instruct').addEventListener('input', syncDesignChips);
|
||||
syncDesignChips();
|
||||
}
|
||||
renderDesignChips();
|
||||
|
||||
document.querySelectorAll('.qwen-sample').forEach(card => {
|
||||
const sample = QWEN_DESIGN_SAMPLES[card.dataset.qwenSample];
|
||||
const state = card.querySelector('.qwen-state');
|
||||
|
||||
@ -50,6 +50,7 @@
|
||||
</div>
|
||||
<button class="btn-secondary" id="design-preset-save">Save Voice Design Preset</button>
|
||||
</div>
|
||||
<div id="design-chips" class="design-chips"></div>
|
||||
<textarea id="design-instruct" placeholder="e.g. A calm, deep male voice with a slight British accent and authoritative tone. Or: Young female, high-pitched, enthusiastic and slightly breathless. Or: Speak in an incredulous tone, with a hint of panic creeping into your voice."></textarea>
|
||||
<table class="design-hints">
|
||||
<thead><tr><th>Dimension</th><th>Examples</th></tr></thead>
|
||||
|
||||
30
tests/design-chips.spec.js
Normal file
30
tests/design-chips.spec.js
Normal file
@ -0,0 +1,30 @@
|
||||
// @ts-check
|
||||
// Design-prompt attribute chips; all /api routes are mocked (no live writes).
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('chips edit only their own phrase', async ({ page }) => {
|
||||
await page.route('**/api/**', route => route.request().method() === 'GET'
|
||||
? route.fulfill({ json: [] }) : route.fulfill({ json: { ok: true } }));
|
||||
await page.route('**/static/dist/main.min.js*', r => r.abort()); // test the source modules
|
||||
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'));
|
||||
const ta = page.locator('#design-instruct');
|
||||
const chip = n => page.locator('#design-chips [data-phrase="' + n + '"]');
|
||||
await ta.fill('Gravelly pirate captain');
|
||||
await chip('male voice').click();
|
||||
await chip('calm').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(ta).toHaveValue('Gravelly pirate captain, male voice, calm');
|
||||
await expect(chip('calm')).toHaveAttribute('aria-pressed', 'true');
|
||||
await chip('female voice').click(); // exclusive group swaps only gender
|
||||
await expect(ta).toHaveValue('Gravelly pirate captain, calm, female voice');
|
||||
await chip('calm').click();
|
||||
await expect(ta).toHaveValue('Gravelly pirate captain, female voice');
|
||||
await expect(chip('male voice')).toHaveAttribute('aria-pressed', 'false');
|
||||
const box = await chip('warm').boundingBox();
|
||||
expect(box && box.height).toBeGreaterThanOrEqual(44);
|
||||
await ta.fill('typed by hand, warm');
|
||||
await expect(chip('warm')).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user