Coverage for open_webui/routers/audio.py: 19%
806 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1"""Audio router — TTS speech synthesis and STT transcription endpoints."""
3import asyncio
4import base64
5import hashlib
6import html
7import io
8import logging
9import mimetypes
10import os
11import uuid
12from fnmatch import fnmatch
13from pathlib import Path
14from typing import Optional
16import aiofiles
17import aiohttp
18from fastapi import (
19 APIRouter,
20 Depends,
21 File,
22 Form,
23 HTTPException,
24 Request,
25 UploadFile,
26 status,
27)
28from fastapi.responses import FileResponse
29from open_webui.config import (
30 CACHE_DIR,
31 ELEVENLABS_API_BASE_URL,
32 WHISPER_COMPUTE_TYPE,
33 WHISPER_LANGUAGE,
34 WHISPER_MODEL_AUTO_UPDATE,
35 WHISPER_MODEL_DIR,
36 WHISPER_MULTILINGUAL,
37 WHISPER_VAD_FILTER,
38)
39from open_webui.constants import ERROR_MESSAGES
40from open_webui.env import (
41 AIOHTTP_CLIENT_SESSION_SSL,
42 AIOHTTP_CLIENT_TIMEOUT,
43 AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
44 AIOHTTP_FILE_STREAM_CHUNK_SIZE,
45 BYPASS_PYDUB_PREPROCESSING,
46 DEVICE_TYPE,
47 ENABLE_FORWARD_USER_INFO_HEADERS,
48 ENV,
49 USE_SLIM,
50)
51from open_webui.events import EVENTS, publish_event
52from open_webui.models.config import Config
53from open_webui.utils.access_control import has_permission
54from open_webui.utils.auth import get_admin_user, get_verified_user
55from open_webui.utils.headers import include_user_info_headers
56from open_webui.utils.json_codec import JSONCodec
57from open_webui.utils.misc import strict_match_mime_type
58from open_webui.utils.session_pool import get_session
59from pydantic import BaseModel
61# pydub needs stdlib audioop (gone in 3.13); keep requires-python capped < 3.13
62if not USE_SLIM: 62 ↛ 67line 62 didn't jump to line 67 because the condition on line 62 was always true
63 from pydub import AudioSegment
64 from pydub.silence import split_on_silence
65 from pydub.utils import mediainfo
67log = logging.getLogger(__name__)
68router = APIRouter()
70# --- Constants ---
72MAX_FILE_SIZE_MB: int = 20
73MAX_FILE_SIZE: int = MAX_FILE_SIZE_MB * 1024 * 1024
74AZURE_MAX_FILE_SIZE_MB: int = 200
75AZURE_MAX_FILE_SIZE: int = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024
77SPEECH_CACHE_DIR = CACHE_DIR / 'audio' / 'speech'
78SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
80TTS_CONFIG_KEYS = {
81 'OPENAI_API_BASE_URL': 'audio.tts.openai.api_base_url',
82 'OPENAI_API_KEY': 'audio.tts.openai.api_key',
83 'OPENAI_PARAMS': 'audio.tts.openai.params',
84 'API_KEY': 'audio.tts.api_key',
85 'ENGINE': 'audio.tts.engine',
86 'MODEL': 'audio.tts.model',
87 'VOICE': 'audio.tts.voice',
88 'SPLIT_ON': 'audio.tts.split_on',
89 'AZURE_SPEECH_REGION': 'audio.tts.azure.speech_region',
90 'AZURE_SPEECH_BASE_URL': 'audio.tts.azure.speech_base_url',
91 'AZURE_SPEECH_OUTPUT_FORMAT': 'audio.tts.azure.speech_output_format',
92 'MISTRAL_API_KEY': 'audio.tts.mistral.api_key',
93 'MISTRAL_API_BASE_URL': 'audio.tts.mistral.api_base_url',
94}
96STT_CONFIG_KEYS = {
97 'OPENAI_API_BASE_URL': 'audio.stt.openai.api_base_url',
98 'OPENAI_API_KEY': 'audio.stt.openai.api_key',
99 'OPENAI_API_REQUEST_FORMAT': 'audio.stt.openai.api_request_format',
100 'ENGINE': 'audio.stt.engine',
101 'MODEL': 'audio.stt.model',
102 'SUPPORTED_CONTENT_TYPES': 'audio.stt.supported_content_types',
103 'ALLOWED_EXTENSIONS': 'audio.stt.allowed_extensions',
104 'WHISPER_MODEL': 'audio.stt.whisper_model',
105 'DEEPGRAM_API_KEY': 'audio.stt.deepgram.api_key',
106 'AZURE_API_KEY': 'audio.stt.azure.api_key',
107 'AZURE_REGION': 'audio.stt.azure.region',
108 'AZURE_LOCALES': 'audio.stt.azure.locales',
109 'AZURE_BASE_URL': 'audio.stt.azure.base_url',
110 'AZURE_MAX_SPEAKERS': 'audio.stt.azure.max_speakers',
111 'MISTRAL_API_KEY': 'audio.stt.mistral.api_key',
112 'MISTRAL_API_BASE_URL': 'audio.stt.mistral.api_base_url',
113 'MISTRAL_USE_CHAT_COMPLETIONS': 'audio.stt.mistral.use_chat_completions',
114}
117async def get_config_values(key_map: dict[str, str]) -> dict:
118 values = await Config.get_many(*key_map.values())
119 return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
122def config_updates(data: dict, key_map: dict[str, str]) -> dict:
123 return {key_map[field]: value for field, value in data.items() if field in key_map}
126def is_audio_conversion_required(file_path):
127 """
128 Check if the given audio file needs conversion to mp3.
129 """
130 SUPPORTED_FORMATS = {'flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'wav', 'webm'}
132 if not os.path.isfile(file_path):
133 log.error(f'File not found: {file_path}')
134 return False
136 try:
137 info = mediainfo(file_path)
138 codec_name = info.get('codec_name', '').lower()
139 codec_type = info.get('codec_type', '').lower()
140 codec_tag_string = info.get('codec_tag_string', '').lower()
142 if codec_name == 'aac' and codec_type == 'audio' and codec_tag_string == 'mp4a':
143 # File is AAC/mp4a audio, recommend mp3 conversion
144 return True
146 # If the codec name is in the supported formats
147 if codec_name in SUPPORTED_FORMATS:
148 return False
150 return True
151 except Exception as e:
152 log.error(f'Error getting audio format: {e}')
153 return False
156def convert_audio_to_mp3(file_path):
157 """Convert audio file to mp3 format."""
158 try:
159 output_path = os.path.splitext(file_path)[0] + '.mp3'
160 audio = AudioSegment.from_file(file_path)
161 audio.export(output_path, format='mp3')
162 log.info('Converted %s to %s', file_path, output_path)
163 return output_path
164 except Exception as e:
165 log.error(f'Error converting audio file: {e}')
166 return None
169def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_path: str) -> bool:
170 """
171 Transcode audio bytes to MP3 if the Content-Type indicates a non-MP3 format.
173 Handles raw PCM audio (e.g. Gemini-TTS via OpenRouter/LiteLLM) by parsing
174 optional rate/channels from the Content-Type params, defaulting to 24kHz,
175 16-bit, mono. For other non-MP3 formats, uses pydub auto-detection.
177 Returns True if transcoding was performed, False if the data is already MP3.
178 Respects BYPASS_PYDUB_PREPROCESSING — when set, writes raw bytes and logs a warning.
179 """
180 mime_type = content_type_header.split(';')[0].strip().lower()
182 if mime_type in ('audio/mpeg', 'audio/mp3'):
183 return False
185 if BYPASS_PYDUB_PREPROCESSING:
186 log.warning(
187 f'TTS returned {mime_type} but BYPASS_PYDUB_PREPROCESSING is set; writing raw audio without transcoding'
188 )
189 return False
191 if mime_type in ('audio/pcm', 'audio/l16', 'audio/raw'):
192 # Parse optional rate/channels from Content-Type params,
193 # default: 24kHz, 16-bit, mono (standard for Gemini TTS).
194 ct_params = {}
195 for part in content_type_header.split(';')[1:]:
196 key_val = part.strip().split('=')
197 if len(key_val) == 2:
198 ct_params[key_val[0].strip().lower()] = key_val[1].strip()
200 sample_rate = int(ct_params.get('rate', 24000))
201 channels = int(ct_params.get('channels', 1))
203 audio_segment = AudioSegment.from_raw(
204 io.BytesIO(audio_data),
205 sample_width=2,
206 frame_rate=sample_rate,
207 channels=channels,
208 )
209 else:
210 audio_segment = AudioSegment.from_file(io.BytesIO(audio_data))
212 audio_segment.export(str(output_path), format='mp3')
213 log.info('Transcoded %s audio to MP3: %s', mime_type, output_path)
214 return True
217def set_faster_whisper_model(model: str, auto_update: bool = False):
218 if USE_SLIM: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 raise HTTPException(503, 'Configure an external speech-to-text engine. Local Whisper is unavailable in slim.')
220 whisper_model = None
221 if model:
222 from faster_whisper import WhisperModel
224 faster_whisper_kwargs = {
225 'model_size_or_path': model,
226 'device': DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == 'cuda' else 'cpu',
227 'compute_type': WHISPER_COMPUTE_TYPE,
228 'download_root': WHISPER_MODEL_DIR,
229 'local_files_only': not auto_update,
230 }
232 try:
233 whisper_model = WhisperModel(**faster_whisper_kwargs)
234 except Exception:
235 log.warning('WhisperModel initialization failed, attempting download with local_files_only=False')
236 faster_whisper_kwargs['local_files_only'] = False
237 whisper_model = WhisperModel(**faster_whisper_kwargs)
238 return whisper_model
241class TTSConfigForm(BaseModel):
242 OPENAI_API_BASE_URL: str
243 OPENAI_API_KEY: str
244 OPENAI_PARAMS: Optional[dict] = None
245 API_KEY: str
246 ENGINE: str
247 MODEL: str
248 VOICE: str
249 SPLIT_ON: str
250 AZURE_SPEECH_REGION: str
251 AZURE_SPEECH_BASE_URL: str
252 AZURE_SPEECH_OUTPUT_FORMAT: str
253 MISTRAL_API_KEY: str
254 MISTRAL_API_BASE_URL: str
257class STTConfigForm(BaseModel):
258 OPENAI_API_BASE_URL: str
259 OPENAI_API_KEY: str
260 OPENAI_API_REQUEST_FORMAT: str = 'multipart'
261 ENGINE: str
262 MODEL: str
263 SUPPORTED_CONTENT_TYPES: list[str] = []
264 ALLOWED_EXTENSIONS: list[str] = []
265 WHISPER_MODEL: str
266 DEEPGRAM_API_KEY: str
267 AZURE_API_KEY: str
268 AZURE_REGION: str
269 AZURE_LOCALES: str
270 AZURE_BASE_URL: str
271 AZURE_MAX_SPEAKERS: str
272 MISTRAL_API_KEY: str
273 MISTRAL_API_BASE_URL: str
274 MISTRAL_USE_CHAT_COMPLETIONS: bool
277class AudioConfigUpdateForm(BaseModel):
278 tts: TTSConfigForm
279 stt: STTConfigForm
282@router.get('/config')
283async def get_audio_config(request: Request, user=Depends(get_admin_user)):
284 return {
285 'tts': await get_config_values(TTS_CONFIG_KEYS),
286 'stt': await get_config_values(STT_CONFIG_KEYS),
287 }
290@router.post('/config/update')
291async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user)):
292 if USE_SLIM: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true
293 current = await Config.get_many('audio.stt.engine', 'audio.tts.engine')
294 if form_data.stt.ENGINE == '' and current.get('audio.stt.engine') != '':
295 raise HTTPException(400, 'Local Whisper is unavailable in slim. Select an external speech-to-text engine.')
296 if form_data.tts.ENGINE == 'transformers' and current.get('audio.tts.engine') != 'transformers':
297 raise HTTPException(400, 'Local TTS is unavailable in slim. Select an external text-to-speech engine.')
298 await Config.upsert(
299 {
300 **config_updates(form_data.tts.model_dump(exclude_unset=True), TTS_CONFIG_KEYS),
301 **config_updates(form_data.stt.model_dump(exclude_unset=True), STT_CONFIG_KEYS),
302 }
303 )
305 if form_data.stt.ENGINE == '' and not USE_SLIM:
306 request.app.state.faster_whisper_model = await asyncio.to_thread(
307 set_faster_whisper_model, form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE
308 )
309 else:
310 request.app.state.faster_whisper_model = None
312 config = await get_audio_config(request, user)
313 await publish_event(
314 request,
315 EVENTS.CONFIG_UPDATED,
316 actor=user,
317 subject_id='audio',
318 data={
319 'tts_engine': config.get('tts', {}).get('ENGINE'),
320 'stt_engine': config.get('stt', {}).get('ENGINE'),
321 },
322 )
323 return config
326def load_speech_pipeline(request):
327 if USE_SLIM:
328 raise HTTPException(503, 'Configure an external text-to-speech engine. Local TTS is unavailable in slim.')
329 from datasets import load_dataset
330 from transformers import pipeline
332 if request.app.state.speech_synthesiser is None:
333 request.app.state.speech_synthesiser = pipeline('text-to-speech', 'microsoft/speecht5_tts')
335 if request.app.state.speech_speaker_embeddings_dataset is None:
336 request.app.state.speech_speaker_embeddings_dataset = load_dataset(
337 'Matthijs/cmu-arctic-xvectors', split='validation'
338 )
341async def _raise_tts_error(exc: Exception, r=None) -> None:
342 """Raise a standardised HTTPException from a TTS provider failure."""
343 if isinstance(exc, HTTPException):
344 raise exc
345 code = r.status if r is not None and r.status >= 400 else 500
346 # LICENSE covers this Open WebUI error identifier.
347 # Do not alter, remove, obscure, or replace it except as LICENSE permits:
348 # https://docs.openwebui.com/license.
349 detail = 'Open WebUI: Server Connection Error'
350 if r is not None:
351 try:
352 res = await r.json()
353 if 'error' in res:
354 msg = res['error']
355 detail = f'External: {msg.get("message", msg) if isinstance(msg, dict) else msg}'
356 elif 'message' in res:
357 detail = f'External: {res["message"]}'
358 except Exception:
359 detail = f'External: {exc}'
360 raise HTTPException(status_code=code, detail=detail)
363async def _write_tts_cache(
364 file_path: Path,
365 audio: bytes,
366 body_path: Path,
367 payload: dict,
368 content_type: str = 'audio/mpeg',
369) -> None:
370 """Persist audio + request metadata to the speech cache."""
371 if USE_SLIM:
372 mime_type = content_type.split(';')[0].strip().lower()
373 if mime_type not in {
374 'audio/mpeg',
375 'audio/mp3',
376 'audio/wav',
377 'audio/x-wav',
378 'audio/ogg',
379 'audio/opus',
380 'audio/webm',
381 'audio/flac',
382 'audio/aac',
383 'audio/mp4',
384 }:
385 raise HTTPException(
386 502,
387 f'TTS returned unsupported format {mime_type}. Configure the provider to return MP3, WAV, Ogg, or another browser-playable audio format.',
388 )
389 async with aiofiles.open(file_path.with_suffix('.mime'), 'w') as f:
390 await f.write(content_type)
391 async with aiofiles.open(file_path, 'wb') as f:
392 await f.write(audio)
393 async with aiofiles.open(body_path, 'w') as f:
394 await f.write(JSONCodec.dumps(payload))
397async def _tts_openai(request, payload, file_path, file_body_path, user):
398 """Generate speech via an OpenAI-compatible TTS endpoint."""
399 payload['model'] = await Config.get('audio.tts.model')
400 if not payload.get('voice'):
401 payload['voice'] = await Config.get('audio.tts.voice')
402 payload = {**payload, **(await Config.get('audio.tts.openai.params') or {})}
403 api_key = await Config.get('audio.tts.openai.api_key')
404 api_base_url = await Config.get('audio.tts.openai.api_base_url')
406 headers = {
407 'Content-Type': 'application/json',
408 'Authorization': f'Bearer {api_key}',
409 }
410 if ENABLE_FORWARD_USER_INFO_HEADERS:
411 headers = include_user_info_headers(headers, user)
413 r = None
414 try:
415 session = await get_session()
416 r = await session.post(
417 url=f'{api_base_url}/audio/speech',
418 json=payload,
419 headers=headers,
420 ssl=AIOHTTP_CLIENT_SESSION_SSL,
421 )
422 r.raise_for_status()
424 audio_data = await r.read()
425 content_type = r.headers.get('Content-Type', 'audio/mpeg')
427 if USE_SLIM:
428 await _write_tts_cache(file_path, audio_data, file_body_path, payload, content_type)
429 return FileResponse(file_path, media_type=content_type)
431 if not await asyncio.to_thread(transcode_audio_to_mp3, audio_data, content_type, file_path):
432 async with aiofiles.open(file_path, 'wb') as f:
433 await f.write(audio_data)
435 async with aiofiles.open(file_body_path, 'w') as f:
436 await f.write(JSONCodec.dumps(payload))
438 return FileResponse(file_path)
439 except Exception as exc:
440 log.exception(exc)
441 await _raise_tts_error(exc, r)
444async def _tts_elevenlabs(request, payload, file_path, file_body_path, user):
445 """Generate speech via the ElevenLabs TTS API."""
446 voice_id = (payload.get('voice') or '').strip()
447 if not voice_id:
448 raise HTTPException(status_code=400, detail='Invalid voice id')
450 available_voices = await get_available_voices(request)
451 if available_voices and voice_id not in available_voices:
452 raise HTTPException(status_code=400, detail='Invalid voice id')
454 r = None
455 try:
456 session = await get_session()
457 async with session.post(
458 f'{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}',
459 json={
460 'text': payload['input'],
461 'model_id': await Config.get('audio.tts.model'),
462 'voice_settings': {'stability': 0.5, 'similarity_boost': 0.5},
463 },
464 headers={
465 'Accept': 'audio/mpeg',
466 'Content-Type': 'application/json',
467 'xi-api-key': await Config.get('audio.tts.api_key'),
468 },
469 ssl=AIOHTTP_CLIENT_SESSION_SSL,
470 ) as r:
471 r.raise_for_status()
472 content_type = r.headers.get('Content-Type', 'audio/mpeg')
473 await _write_tts_cache(file_path, await r.read(), file_body_path, payload, content_type)
474 return FileResponse(file_path, media_type=content_type if USE_SLIM else None)
475 except Exception as exc:
476 log.exception(exc)
477 await _raise_tts_error(exc, r)
480async def _tts_azure(request, payload, file_path, file_body_path, user):
481 """Generate speech via Azure Cognitive Services TTS."""
482 az_region = await Config.get('audio.tts.azure.speech_region') or 'eastus'
483 az_base = await Config.get('audio.tts.azure.speech_base_url')
484 language = payload.get('voice') or await Config.get('audio.tts.voice')
485 locale = '-'.join(language.split('-')[:2])
486 output_format = await Config.get('audio.tts.azure.speech_output_format')
488 ssml = (
489 f'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{html.escape(locale)}">'
490 f'<voice name="{html.escape(language)}">{html.escape(payload["input"])}</voice>'
491 f'</speak>'
492 )
494 r = None
495 try:
496 session = await get_session()
497 async with session.post(
498 (az_base or f'https://{az_region}.tts.speech.microsoft.com') + '/cognitiveservices/v1',
499 headers={
500 'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key'),
501 'Content-Type': 'application/ssml+xml',
502 'X-Microsoft-OutputFormat': output_format,
503 },
504 data=ssml,
505 ssl=AIOHTTP_CLIENT_SESSION_SSL,
506 ) as r:
507 r.raise_for_status()
508 content_type = r.headers.get('Content-Type', 'audio/mpeg')
509 await _write_tts_cache(file_path, await r.read(), file_body_path, payload, content_type)
510 return FileResponse(file_path, media_type=content_type if USE_SLIM else None)
511 except Exception as exc:
512 log.exception(exc)
513 await _raise_tts_error(exc, r)
516async def _tts_transformers(request, payload, file_path, file_body_path, user):
517 """Generate speech via the local HuggingFace SpeechT5 pipeline (thread-offloaded)."""
518 if USE_SLIM:
519 raise HTTPException(503, 'Configure an external text-to-speech engine. Local TTS is unavailable in slim.')
520 import soundfile as sf
521 import torch
523 await asyncio.to_thread(load_speech_pipeline, request)
525 embeddings = request.app.state.speech_speaker_embeddings_dataset
526 model_name = await Config.get('audio.tts.model')
528 idx = 6799
529 try:
530 idx = embeddings['filename'].index(model_name)
531 except (ValueError, KeyError):
532 log.debug('Speaker embedding not found for %s, using default index %s', model_name, idx)
534 def _run_pipeline():
535 speaker_embedding = torch.tensor(embeddings[idx]['xvector']).unsqueeze(0)
536 wav = request.app.state.speech_synthesiser(
537 payload['input'], # raw text to synthesize
538 forward_params={
539 'speaker_embeddings': speaker_embedding,
540 },
541 )
542 sf.write(str(file_path), wav['audio'], samplerate=wav['sampling_rate'])
544 await asyncio.to_thread(_run_pipeline)
546 # Audio file already written by sf.write; just persist the request metadata.
547 async with aiofiles.open(file_body_path, 'w') as f:
548 await f.write(JSONCodec.dumps(payload))
549 return FileResponse(file_path)
552async def _tts_mistral(request, payload, file_path, file_body_path, user):
553 """Generate speech via the Mistral TTS API."""
554 api_key = await Config.get('audio.tts.mistral.api_key')
555 api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1'
557 if not api_key:
558 raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral TTS')
560 r = None
561 try:
562 session = await get_session()
563 r = await session.post(
564 url=f'{api_base_url}/audio/speech',
565 json={
566 'input': payload.get('input', ''), # text to synthesize
567 'model': await Config.get('audio.tts.model') or 'voxtral-mini-tts-2603',
568 'voice_id': payload.get('voice', ''),
569 'response_format': 'mp3',
570 },
571 headers={
572 'Content-Type': 'application/json',
573 'Authorization': f'Bearer {api_key}',
574 },
575 ssl=AIOHTTP_CLIENT_SESSION_SSL,
576 )
577 r.raise_for_status()
579 res = await r.json()
580 audio_b64 = res.get('audio_data', '')
581 if not audio_b64:
582 raise ValueError('No audio_data in Mistral TTS response')
584 await _write_tts_cache(file_path, base64.b64decode(audio_b64), file_body_path, payload)
585 return FileResponse(file_path)
586 except Exception as exc:
587 log.exception(exc)
588 await _raise_tts_error(exc, r)
591# Dispatcher map: engine name -> handler
592_TTS_ENGINES = {
593 'openai': _tts_openai,
594 'elevenlabs': _tts_elevenlabs,
595 'azure': _tts_azure,
596 'transformers': _tts_transformers,
597 'mistral': _tts_mistral,
598}
601@router.post('/speech')
602async def speech(request: Request, user=Depends(get_verified_user)):
603 engine = await Config.get('audio.tts.engine')
604 if USE_SLIM and engine in ('', 'transformers'): 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true
605 raise HTTPException(503, 'Configure an external text-to-speech engine.')
606 if engine == '':
607 raise HTTPException(
608 status_code=status.HTTP_404_NOT_FOUND,
609 detail=ERROR_MESSAGES.NOT_FOUND,
610 )
612 if user.role != 'admin' and not await has_permission(user.id, 'chat.tts', await Config.get('user.permissions')): 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true
613 raise HTTPException(
614 status_code=status.HTTP_403_FORBIDDEN,
615 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
616 )
618 body = await request.body()
619 name = hashlib.sha256(
620 body
621 + str(engine).encode('utf-8')
622 + str(await Config.get('audio.tts.model')).encode('utf-8')
623 + (b':slim' if USE_SLIM else b'')
624 ).hexdigest()
626 file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3')
627 file_body_path = SPEECH_CACHE_DIR.joinpath(f'{name}.json')
629 # Return cached result if available
630 if file_path.is_file(): 630 ↛ 631line 630 didn't jump to line 631 because the condition on line 630 was never true
631 await publish_event(
632 request,
633 EVENTS.AUDIO_SPEECH_REQUESTED,
634 actor=user,
635 subject_id=name,
636 data={'engine': engine, 'cached': True},
637 )
638 content_type = None
639 if USE_SLIM:
640 async with aiofiles.open(file_path.with_suffix('.mime')) as f:
641 content_type = await f.read()
642 return FileResponse(file_path, media_type=content_type)
644 try:
645 payload = JSONCodec.loads(body)
646 except Exception as exc:
647 log.exception(exc)
648 raise HTTPException(status_code=400, detail='Invalid JSON payload')
650 handler = _TTS_ENGINES.get(engine)
651 if handler is None:
652 raise HTTPException(status_code=400, detail=f'Unsupported TTS engine: {engine}')
654 response = await handler(request, payload, file_path, file_body_path, user)
655 await publish_event(
656 request,
657 EVENTS.AUDIO_SPEECH_REQUESTED,
658 actor=user,
659 subject_id=name,
660 data={
661 'engine': engine,
662 'model': payload.get('model'),
663 'input_preview': str(payload.get('input', ''))[:300],
664 'cached': False,
665 },
666 )
667 return response
670async def _transcribe_whisper(request, file_path, languages, file_dir, id):
671 if request.app.state.faster_whisper_model is None:
672 request.app.state.faster_whisper_model = await asyncio.to_thread(
673 set_faster_whisper_model, await Config.get('audio.stt.whisper_model')
674 )
676 model = request.app.state.faster_whisper_model
678 def _run():
679 segments, info = model.transcribe(
680 file_path,
681 beam_size=5,
682 vad_filter=WHISPER_VAD_FILTER,
683 language=languages[0],
684 multilingual=WHISPER_MULTILINGUAL,
685 )
686 log.info("Detected language '%s' with probability %f", info.language, info.language_probability)
687 return ''.join([segment.text for segment in list(segments)])
689 transcript = await asyncio.to_thread(_run)
690 data = {'text': transcript.strip()}
692 async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
693 await f.write(JSONCodec.dumps(data))
695 log.debug(data)
696 return data
699async def _transcribe_openai(request, file_path, filename, languages, file_dir, id, user=None):
700 """Transcribe audio via an OpenAI-compatible STT endpoint."""
701 r = None
702 try:
703 session = await get_session()
704 api_key = await Config.get('audio.stt.openai.api_key')
705 api_base_url = await Config.get('audio.stt.openai.api_base_url')
706 request_format = (await Config.get('audio.stt.openai.api_request_format') or 'multipart').lower()
708 headers = {'Authorization': f'Bearer {api_key}'}
709 if user and ENABLE_FORWARD_USER_INFO_HEADERS:
710 headers = include_user_info_headers(headers, user)
712 for language in languages:
713 payload = {'model': await Config.get('audio.stt.model')}
714 if language:
715 payload['language'] = language
717 if request_format == 'json':
718 ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'wav'
719 async with aiofiles.open(file_path, 'rb') as f:
720 payload['input_audio'] = {
721 'data': base64.b64encode(await f.read()).decode('utf-8'),
722 'format': 'ogg' if ext == 'oga' else ext,
723 }
725 r = await session.post(
726 url=f'{api_base_url}/audio/transcriptions',
727 headers={**headers, 'Content-Type': 'application/json'},
728 json=payload,
729 ssl=AIOHTTP_CLIENT_SESSION_SSL,
730 )
731 else:
732 form_data = aiohttp.FormData()
733 for key, value in payload.items():
734 form_data.add_field(key, str(value))
736 async def audio_chunks():
737 async with aiofiles.open(file_path, 'rb') as audio_file:
738 while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
739 yield chunk
741 form_data.add_field('file', audio_chunks(), filename=filename)
743 r = await session.post(
744 url=f'{api_base_url}/audio/transcriptions',
745 headers=headers,
746 data=form_data,
747 ssl=AIOHTTP_CLIENT_SESSION_SSL,
748 )
749 if r.status == 200:
750 break
752 r.raise_for_status()
753 data = await r.json()
755 async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
756 await f.write(JSONCodec.dumps(data))
757 return data
758 except Exception as e:
759 log.exception(e)
760 detail = None
761 if r is not None:
762 try:
763 res = await r.json()
764 if 'error' in res:
765 detail = f'External: {res["error"].get("message", "")}'
766 except Exception:
767 detail = f'External: {e}'
768 # LICENSE covers this Open WebUI error identifier.
769 # Do not alter, remove, obscure, or replace it except as LICENSE permits:
770 # https://docs.openwebui.com/license.
771 raise Exception(detail if detail else 'Open WebUI: Server Connection Error')
774async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
775 """Transcribe audio via the Deepgram listen API with language fallback."""
776 content_type = mimetypes.guess_type(file_path)[0] or 'audio/wav'
778 async with aiofiles.open(file_path, 'rb') as f:
779 audio_bytes = await f.read()
781 api_key = await Config.get('audio.stt.deepgram.api_key')
782 stt_model = await Config.get('audio.stt.model')
784 r = None
785 try:
786 session = await get_session()
787 for lang in languages:
788 query: dict = {'smart_format': 'true'}
789 if stt_model:
790 query['model'] = stt_model
791 if lang:
792 query['language'] = lang
794 r = await session.post(
795 'https://api.deepgram.com/v1/listen',
796 headers={'Authorization': f'Token {api_key}', 'Content-Type': content_type},
797 params=query,
798 data=audio_bytes,
799 ssl=AIOHTTP_CLIENT_SESSION_SSL,
800 )
801 if r.status == 200:
802 break
804 r.raise_for_status()
805 body = await r.json()
807 # Parse the Deepgram response structure
808 try:
809 transcript = body['results']['channels'][0]['alternatives'][0].get('transcript', '').strip()
810 except (KeyError, IndexError) as exc:
811 log.error(f'Malformed Deepgram response: {exc}')
812 raise Exception('Failed to parse Deepgram response') from exc
814 data = {'text': transcript}
815 async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
816 await f.write(JSONCodec.dumps(data))
817 return data
819 except Exception as e:
820 log.exception(e)
821 # LICENSE covers this Open WebUI error identifier.
822 # Do not alter, remove, obscure, or replace it except as LICENSE permits:
823 # https://docs.openwebui.com/license.
824 detail = 'Open WebUI: Server Connection Error'
825 if r is not None:
826 try:
827 res = await r.json()
828 msg = (
829 res.get('error', {}).get('message', '')
830 if isinstance(res.get('error'), dict)
831 else str(res.get('error', ''))
832 )
833 if msg:
834 detail = f'External: {msg}'
835 except Exception:
836 detail = f'External: {e}'
837 raise Exception(detail)
840async def _transcribe_azure(request, file_path, filename, file_dir, id):
841 """Transcribe audio via Azure Cognitive Services batch transcription."""
842 if not os.path.isfile(file_path):
843 raise HTTPException(status_code=400, detail='Audio file not found')
845 audio_size = os.path.getsize(file_path)
846 if audio_size > AZURE_MAX_FILE_SIZE:
847 raise HTTPException(
848 status_code=status.HTTP_400_BAD_REQUEST,
849 detail=f'File size ({audio_size // (1024 * 1024)}MB) exceeds Azure limit of {AZURE_MAX_FILE_SIZE_MB}MB',
850 )
852 api_key = await Config.get('audio.stt.azure.api_key')
853 region = await Config.get('audio.stt.azure.region') or 'eastus'
854 locale_str = await Config.get('audio.stt.azure.locales')
855 base_url = await Config.get('audio.stt.azure.base_url')
856 max_speakers = await Config.get('audio.stt.azure.max_speakers') or 3
858 # Default to a broad set of locales when none are configured
859 if len(locale_str) < 2:
860 locale_str = ','.join(
861 [
862 'en-US',
863 'es-ES',
864 'es-MX',
865 'fr-FR',
866 'hi-IN',
867 'it-IT',
868 'de-DE',
869 'en-GB',
870 'en-IN',
871 'ja-JP',
872 'ko-KR',
873 'pt-BR',
874 'zh-CN',
875 ]
876 )
878 if not api_key or not region:
879 raise HTTPException(status_code=400, detail='Azure API key and region are required for Azure STT')
881 # Build the transcription definition payload
882 definition = JSONCodec.dumps(
883 {'locales': locale_str.split(','), 'diarization': {'maxSpeakers': max_speakers, 'enabled': True}}
884 if locale_str
885 else {}
886 )
887 endpoint = (
888 base_url or f'https://{region}.api.cognitive.microsoft.com'
889 ) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
891 r = None
892 try:
893 session = await get_session()
894 form_data = aiohttp.FormData()
895 form_data.add_field('definition', definition)
897 async def audio_chunks():
898 async with aiofiles.open(file_path, 'rb') as audio_file:
899 while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
900 yield chunk
902 form_data.add_field('audio', audio_chunks(), filename=filename)
903 r = await session.post(
904 url=endpoint,
905 data=form_data,
906 headers={'Ocp-Apim-Subscription-Key': api_key},
907 ssl=AIOHTTP_CLIENT_SESSION_SSL,
908 )
909 r.raise_for_status()
910 response = await r.json()
912 if not response.get('combinedPhrases'):
913 raise ValueError('No transcription found in response')
915 transcript = response['combinedPhrases'][0].get('text', '').strip()
916 if not transcript:
917 raise ValueError('Empty transcript in response')
919 data = {'text': transcript}
921 async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
922 await f.write(JSONCodec.dumps(data))
924 log.debug(data)
925 return data
927 except (KeyError, IndexError, ValueError) as e:
928 log.exception('Error parsing Azure response')
929 raise HTTPException(status_code=500, detail=f'Failed to parse Azure response: {str(e)}')
930 except aiohttp.ClientResponseError as e:
931 log.exception(e)
932 detail = None
933 try:
934 if r is not None and r.status != 200:
935 res = await r.json()
936 if 'code' in res and 'message' in res:
937 azure_code = res.get('innerError', {}).get('code', res['code'])
938 user_facing_codes = {
939 'EmptyAudioFile',
940 'AudioLengthLimitExceeded',
941 'NoLanguageIdentified',
942 'MultipleLanguagesIdentified',
943 }
944 if azure_code in user_facing_codes:
945 detail = res['message']
946 else:
947 log.error(f'Azure STT error [{azure_code}]: {res["message"]}')
948 detail = 'An error occurred during transcription.'
949 elif 'error' in res:
950 detail = f'External: {res["error"].get("message", "")}'
951 except Exception:
952 detail = f'External: {e}'
953 # LICENSE covers this Open WebUI error identifier.
954 # Do not alter, remove, obscure, or replace it except as LICENSE permits:
955 # https://docs.openwebui.com/license.
956 raise HTTPException(
957 status_code=e.status if e.status else 500,
958 detail=detail if detail else 'Open WebUI: Server Connection Error',
959 )
962async def transcription_handler(request, file_path, metadata, user=None):
963 filename = os.path.basename(file_path)
964 file_dir = os.path.dirname(file_path)
965 id = filename.split('.')[0]
967 metadata = metadata or {}
969 languages = [
970 metadata.get('language', None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
971 None, # Always fallback to None in case transcription fails
972 ]
974 if await Config.get('audio.stt.engine') == '':
975 return await _transcribe_whisper(request, file_path, languages, file_dir, id)
976 elif await Config.get('audio.stt.engine') == 'openai':
977 return await _transcribe_openai(request, file_path, filename, languages, file_dir, id, user)
978 elif await Config.get('audio.stt.engine') == 'deepgram':
979 return await _transcribe_deepgram(request, file_path, languages, file_dir, id)
980 elif await Config.get('audio.stt.engine') == 'azure':
981 return await _transcribe_azure(request, file_path, filename, file_dir, id)
983 elif await Config.get('audio.stt.engine') == 'mistral':
984 return await _transcribe_mistral(request, file_path, filename, metadata, file_dir, id)
987async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, id):
988 """Transcribe audio via the Mistral STT API."""
989 if not os.path.isfile(file_path):
990 raise HTTPException(status_code=400, detail='Audio file not found')
992 file_size = os.path.getsize(file_path)
993 if file_size > MAX_FILE_SIZE:
994 raise HTTPException(status_code=400, detail=f'File size exceeds limit of {MAX_FILE_SIZE_MB}MB')
996 api_key = await Config.get('audio.stt.mistral.api_key')
997 api_base_url = await Config.get('audio.stt.mistral.api_base_url') or 'https://api.mistral.ai/v1'
998 use_chat_completions = await Config.get('audio.stt.mistral.use_chat_completions')
1000 if not api_key:
1001 raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral STT')
1003 r = None
1004 try:
1005 model = await Config.get('audio.stt.model') or 'voxtral-mini-latest'
1006 log.info(
1007 'Mistral STT - model: %s, method: %s',
1008 model,
1009 'chat_completions' if use_chat_completions else 'transcriptions',
1010 )
1012 session = await get_session()
1013 if use_chat_completions:
1014 audio_file_to_use = file_path
1015 if USE_SLIM and Path(filename).suffix.lower() not in ('.mp3', '.wav'):
1016 raise HTTPException(
1017 400, 'Mistral chat transcription requires MP3 or WAV in slim; local conversion is unavailable.'
1018 )
1019 if not BYPASS_PYDUB_PREPROCESSING and is_audio_conversion_required(file_path):
1020 log.debug('Converting audio to mp3 for chat completions API')
1021 converted_path = await asyncio.to_thread(convert_audio_to_mp3, file_path)
1022 if converted_path:
1023 audio_file_to_use = converted_path
1024 else:
1025 log.error('Audio conversion failed')
1026 raise HTTPException(
1027 status_code=500,
1028 detail='Audio conversion failed. Chat completions API requires mp3 or wav format.',
1029 )
1031 async with aiofiles.open(audio_file_to_use, 'rb') as audio_file:
1032 raw = await audio_file.read()
1033 audio_base64 = {
1034 'data': base64.b64encode(raw).decode('utf-8'),
1035 'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'),
1036 }
1038 language = metadata.get('language', None) if metadata else None
1039 text_instruction = (
1040 f'Transcribe this audio exactly as spoken in {language}. Do not translate it.'
1041 if language
1042 else 'Transcribe this audio exactly as spoken in its original language. Do not translate it to another language.'
1043 )
1045 payload = {
1046 'model': model,
1047 'messages': [
1048 {
1049 'role': 'user',
1050 'content': [
1051 {'type': 'input_audio', 'input_audio': audio_base64},
1052 {'type': 'text', 'text': text_instruction},
1053 ],
1054 }
1055 ],
1056 }
1058 r = await session.post(
1059 url=f'{api_base_url}/chat/completions',
1060 json=payload,
1061 headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
1062 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1063 )
1064 r.raise_for_status()
1065 response = await r.json()
1067 transcript = response.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
1068 if not transcript:
1069 raise ValueError('Empty transcript in response')
1070 data = {'text': transcript}
1072 else:
1073 mime_type, _ = mimetypes.guess_type(file_path)
1074 if not mime_type:
1075 mime_type = 'audio/webm'
1077 form_data = aiohttp.FormData()
1078 form_data.add_field('model', model)
1080 language = metadata.get('language', None) if metadata else None
1081 if language:
1082 form_data.add_field('language', language)
1084 async def audio_chunks():
1085 async with aiofiles.open(file_path, 'rb') as audio_file:
1086 while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
1087 yield chunk
1089 form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type)
1091 r = await session.post(
1092 url=f'{api_base_url}/audio/transcriptions',
1093 data=form_data,
1094 headers={'Authorization': f'Bearer {api_key}'},
1095 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1096 )
1097 r.raise_for_status()
1098 response = await r.json()
1100 transcript = response.get('text', '').strip()
1101 if not transcript:
1102 raise ValueError('Empty transcript in response')
1103 data = {'text': transcript}
1105 async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
1106 await f.write(JSONCodec.dumps(data))
1108 log.debug(data)
1109 return data
1111 except ValueError as e:
1112 log.exception('Error parsing Mistral response')
1113 raise HTTPException(status_code=500, detail=f'Failed to parse Mistral response: {str(e)}')
1114 except aiohttp.ClientResponseError as e:
1115 log.exception(e)
1116 detail = None
1117 try:
1118 if r is not None and r.status != 200:
1119 res = await r.json()
1120 if 'error' in res:
1121 detail = f'External: {res["error"].get("message", "")}'
1122 else:
1123 detail = f'External: {await r.text()}'
1124 except Exception:
1125 detail = f'External: {e}'
1126 # LICENSE covers this Open WebUI error identifier.
1127 # Do not alter, remove, obscure, or replace it except as LICENSE permits:
1128 # https://docs.openwebui.com/license.
1129 raise HTTPException(
1130 status_code=e.status if e.status else 500,
1131 detail=detail if detail else 'Open WebUI: Server Connection Error',
1132 )
1135async def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None):
1136 log.info('transcribe: %s %s', file_path, metadata)
1138 if BYPASS_PYDUB_PREPROCESSING:
1139 log.info('Bypassing pydub preprocessing (BYPASS_PYDUB_PREPROCESSING=true)')
1140 chunk_paths = [file_path]
1141 else:
1142 if is_audio_conversion_required(file_path):
1143 file_path = await asyncio.to_thread(convert_audio_to_mp3, file_path)
1144 if not file_path:
1145 raise HTTPException(
1146 status_code=status.HTTP_400_BAD_REQUEST,
1147 detail='Audio conversion failed. The audio file may be corrupted or empty.',
1148 )
1150 try:
1151 file_path = await asyncio.to_thread(compress_audio, file_path)
1152 except Exception as e:
1153 log.exception(e)
1155 # Always produce a list of chunk paths (could be one entry if small)
1156 try:
1157 chunk_paths = await asyncio.to_thread(split_audio, file_path, MAX_FILE_SIZE)
1158 print(f'Chunk paths: {chunk_paths}')
1159 except Exception as e:
1160 log.exception(e)
1161 raise HTTPException(
1162 status_code=status.HTTP_400_BAD_REQUEST,
1163 detail=ERROR_MESSAGES.DEFAULT(e, 'Error processing audio file'),
1164 )
1166 try:
1167 tasks = [transcription_handler(request, chunk_path, metadata, user) for chunk_path in chunk_paths]
1168 # gather keeps results in chunk order, unlike as_completed
1169 results = await asyncio.gather(*tasks)
1170 except HTTPException:
1171 raise
1172 except Exception as transcribe_exc:
1173 raise HTTPException(
1174 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1175 detail=f'Error transcribing chunk: {transcribe_exc}',
1176 )
1177 finally:
1178 # Clean up only the temporary chunks, never the original file
1179 for chunk_path in chunk_paths:
1180 if chunk_path != file_path and os.path.isfile(chunk_path):
1181 try:
1182 await asyncio.to_thread(os.remove, chunk_path)
1183 except Exception:
1184 pass
1186 return {
1187 'text': ' '.join([result['text'] for result in results]),
1188 }
1191def compress_audio(file_path):
1192 if os.path.getsize(file_path) > MAX_FILE_SIZE:
1193 id = os.path.splitext(os.path.basename(file_path))[0] # Handles names with multiple dots
1194 file_dir = os.path.dirname(file_path)
1196 audio = AudioSegment.from_file(file_path)
1197 audio = audio.set_frame_rate(16000).set_channels(1) # Compress audio
1199 compressed_path = os.path.join(file_dir, f'{id}_compressed.mp3')
1200 audio.export(compressed_path, format='mp3', bitrate='32k')
1201 # log.debug(f"Compressed audio to {compressed_path}") # Uncomment if log is defined
1203 return compressed_path
1204 else:
1205 return file_path
1208def split_audio(file_path, max_bytes, format='mp3', bitrate='32k'):
1209 """
1210 Splits audio into chunks not exceeding max_bytes.
1211 Returns a list of chunk file paths. If audio fits, returns list with original path.
1212 """
1213 file_size = os.path.getsize(file_path)
1214 if file_size <= max_bytes:
1215 return [file_path] # Nothing to split
1217 audio = AudioSegment.from_file(file_path)
1218 duration_ms = len(audio)
1219 orig_size = file_size
1221 approx_chunk_ms = max(int(duration_ms * (max_bytes / orig_size)) - 1000, 1000)
1222 chunks = []
1223 start = 0
1224 i = 0
1226 base, _ = os.path.splitext(file_path)
1228 while start < duration_ms:
1229 end = min(start + approx_chunk_ms, duration_ms)
1230 chunk = audio[start:end]
1231 chunk_path = f'{base}_chunk_{i}.{format}'
1232 chunk.export(chunk_path, format=format, bitrate=bitrate)
1234 # Reduce chunk duration if still too large
1235 while os.path.getsize(chunk_path) > max_bytes and (end - start) > 5000:
1236 end = start + ((end - start) // 2)
1237 chunk = audio[start:end]
1238 chunk.export(chunk_path, format=format, bitrate=bitrate)
1240 if os.path.getsize(chunk_path) > max_bytes:
1241 os.remove(chunk_path)
1242 raise Exception('Audio chunk cannot be reduced below max file size.')
1244 chunks.append(chunk_path)
1245 start = end
1246 i += 1
1248 return chunks
1251@router.post('/transcriptions')
1252async def transcription(
1253 request: Request,
1254 file: UploadFile = File(...),
1255 language: Optional[str] = Form(None),
1256 user=Depends(get_verified_user),
1257):
1258 if user.role != 'admin' and not await has_permission(user.id, 'chat.stt', await Config.get('user.permissions')): 1258 ↛ 1259line 1258 didn't jump to line 1259 because the condition on line 1258 was never true
1259 raise HTTPException(
1260 status_code=status.HTTP_403_FORBIDDEN,
1261 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1262 )
1263 log.info('file.content_type: %s', file.content_type)
1264 stt_supported_content_types = await Config.get('audio.stt.supported_content_types', [])
1266 if not strict_match_mime_type(stt_supported_content_types, file.content_type): 1266 ↛ 1272line 1266 didn't jump to line 1272 because the condition on line 1266 was always true
1267 raise HTTPException(
1268 status_code=status.HTTP_400_BAD_REQUEST,
1269 detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,
1270 )
1272 try:
1273 safe_name = os.path.basename(file.filename) if file.filename else ''
1274 ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else ''
1276 allowed_extensions = await Config.get('audio.stt.allowed_extensions', [])
1277 if allowed_extensions and ext not in allowed_extensions:
1278 raise HTTPException(
1279 status_code=status.HTTP_400_BAD_REQUEST,
1280 detail='Invalid audio file extension',
1281 )
1283 id = uuid.uuid4()
1285 filename = f'{id}.{ext}'
1286 contents = await file.read()
1288 file_dir = os.path.join(CACHE_DIR, 'audio', 'transcriptions')
1289 os.makedirs(file_dir, exist_ok=True)
1290 file_path = os.path.join(file_dir, filename)
1292 # Defense-in-depth: ensure resolved path stays within intended directory
1293 if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
1294 raise ValueError('Invalid file path detected')
1296 async with aiofiles.open(file_path, 'wb') as f:
1297 await f.write(contents)
1299 try:
1300 metadata = None
1302 if language:
1303 metadata = {'language': language}
1305 result = await transcribe(request, file_path, metadata, user)
1307 await publish_event(
1308 request,
1309 EVENTS.AUDIO_TRANSCRIPTION_REQUESTED,
1310 actor=user,
1311 subject_id=str(id),
1312 data={
1313 'filename': safe_name,
1314 'content_type': file.content_type,
1315 'language': language,
1316 },
1317 )
1318 return {
1319 **result,
1320 'filename': os.path.basename(file_path),
1321 }
1323 except HTTPException:
1324 raise
1325 except Exception as e:
1326 log.exception(e)
1328 raise HTTPException(
1329 status_code=status.HTTP_400_BAD_REQUEST,
1330 detail='Transcription failed.',
1331 )
1333 except HTTPException:
1334 raise
1335 except Exception as e:
1336 log.exception(e)
1338 raise HTTPException(
1339 status_code=status.HTTP_400_BAD_REQUEST,
1340 detail='Transcription failed.',
1341 )
1344async def get_available_models(request: Request) -> list[dict]:
1345 """Return the list of available TTS models for the configured engine."""
1346 available_models = []
1347 engine = await Config.get('audio.tts.engine')
1348 _timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
1350 if engine == 'openai': 1350 ↛ 1351line 1350 didn't jump to line 1351 because the condition on line 1350 was never true
1351 base_url = await Config.get('audio.tts.openai.api_base_url')
1352 if not base_url.startswith('https://api.openai.com'):
1353 session = await get_session()
1354 try:
1355 async with session.get(
1356 f'{base_url}/audio/models',
1357 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1358 timeout=_timeout,
1359 ) as resp:
1360 resp.raise_for_status()
1361 data = await resp.json()
1362 available_models = data.get('models', [])
1363 except Exception as e:
1364 log.debug('/audio/models not available, trying /models fallback: %s', e)
1365 try:
1366 async with session.get(
1367 f'{base_url}/models',
1368 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1369 timeout=_timeout,
1370 ) as resp:
1371 resp.raise_for_status()
1372 data = await resp.json()
1373 available_models = data.get('data', data.get('models', []))
1374 except Exception as e2:
1375 log.error(f'Error fetching models from custom endpoint: {e2}')
1376 available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
1377 else:
1378 available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
1380 elif engine == 'elevenlabs': 1380 ↛ 1381line 1380 didn't jump to line 1381 because the condition on line 1380 was never true
1381 try:
1382 session = await get_session()
1383 async with session.get(
1384 f'{ELEVENLABS_API_BASE_URL}/v1/models',
1385 headers={
1386 'xi-api-key': await Config.get('audio.tts.api_key'),
1387 'Content-Type': 'application/json',
1388 },
1389 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1390 timeout=_timeout,
1391 ) as resp:
1392 resp.raise_for_status()
1393 models = await resp.json()
1394 available_models = [{'name': m['name'], 'id': m['model_id']} for m in models]
1395 except Exception as e:
1396 log.error(f'Error fetching models: {e}')
1398 elif engine == 'mistral': 1398 ↛ 1399line 1398 didn't jump to line 1399 because the condition on line 1398 was never true
1399 available_models = [{'id': 'voxtral-mini-tts-2603'}]
1401 return available_models
1404@router.get('/models')
1405async def get_models(request: Request, user=Depends(get_verified_user)):
1406 return {'models': await get_available_models(request)}
1409_OPENAI_DEFAULT_VOICES = {
1410 'alloy': 'alloy',
1411 'echo': 'echo',
1412 'fable': 'fable',
1413 'onyx': 'onyx',
1414 'nova': 'nova',
1415 'shimmer': 'shimmer',
1416}
1419async def get_available_voices(request) -> dict:
1420 """Return ``{voice_id: voice_name}`` for the configured TTS engine."""
1421 engine = await Config.get('audio.tts.engine')
1422 _timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
1424 if engine == 'openai': 1424 ↛ 1425line 1424 didn't jump to line 1425 because the condition on line 1424 was never true
1425 base_url = await Config.get('audio.tts.openai.api_base_url')
1426 if not base_url.startswith('https://api.openai.com'):
1427 try:
1428 session = await get_session()
1429 async with session.get(
1430 f'{base_url}/audio/voices',
1431 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1432 timeout=_timeout,
1433 ) as resp:
1434 resp.raise_for_status()
1435 data = await resp.json()
1436 return {v['id']: v['name'] for v in data.get('voices', [])}
1437 except Exception as e:
1438 log.error(f'Error fetching voices from custom endpoint: {e}')
1439 return dict(_OPENAI_DEFAULT_VOICES)
1440 return dict(_OPENAI_DEFAULT_VOICES)
1442 if engine == 'elevenlabs': 1442 ↛ 1443line 1442 didn't jump to line 1443 because the condition on line 1442 was never true
1443 try:
1444 session = await get_session()
1445 async with session.get(
1446 f'{ELEVENLABS_API_BASE_URL}/v1/voices',
1447 headers={
1448 'xi-api-key': await Config.get('audio.tts.api_key'),
1449 'Content-Type': 'application/json',
1450 },
1451 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1452 timeout=_timeout,
1453 ) as resp:
1454 resp.raise_for_status()
1455 voices_data = await resp.json()
1456 return {v['voice_id']: v['name'] for v in voices_data.get('voices', [])}
1457 except Exception as e:
1458 log.warning(f'Error fetching ElevenLabs voices: {e}')
1459 return {}
1461 if engine == 'azure': 1461 ↛ 1462line 1461 didn't jump to line 1462 because the condition on line 1461 was never true
1462 try:
1463 region = await Config.get('audio.tts.azure.speech_region')
1464 base_url = await Config.get('audio.tts.azure.speech_base_url')
1465 url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list'
1467 session = await get_session()
1468 async with session.get(
1469 url,
1470 headers={'Ocp-Apim-Subscription-Key': await Config.get('audio.tts.api_key')},
1471 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1472 timeout=_timeout,
1473 ) as resp:
1474 resp.raise_for_status()
1475 voices = await resp.json()
1476 return {v['ShortName']: f'{v["DisplayName"]} ({v["ShortName"]})' for v in voices}
1477 except Exception as e:
1478 log.error(f'Error fetching Azure voices: {e}')
1479 return {}
1481 if engine == 'mistral': 1481 ↛ 1482line 1481 didn't jump to line 1482 because the condition on line 1481 was never true
1482 api_key = await Config.get('audio.tts.mistral.api_key')
1483 api_base_url = await Config.get('audio.tts.mistral.api_base_url') or 'https://api.mistral.ai/v1'
1484 if api_key:
1485 try:
1486 session = await get_session()
1487 async with session.get(
1488 f'{api_base_url}/audio/voices',
1489 headers={'Authorization': f'Bearer {api_key}'},
1490 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1491 timeout=_timeout,
1492 ) as resp:
1493 resp.raise_for_status()
1494 voices_data = await resp.json()
1495 items = voices_data.get('items', []) if isinstance(voices_data, dict) else voices_data
1496 result = {}
1497 for v in items:
1498 if isinstance(v, dict):
1499 vid = v.get('voice_id', v.get('id', ''))
1500 if vid:
1501 result[vid] = v.get('name', vid)
1502 return result
1503 except Exception as e:
1504 log.error(f'Error fetching Mistral voices: {e}')
1506 return {}
1509@router.get('/voices')
1510async def get_voices(request: Request, user=Depends(get_verified_user)):
1511 return {'voices': [{'id': k, 'name': v} for k, v in (await get_available_voices(request)).items()]}