mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-12 22:33:54 -04:00
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:
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": "生成音频",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user