feat(ui): add per-request TTS instructions

Let studio users guide speech delivery for backends that support request instructions. Blank guidance stays out of requests and media history.

Assisted-by: Codex:gpt-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-09-11 21:56:02 +00:00
1 parent d55474a149
commit 919b5c96fa
11 files changed
+95 -11

No files matched your search

@@ -152,6 +152,32 @@ test.describe('Voice Library', () => {
expect(ttsBody.model).toBe('qwen-base')
})
test('sends trimmed speech instructions and omits blank instructions', async ({ page }) => {
const ttsBodies = []
await page.route('**/tts', async route => {
ttsBodies.push(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: 'audio/wav',
headers: { 'Content-Disposition': 'attachment; filename="speech.wav"' },
body: pcmWav(1),
})
})
await page.goto('/app/tts')
await page.getByPlaceholder('Enter text to synthesize...').fill('Read this sentence.')
await page.getByLabel('Instructions').fill(' Speak slowly and warmly. ')
await page.getByRole('button', { name: /Generate$/ }).click()
await expect.poll(() => ttsBodies.length).toBe(1)
expect(ttsBodies[0].instructions).toBe('Speak slowly and warmly.')
await expect(page.getByRole('button', { name: /Generate$/ })).toBeEnabled()
await page.getByLabel('Instructions').fill(' \n ')
await page.getByRole('button', { name: /Generate$/ }).click()
await expect.poll(() => ttsBodies.length).toBe(2)
expect(ttsBodies[1]).not.toHaveProperty('instructions')
})
test('normalizes an upload and creates a consented profile', async ({ page }) => {
await page.goto('/app/voice-library/new')
await page.locator('#voice-profile-audio-file').setInputFiles({
@@ -103,7 +103,10 @@
"voice": "Stimme",
"voicePlaceholder": "Optionale Stimm-ID",
"input": "Text",
"inputPlaceholder": "Text zum Synthetisieren eingeben..."
"inputPlaceholder": "Text zum Synthetisieren eingeben...",
"instructions": "Anweisungen",
"instructionsPlaceholder": "Zum Beispiel: Langsam und warmherzig sprechen",
"instructionsHint": "Optionale Hinweise zur Sprechweise. Die Unterstützung hängt vom Backend ab."
},
"actions": {
"generate": "Audio generieren",
@@ -157,7 +157,10 @@
"voice": "Voice",
"voicePlaceholder": "Optional speaker or voice ID",
"input": "Text",
"inputPlaceholder": "Enter text to synthesize..."
"inputPlaceholder": "Enter text to synthesize...",
"instructions": "Instructions",
"instructionsPlaceholder": "For example: Speak slowly and warmly",
"instructionsHint": "Optional delivery guidance. Support depends on the backend."
},
"actions": {
"generate": "Generate",
@@ -103,7 +103,10 @@
"voice": "Voz",
"voicePlaceholder": "ID de voz opcional",
"input": "Texto",
"inputPlaceholder": "Introduce el texto a sintetizar..."
"inputPlaceholder": "Introduce el texto a sintetizar...",
"instructions": "Instrucciones",
"instructionsPlaceholder": "Por ejemplo: Habla despacio y con calidez",
"instructionsHint": "Indicaciones opcionales sobre la locución. La compatibilidad depende del backend."
},
"actions": {
"generate": "Generar audio",
@@ -103,7 +103,10 @@
"voice": "Suara",
"voicePlaceholder": "ID suara opsional",
"input": "Teks",
"inputPlaceholder": "Masukkan teks untuk disintesis..."
"inputPlaceholder": "Masukkan teks untuk disintesis...",
"instructions": "Instruksi",
"instructionsPlaceholder": "Contoh: Ucapkan perlahan dan hangat",
"instructionsHint": "Panduan penyampaian opsional. Dukungan bergantung pada backend."
},
"actions": {
"generate": "Hasilkan",
@@ -103,7 +103,10 @@
"voice": "Voce",
"voicePlaceholder": "ID voce opzionale",
"input": "Testo",
"inputPlaceholder": "Inserisci il testo da convertire in voce..."
"inputPlaceholder": "Inserisci il testo da convertire in voce...",
"instructions": "Istruzioni",
"instructionsPlaceholder": "Ad esempio: Parla lentamente e con calore",
"instructionsHint": "Indicazioni facoltative sullo stile. Il supporto dipende dal backend."
},
"actions": {
"generate": "Genera audio",
@@ -103,7 +103,10 @@
"voice": "음성",
"voicePlaceholder": "음성 ID (선택 사항)",
"input": "텍스트",
"inputPlaceholder": "합성할 텍스트를 입력하세요..."
"inputPlaceholder": "합성할 텍스트를 입력하세요...",
"instructions": "지침",
"instructionsPlaceholder": "예: 천천히 따뜻한 어조로 말하기",
"instructionsHint": "선택 사항인 말하기 지침입니다. 지원 여부는 백엔드에 따라 다릅니다."
},
"actions": {
"generate": "생성",
@@ -157,7 +157,10 @@
"voice": "Voz",
"voicePlaceholder": "Falante opcional ou ID de voz",
"input": "Texto",
"inputPlaceholder": "Digite o texto para sintetizar..."
"inputPlaceholder": "Digite o texto para sintetizar...",
"instructions": "Instruções",
"instructionsPlaceholder": "Por exemplo: Fale devagar e com calor",
"instructionsHint": "Orientações opcionais de interpretação. O suporte depende do backend."
},
"actions": {
"generate": "Gerar",
@@ -103,7 +103,10 @@
"voice": "声音",
"voicePlaceholder": "可选的声音 ID",
"input": "文本",
"inputPlaceholder": "输入要合成的文本..."
"inputPlaceholder": "输入要合成的文本...",
"instructions": "朗读指令",
"instructionsPlaceholder": "例如:缓慢、温暖地朗读",
"instructionsHint": "可选的表达方式指导。是否支持取决于后端。"
},
"actions": {
"generate": "生成音频",
+20 -3
View File
@@ -32,6 +32,7 @@ export default function TTS() {
const [manualVoice, setManualVoice] = useState('')
const [voiceProfileID, setVoiceProfileID] = useState(requestedVoiceID)
const [text, setText] = useState('')
const [instructions, setInstructions] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// What was actually sent, so the request panel records rather than predicts.
@@ -75,6 +76,7 @@ export default function TTS() {
const selectedVoice = supportsVoiceProfiles ? selectedProfile?.voice : manualVoice.trim()
const request = { model, input: text.trim() }
if (selectedVoice) request.voice = selectedVoice
if (instructions.trim()) request.instructions = instructions.trim()
setLastRequest(request)
const { blob, serverUrl } = await ttsApi.generate(request)
const url = URL.createObjectURL(blob)
@@ -84,9 +86,12 @@ export default function TTS() {
addEntry({
prompt: text.trim(),
model,
params: selectedProfile
? { voice: selectedProfile.name }
: (!supportsVoiceProfiles && manualVoice.trim() ? { voice: manualVoice.trim() } : {}),
params: {
...(selectedProfile
? { voice: selectedProfile.name }
: (!supportsVoiceProfiles && manualVoice.trim() ? { voice: manualVoice.trim() } : {})),
...(instructions.trim() ? { instructions: instructions.trim() } : {}),
},
results: [{ url: serverUrl }],
})
}
@@ -168,6 +173,18 @@ export default function TTS() {
rows={5}
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="tts-instructions">{t('tts.labels.instructions')}</label>
<textarea
id="tts-instructions"
className="textarea"
value={instructions}
onChange={(event) => setInstructions(event.target.value)}
placeholder={t('tts.labels.instructionsPlaceholder')}
rows={3}
/>
<p className="form-hint">{t('tts.labels.instructionsHint')}</p>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> {t('tts.actions.generating')}</> : <><i className="fas fa-headphones" /> {t('tts.actions.generate')}</>}
</button>
+17
View File
@@ -61,6 +61,23 @@ tts:
LocalAI returns `404` when the requested model is not installed. Models without
voice metadata do not appear in the unfiltered response.
The **Instructions** field in the Text to Speech studio maps to the optional
`instructions` property on `/v1/audio/speech`. Use it to describe delivery,
such as tone or pace:
```bash
curl http://localhost:8080/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{
"model": "tts",
"input": "Welcome to LocalAI.",
"instructions": "Speak slowly and warmly."
}' --output speech.wav
```
Backend support for speech instructions varies. Backends that do not support
this control may ignore it.
## Voice Library
Administrators can manage reusable voice-cloning references from **Operate → Voice Library** in the LocalAI WebUI. The library replaces per-model filesystem and YAML setup for supported cloning backends: