Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/http/react-ui/e2e/media-history.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ test.describe('Media History - TTS', () => {
await page.goto('/app/tts')
await expect(page.getByRole('button', { name: 'test-tts-model' })).toBeVisible({ timeout: 10_000 })

await page.locator('.textarea').fill('hello world')
await page.getByPlaceholder('Enter text to synthesize...').fill('hello world')
await page.locator('button[type="submit"]').click()

// History entry should appear
Expand Down
26 changes: 26 additions & 0 deletions core/http/react-ui/e2e/voice-library.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/de/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/en/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/es/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/id/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/it/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/ko/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@
"voice": "음성",
"voicePlaceholder": "음성 ID (선택 사항)",
"input": "텍스트",
"inputPlaceholder": "합성할 텍스트를 입력하세요..."
"inputPlaceholder": "합성할 텍스트를 입력하세요...",
"instructions": "지침",
"instructionsPlaceholder": "예: 천천히 따뜻한 어조로 말하기",
"instructionsHint": "선택 사항인 말하기 지침입니다. 지원 여부는 백엔드에 따라 다릅니다."
},
"actions": {
"generate": "생성",
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/pt-BR/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion core/http/react-ui/public/locales/zh-CN/media.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@
"voice": "声音",
"voicePlaceholder": "可选的声音 ID",
"input": "文本",
"inputPlaceholder": "输入要合成的文本..."
"inputPlaceholder": "输入要合成的文本...",
"instructions": "朗读指令",
"instructionsPlaceholder": "例如:缓慢、温暖地朗读",
"instructionsHint": "可选的表达方式指导。是否支持取决于后端。"
},
"actions": {
"generate": "生成音频",
Expand Down
23 changes: 20 additions & 3 deletions core/http/react-ui/src/pages/TTS.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 }],
})
}
Expand Down Expand Up @@ -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>
Expand Down
17 changes: 17 additions & 0 deletions docs/content/features/text-to-audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ curl http://localhost:8080/tts -H "Content-Type: application/json" -d '{

Returns an `audio/wav` file.

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:
Expand Down
Loading