diff --git a/CHANGELOG.md b/CHANGELOG.md index 247f6e9..f73e82a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Manual.md b/Manual.md index 59d6bb6..ab75c8f 100644 --- a/Manual.md +++ b/Manual.md @@ -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). diff --git a/static/css/voice-design.css b/static/css/voice-design.css index c159e21..d375d34 100644 --- a/static/css/voice-design.css +++ b/static/css/voice-design.css @@ -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; } diff --git a/static/js/i18n.js b/static/js/i18n.js index e989297..c603928 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -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...', diff --git a/static/js/voice-clone.js b/static/js/voice-clone.js index 1de12ea..388f691 100644 --- a/static/js/voice-clone.js +++ b/static/js/voice-clone.js @@ -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 = ''; + 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'); diff --git a/static/sections/s-design.html b/static/sections/s-design.html index 1ad8d71..fb7df2d 100644 --- a/static/sections/s-design.html +++ b/static/sections/s-design.html @@ -50,6 +50,7 @@ +
| Dimension | Examples |
|---|