feat(design): attribute chips for voice-design prompts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 16:34:06 +02:00
parent 19283a53a4
commit 67537310d0
7 changed files with 109 additions and 0 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### 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`. - **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 ### Changed

View File

@ -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 ## 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). **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).

View File

@ -48,3 +48,11 @@
.preview-match-panel audio { width: 100%; min-width: 0; } .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; } .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; } } @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; }

View File

@ -77,6 +77,8 @@ const I18N_DICT = {
'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen', 'Open rehearsal': 'Probe öffnen', 'Fetch voices': 'Stimmen abrufen',
'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript', 'Name your voice': 'Benenne deine Stimme', 'Reference transcript': 'Referenz-Transkript',
'Preview': 'Vorschau', 'Trim your sample': 'Probe zuschneiden', 'Language': 'Sprache', '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', 'Gender': 'Geschlecht', 'Voice': 'Stimme', 'Speaking style · voice-design prompt': 'Sprechstil · Voice-Design-Prompt',
// Common placeholders // Common placeholders
'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...', 'Search voices…': 'Stimmen suchen…', 'Search voices...': 'Stimmen suchen...',

View File

@ -815,6 +815,67 @@ $('design-preset-delete').addEventListener('click', () => {
if (id === 'design-sample-text') $('d-transcript').value = $('design-sample-text').value; 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 => { document.querySelectorAll('.qwen-sample').forEach(card => {
const sample = QWEN_DESIGN_SAMPLES[card.dataset.qwenSample]; const sample = QWEN_DESIGN_SAMPLES[card.dataset.qwenSample];
const state = card.querySelector('.qwen-state'); const state = card.querySelector('.qwen-state');

View File

@ -50,6 +50,7 @@
</div> </div>
<button class="btn-secondary" id="design-preset-save">Save Voice Design Preset</button> <button class="btn-secondary" id="design-preset-save">Save Voice Design Preset</button>
</div> </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.&#10;&#10;Or: Young female, high-pitched, enthusiastic and slightly breathless.&#10;&#10;Or: Speak in an incredulous tone, with a hint of panic creeping into your voice."></textarea> <textarea id="design-instruct" placeholder="e.g. A calm, deep male voice with a slight British accent and authoritative tone.&#10;&#10;Or: Young female, high-pitched, enthusiastic and slightly breathless.&#10;&#10;Or: Speak in an incredulous tone, with a hint of panic creeping into your voice."></textarea>
<table class="design-hints"> <table class="design-hints">
<thead><tr><th>Dimension</th><th>Examples</th></tr></thead> <thead><tr><th>Dimension</th><th>Examples</th></tr></thead>

View 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');
});