refac: audio
This commit is contained in:
1 parent
c306a7e16e
commit
2ca91ceeec
1 file changed
+325
-421
+325
-421
@@ -8,13 +8,11 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from fnmatch import fnmatch
|
from fnmatch import fnmatch
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import requests
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
@@ -687,7 +685,230 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def transcription_handler(request, file_path, metadata, user=None):
|
async def _transcribe_whisper(request, file_path, languages, file_dir, id):
|
||||||
|
if request.app.state.faster_whisper_model is None:
|
||||||
|
request.app.state.faster_whisper_model = set_faster_whisper_model(request.app.state.config.WHISPER_MODEL)
|
||||||
|
|
||||||
|
model = request.app.state.faster_whisper_model
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
segments, info = model.transcribe(
|
||||||
|
file_path, beam_size=5, vad_filter=WHISPER_VAD_FILTER,
|
||||||
|
language=languages[0], multilingual=WHISPER_MULTILINGUAL,
|
||||||
|
)
|
||||||
|
log.info("Detected language '%s' with probability %f" % (info.language, info.language_probability))
|
||||||
|
return ''.join([segment.text for segment in list(segments)])
|
||||||
|
|
||||||
|
transcript = await asyncio.to_thread(_run)
|
||||||
|
data = {'text': transcript.strip()}
|
||||||
|
|
||||||
|
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||||
|
await f.write(json.dumps(data))
|
||||||
|
|
||||||
|
log.debug(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def _transcribe_openai(request, file_path, filename, languages, file_dir, id, user=None):
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||||
|
for language in languages:
|
||||||
|
payload = {'model': request.app.state.config.STT_MODEL}
|
||||||
|
if language:
|
||||||
|
payload['language'] = language
|
||||||
|
|
||||||
|
headers = {'Authorization': f'Bearer {request.app.state.config.STT_OPENAI_API_KEY}'}
|
||||||
|
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
|
||||||
|
headers = include_user_info_headers(headers, user)
|
||||||
|
|
||||||
|
form_data = aiohttp.FormData()
|
||||||
|
for key, value in payload.items():
|
||||||
|
form_data.add_field(key, str(value))
|
||||||
|
form_data.add_field('file', open(file_path, 'rb'), filename=filename)
|
||||||
|
|
||||||
|
r = await session.post(
|
||||||
|
url=f'{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions',
|
||||||
|
headers=headers, data=form_data, ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
|
)
|
||||||
|
if r.status == 200:
|
||||||
|
break
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
data = await r.json()
|
||||||
|
|
||||||
|
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||||
|
await f.write(json.dumps(data))
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
detail = None
|
||||||
|
if r is not None:
|
||||||
|
try:
|
||||||
|
res = await r.json()
|
||||||
|
if 'error' in res:
|
||||||
|
detail = f'External: {res["error"].get("message", "")}'
|
||||||
|
except Exception:
|
||||||
|
detail = f'External: {e}'
|
||||||
|
raise Exception(detail if detail else 'Open WebUI: Server Connection Error')
|
||||||
|
|
||||||
|
|
||||||
|
async def _transcribe_deepgram(request, file_path, languages, file_dir, id):
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
mime, _ = mimetypes.guess_type(file_path)
|
||||||
|
if not mime:
|
||||||
|
mime = 'audio/wav'
|
||||||
|
|
||||||
|
async with aiofiles.open(file_path, 'rb') as f:
|
||||||
|
file_data = await f.read()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Token {request.app.state.config.DEEPGRAM_API_KEY}',
|
||||||
|
'Content-Type': mime,
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||||
|
for language in languages:
|
||||||
|
params = {}
|
||||||
|
if request.app.state.config.STT_MODEL:
|
||||||
|
params['model'] = request.app.state.config.STT_MODEL
|
||||||
|
if language:
|
||||||
|
params['language'] = language
|
||||||
|
|
||||||
|
r = await session.post(
|
||||||
|
'https://api.deepgram.com/v1/listen?smart_format=true',
|
||||||
|
headers=headers, params=params, data=file_data,
|
||||||
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
|
)
|
||||||
|
if r.status == 200:
|
||||||
|
break
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
response_data = await r.json()
|
||||||
|
|
||||||
|
try:
|
||||||
|
transcript = response_data['results']['channels'][0]['alternatives'][0].get('transcript', '')
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
log.error(f'Malformed response from Deepgram: {str(e)}')
|
||||||
|
raise Exception('Failed to parse Deepgram response - unexpected response format')
|
||||||
|
data = {'text': transcript.strip()}
|
||||||
|
|
||||||
|
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||||
|
await f.write(json.dumps(data))
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
detail = None
|
||||||
|
if r is not None:
|
||||||
|
try:
|
||||||
|
res = await r.json()
|
||||||
|
if 'error' in res:
|
||||||
|
detail = f'External: {res["error"].get("message", "")}'
|
||||||
|
except Exception:
|
||||||
|
detail = f'External: {e}'
|
||||||
|
raise Exception(detail if detail else 'Open WebUI: Server Connection Error')
|
||||||
|
|
||||||
|
|
||||||
|
async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=400, detail='Audio file not found')
|
||||||
|
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
if file_size > AZURE_MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File size exceeds Azure's limit of {AZURE_MAX_FILE_SIZE_MB}MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
|
||||||
|
region = request.app.state.config.AUDIO_STT_AZURE_REGION or 'eastus'
|
||||||
|
locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES
|
||||||
|
base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL
|
||||||
|
max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3
|
||||||
|
|
||||||
|
if len(locales) < 2:
|
||||||
|
locales = ','.join([
|
||||||
|
'en-US', 'es-ES', 'es-MX', 'fr-FR', 'hi-IN', 'it-IT', 'de-DE',
|
||||||
|
'en-GB', 'en-IN', 'ja-JP', 'ko-KR', 'pt-BR', 'zh-CN',
|
||||||
|
])
|
||||||
|
|
||||||
|
if not api_key or not region:
|
||||||
|
raise HTTPException(status_code=400, detail='Azure API key is required for Azure STT')
|
||||||
|
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
definition = json.dumps(
|
||||||
|
{'locales': locales.split(','), 'diarization': {'maxSpeakers': max_speakers, 'enabled': True}}
|
||||||
|
if locales else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
url = (
|
||||||
|
base_url or f'https://{region}.api.cognitive.microsoft.com'
|
||||||
|
) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
|
||||||
|
|
||||||
|
form_data = aiohttp.FormData()
|
||||||
|
form_data.add_field('definition', definition)
|
||||||
|
form_data.add_field('audio', open(file_path, 'rb'), filename=filename)
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||||
|
r = await session.post(
|
||||||
|
url=url, data=form_data,
|
||||||
|
headers={'Ocp-Apim-Subscription-Key': api_key},
|
||||||
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
response = await r.json()
|
||||||
|
|
||||||
|
if not response.get('combinedPhrases'):
|
||||||
|
raise ValueError('No transcription found in response')
|
||||||
|
|
||||||
|
transcript = response['combinedPhrases'][0].get('text', '').strip()
|
||||||
|
if not transcript:
|
||||||
|
raise ValueError('Empty transcript in response')
|
||||||
|
|
||||||
|
data = {'text': transcript}
|
||||||
|
|
||||||
|
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||||
|
await f.write(json.dumps(data))
|
||||||
|
|
||||||
|
log.debug(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
except (KeyError, IndexError, ValueError) as e:
|
||||||
|
log.exception('Error parsing Azure response')
|
||||||
|
raise HTTPException(status_code=500, detail=f'Failed to parse Azure response: {str(e)}')
|
||||||
|
except aiohttp.ClientResponseError as e:
|
||||||
|
log.exception(e)
|
||||||
|
detail = None
|
||||||
|
try:
|
||||||
|
if r is not None and r.status != 200:
|
||||||
|
res = await r.json()
|
||||||
|
if 'code' in res and 'message' in res:
|
||||||
|
azure_code = res.get('innerError', {}).get('code', res['code'])
|
||||||
|
user_facing_codes = {
|
||||||
|
'EmptyAudioFile', 'AudioLengthLimitExceeded',
|
||||||
|
'NoLanguageIdentified', 'MultipleLanguagesIdentified',
|
||||||
|
}
|
||||||
|
if azure_code in user_facing_codes:
|
||||||
|
detail = res['message']
|
||||||
|
else:
|
||||||
|
log.error(f'Azure STT error [{azure_code}]: {res["message"]}')
|
||||||
|
detail = 'An error occurred during transcription.'
|
||||||
|
elif 'error' in res:
|
||||||
|
detail = f'External: {res["error"].get("message", "")}'
|
||||||
|
except Exception:
|
||||||
|
detail = f'External: {e}'
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.status if e.status else 500,
|
||||||
|
detail=detail if detail else 'Open WebUI: Server Connection Error',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def transcription_handler(request, file_path, metadata, user=None):
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
file_dir = os.path.dirname(file_path)
|
file_dir = os.path.dirname(file_path)
|
||||||
id = filename.split('.')[0]
|
id = filename.split('.')[0]
|
||||||
@@ -700,323 +921,48 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||||||
]
|
]
|
||||||
|
|
||||||
if request.app.state.config.STT_ENGINE == '':
|
if request.app.state.config.STT_ENGINE == '':
|
||||||
if request.app.state.faster_whisper_model is None:
|
return await _transcribe_whisper(request, file_path, languages, file_dir, id)
|
||||||
request.app.state.faster_whisper_model = set_faster_whisper_model(request.app.state.config.WHISPER_MODEL)
|
|
||||||
|
|
||||||
model = request.app.state.faster_whisper_model
|
|
||||||
segments, info = model.transcribe(
|
|
||||||
file_path,
|
|
||||||
beam_size=5,
|
|
||||||
vad_filter=WHISPER_VAD_FILTER,
|
|
||||||
language=languages[0],
|
|
||||||
multilingual=WHISPER_MULTILINGUAL,
|
|
||||||
)
|
|
||||||
log.info("Detected language '%s' with probability %f" % (info.language, info.language_probability))
|
|
||||||
|
|
||||||
transcript = ''.join([segment.text for segment in list(segments)])
|
|
||||||
data = {'text': transcript.strip()}
|
|
||||||
|
|
||||||
# save the transcript to a json file
|
|
||||||
transcript_file = os.path.join(file_dir, f'{id}.json')
|
|
||||||
with open(transcript_file, 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
log.debug(data)
|
|
||||||
return data
|
|
||||||
elif request.app.state.config.STT_ENGINE == 'openai':
|
elif request.app.state.config.STT_ENGINE == 'openai':
|
||||||
r = None
|
return await _transcribe_openai(request, file_path, filename, languages, file_dir, id, user)
|
||||||
try:
|
|
||||||
for language in languages:
|
|
||||||
payload = {
|
|
||||||
'model': request.app.state.config.STT_MODEL,
|
|
||||||
}
|
|
||||||
|
|
||||||
if language:
|
|
||||||
payload['language'] = language
|
|
||||||
|
|
||||||
headers = {'Authorization': f'Bearer {request.app.state.config.STT_OPENAI_API_KEY}'}
|
|
||||||
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
|
|
||||||
headers = include_user_info_headers(headers, user)
|
|
||||||
|
|
||||||
with open(file_path, 'rb') as audio_file:
|
|
||||||
r = requests.post(
|
|
||||||
url=f'{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions',
|
|
||||||
headers=headers,
|
|
||||||
files={'file': (filename, audio_file)},
|
|
||||||
data=payload,
|
|
||||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if r.status_code == 200:
|
|
||||||
# Successful transcription
|
|
||||||
break
|
|
||||||
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
# save the transcript to a json file
|
|
||||||
transcript_file = os.path.join(file_dir, f'{id}.json')
|
|
||||||
with open(transcript_file, 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
return data
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
|
|
||||||
detail = None
|
|
||||||
if r is not None:
|
|
||||||
try:
|
|
||||||
res = r.json()
|
|
||||||
if 'error' in res:
|
|
||||||
detail = f'External: {res["error"].get("message", "")}'
|
|
||||||
except Exception:
|
|
||||||
detail = f'External: {e}'
|
|
||||||
|
|
||||||
raise Exception(detail if detail else 'Open WebUI: Server Connection Error')
|
|
||||||
|
|
||||||
elif request.app.state.config.STT_ENGINE == 'deepgram':
|
elif request.app.state.config.STT_ENGINE == 'deepgram':
|
||||||
try:
|
return await _transcribe_deepgram(request, file_path, languages, file_dir, id)
|
||||||
# Determine the MIME type of the file
|
|
||||||
mime, _ = mimetypes.guess_type(file_path)
|
|
||||||
if not mime:
|
|
||||||
mime = 'audio/wav' # fallback to wav if undetectable
|
|
||||||
|
|
||||||
# Read the audio file
|
|
||||||
with open(file_path, 'rb') as f:
|
|
||||||
file_data = f.read()
|
|
||||||
|
|
||||||
# Build headers and parameters
|
|
||||||
headers = {
|
|
||||||
'Authorization': f'Token {request.app.state.config.DEEPGRAM_API_KEY}',
|
|
||||||
'Content-Type': mime,
|
|
||||||
}
|
|
||||||
|
|
||||||
for language in languages:
|
|
||||||
params = {}
|
|
||||||
if request.app.state.config.STT_MODEL:
|
|
||||||
params['model'] = request.app.state.config.STT_MODEL
|
|
||||||
|
|
||||||
if language:
|
|
||||||
params['language'] = language
|
|
||||||
|
|
||||||
# Make request to Deepgram API
|
|
||||||
r = requests.post(
|
|
||||||
'https://api.deepgram.com/v1/listen?smart_format=true',
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
data=file_data,
|
|
||||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
if r.status_code == 200:
|
|
||||||
# Successful transcription
|
|
||||||
break
|
|
||||||
|
|
||||||
r.raise_for_status()
|
|
||||||
response_data = r.json()
|
|
||||||
|
|
||||||
# Extract transcript from Deepgram response
|
|
||||||
try:
|
|
||||||
transcript = response_data['results']['channels'][0]['alternatives'][0].get('transcript', '')
|
|
||||||
except (KeyError, IndexError) as e:
|
|
||||||
log.error(f'Malformed response from Deepgram: {str(e)}')
|
|
||||||
raise Exception('Failed to parse Deepgram response - unexpected response format')
|
|
||||||
data = {'text': transcript.strip()}
|
|
||||||
|
|
||||||
# Save transcript
|
|
||||||
transcript_file = os.path.join(file_dir, f'{id}.json')
|
|
||||||
with open(transcript_file, 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
detail = None
|
|
||||||
if r is not None:
|
|
||||||
try:
|
|
||||||
res = r.json()
|
|
||||||
if 'error' in res:
|
|
||||||
detail = f'External: {res["error"].get("message", "")}'
|
|
||||||
except Exception:
|
|
||||||
detail = f'External: {e}'
|
|
||||||
raise Exception(detail if detail else 'Open WebUI: Server Connection Error')
|
|
||||||
|
|
||||||
elif request.app.state.config.STT_ENGINE == 'azure':
|
elif request.app.state.config.STT_ENGINE == 'azure':
|
||||||
# Check file exists and size
|
return await _transcribe_azure(request, file_path, filename, file_dir, id)
|
||||||
if not os.path.exists(file_path):
|
|
||||||
raise HTTPException(status_code=400, detail='Audio file not found')
|
|
||||||
|
|
||||||
# Check file size (Azure has a larger limit of 200MB)
|
|
||||||
file_size = os.path.getsize(file_path)
|
|
||||||
if file_size > AZURE_MAX_FILE_SIZE:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"File size exceeds Azure's limit of {AZURE_MAX_FILE_SIZE_MB}MB",
|
|
||||||
)
|
|
||||||
|
|
||||||
api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
|
|
||||||
region = request.app.state.config.AUDIO_STT_AZURE_REGION or 'eastus'
|
|
||||||
locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES
|
|
||||||
base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL
|
|
||||||
max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3
|
|
||||||
|
|
||||||
# IF NO LOCALES, USE DEFAULTS
|
|
||||||
if len(locales) < 2:
|
|
||||||
locales = [
|
|
||||||
'en-US',
|
|
||||||
'es-ES',
|
|
||||||
'es-MX',
|
|
||||||
'fr-FR',
|
|
||||||
'hi-IN',
|
|
||||||
'it-IT',
|
|
||||||
'de-DE',
|
|
||||||
'en-GB',
|
|
||||||
'en-IN',
|
|
||||||
'ja-JP',
|
|
||||||
'ko-KR',
|
|
||||||
'pt-BR',
|
|
||||||
'zh-CN',
|
|
||||||
]
|
|
||||||
locales = ','.join(locales)
|
|
||||||
|
|
||||||
if not api_key or not region:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail='Azure API key is required for Azure STT',
|
|
||||||
)
|
|
||||||
|
|
||||||
r = None
|
|
||||||
try:
|
|
||||||
# Prepare the request
|
|
||||||
data = {
|
|
||||||
'definition': json.dumps(
|
|
||||||
{
|
|
||||||
'locales': locales.split(','),
|
|
||||||
'diarization': {'maxSpeakers': max_speakers, 'enabled': True},
|
|
||||||
}
|
|
||||||
if locales
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
url = (
|
|
||||||
base_url or f'https://{region}.api.cognitive.microsoft.com'
|
|
||||||
) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
|
|
||||||
|
|
||||||
# Use context manager to ensure file is properly closed
|
|
||||||
with open(file_path, 'rb') as audio_file:
|
|
||||||
r = requests.post(
|
|
||||||
url=url,
|
|
||||||
files={'audio': audio_file},
|
|
||||||
data=data,
|
|
||||||
headers={
|
|
||||||
'Ocp-Apim-Subscription-Key': api_key,
|
|
||||||
},
|
|
||||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
r.raise_for_status()
|
|
||||||
response = r.json()
|
|
||||||
|
|
||||||
# Extract transcript from response
|
|
||||||
if not response.get('combinedPhrases'):
|
|
||||||
raise ValueError('No transcription found in response')
|
|
||||||
|
|
||||||
# Get the full transcript from combinedPhrases
|
|
||||||
transcript = response['combinedPhrases'][0].get('text', '').strip()
|
|
||||||
if not transcript:
|
|
||||||
raise ValueError('Empty transcript in response')
|
|
||||||
|
|
||||||
data = {'text': transcript}
|
|
||||||
|
|
||||||
# Save transcript to json file (consistent with other providers)
|
|
||||||
transcript_file = os.path.join(file_dir, f'{id}.json')
|
|
||||||
with open(transcript_file, 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
log.debug(data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
except (KeyError, IndexError, ValueError) as e:
|
|
||||||
log.exception('Error parsing Azure response')
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f'Failed to parse Azure response: {str(e)}',
|
|
||||||
)
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
log.exception(e)
|
|
||||||
detail = None
|
|
||||||
status_code = getattr(r, 'status_code', 500) if r else 500
|
|
||||||
|
|
||||||
try:
|
|
||||||
if r is not None and r.status_code != 200:
|
|
||||||
res = r.json()
|
|
||||||
# Azure returns {"code": "...", "message": "...", "innerError": {...}}
|
|
||||||
if 'code' in res and 'message' in res:
|
|
||||||
azure_code = res.get('innerError', {}).get('code', res['code'])
|
|
||||||
user_facing_codes = {
|
|
||||||
'EmptyAudioFile',
|
|
||||||
'AudioLengthLimitExceeded',
|
|
||||||
'NoLanguageIdentified',
|
|
||||||
'MultipleLanguagesIdentified',
|
|
||||||
}
|
|
||||||
if azure_code in user_facing_codes:
|
|
||||||
detail = res['message']
|
|
||||||
else:
|
|
||||||
log.error(f'Azure STT error [{azure_code}]: {res["message"]}')
|
|
||||||
detail = 'An error occurred during transcription.'
|
|
||||||
elif 'error' in res:
|
|
||||||
detail = f'External: {res["error"].get("message", "")}'
|
|
||||||
except Exception:
|
|
||||||
detail = f'External: {e}'
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status_code,
|
|
||||||
detail=detail if detail else 'Open WebUI: Server Connection Error',
|
|
||||||
)
|
|
||||||
|
|
||||||
elif request.app.state.config.STT_ENGINE == 'mistral':
|
elif request.app.state.config.STT_ENGINE == 'mistral':
|
||||||
# Check file exists
|
return await _transcribe_mistral(request, file_path, filename, metadata, file_dir, id)
|
||||||
if not os.path.exists(file_path):
|
|
||||||
raise HTTPException(status_code=400, detail='Audio file not found')
|
|
||||||
|
|
||||||
# Check file size
|
|
||||||
file_size = os.path.getsize(file_path)
|
|
||||||
if file_size > MAX_FILE_SIZE:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f'File size exceeds limit of {MAX_FILE_SIZE_MB}MB',
|
|
||||||
)
|
|
||||||
|
|
||||||
api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY
|
async def _transcribe_mistral(request, file_path, filename, metadata, file_dir, id):
|
||||||
api_base_url = request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
|
if not os.path.exists(file_path):
|
||||||
use_chat_completions = request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS
|
raise HTTPException(status_code=400, detail='Audio file not found')
|
||||||
|
|
||||||
if not api_key:
|
file_size = os.path.getsize(file_path)
|
||||||
raise HTTPException(
|
if file_size > MAX_FILE_SIZE:
|
||||||
status_code=400,
|
raise HTTPException(status_code=400, detail=f'File size exceeds limit of {MAX_FILE_SIZE_MB}MB')
|
||||||
detail='Mistral API key is required for Mistral STT',
|
|
||||||
)
|
|
||||||
|
|
||||||
r = None
|
api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY
|
||||||
try:
|
api_base_url = request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1'
|
||||||
# Use voxtral-mini-latest as the default model for transcription
|
use_chat_completions = request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS
|
||||||
model = request.app.state.config.STT_MODEL or 'voxtral-mini-latest'
|
|
||||||
|
|
||||||
log.info(
|
if not api_key:
|
||||||
f'Mistral STT - model: {model}, '
|
raise HTTPException(status_code=400, detail='Mistral API key is required for Mistral STT')
|
||||||
f'method: {"chat_completions" if use_chat_completions else "transcriptions"}'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
model = request.app.state.config.STT_MODEL or 'voxtral-mini-latest'
|
||||||
|
log.info(
|
||||||
|
f'Mistral STT - model: {model}, '
|
||||||
|
f'method: {"chat_completions" if use_chat_completions else "transcriptions"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||||
if use_chat_completions:
|
if use_chat_completions:
|
||||||
# Use chat completions API with audio input
|
|
||||||
# This method requires mp3 or wav format
|
|
||||||
audio_file_to_use = file_path
|
audio_file_to_use = file_path
|
||||||
|
|
||||||
if is_audio_conversion_required(file_path):
|
if is_audio_conversion_required(file_path):
|
||||||
log.debug('Converting audio to mp3 for chat completions API')
|
log.debug('Converting audio to mp3 for chat completions API')
|
||||||
converted_path = convert_audio_to_mp3(file_path)
|
converted_path = await asyncio.to_thread(convert_audio_to_mp3, file_path)
|
||||||
if converted_path:
|
if converted_path:
|
||||||
audio_file_to_use = converted_path
|
audio_file_to_use = converted_path
|
||||||
else:
|
else:
|
||||||
@@ -1026,133 +972,99 @@ def transcription_handler(request, file_path, metadata, user=None):
|
|||||||
detail='Audio conversion failed. Chat completions API requires mp3 or wav format.',
|
detail='Audio conversion failed. Chat completions API requires mp3 or wav format.',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read and encode audio file as base64
|
async with aiofiles.open(audio_file_to_use, 'rb') as audio_file:
|
||||||
with open(audio_file_to_use, 'rb') as audio_file:
|
raw = await audio_file.read()
|
||||||
audio_base64 = {
|
audio_base64 = {
|
||||||
'data': base64.b64encode(audio_file.read()).decode('utf-8'),
|
'data': base64.b64encode(raw).decode('utf-8'),
|
||||||
'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'),
|
'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Prepare chat completions request
|
|
||||||
url = f'{api_base_url}/chat/completions'
|
|
||||||
|
|
||||||
# Add language instruction if specified
|
|
||||||
language = metadata.get('language', None) if metadata else None
|
language = metadata.get('language', None) if metadata else None
|
||||||
if language:
|
text_instruction = (
|
||||||
text_instruction = f'Transcribe this audio exactly as spoken in {language}. Do not translate it.'
|
f'Transcribe this audio exactly as spoken in {language}. Do not translate it.'
|
||||||
else:
|
if language
|
||||||
text_instruction = 'Transcribe this audio exactly as spoken in its original language. Do not translate it to another language.'
|
else 'Transcribe this audio exactly as spoken in its original language. Do not translate it to another language.'
|
||||||
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'model': model,
|
'model': model,
|
||||||
'messages': [
|
'messages': [{
|
||||||
{
|
'role': 'user',
|
||||||
'role': 'user',
|
'content': [
|
||||||
'content': [
|
{'type': 'input_audio', 'input_audio': audio_base64},
|
||||||
{
|
{'type': 'text', 'text': text_instruction},
|
||||||
'type': 'input_audio',
|
],
|
||||||
'input_audio': audio_base64,
|
}],
|
||||||
},
|
|
||||||
{'type': 'text', 'text': text_instruction},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r = requests.post(
|
r = await session.post(
|
||||||
url=url,
|
url=f'{api_base_url}/chat/completions', json=payload,
|
||||||
json=payload,
|
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
|
||||||
headers={
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
response = r.json()
|
response = await r.json()
|
||||||
|
|
||||||
# Extract transcript from chat completion response
|
|
||||||
transcript = response.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
|
transcript = response.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
|
||||||
if not transcript:
|
if not transcript:
|
||||||
raise ValueError('Empty transcript in response')
|
raise ValueError('Empty transcript in response')
|
||||||
|
|
||||||
data = {'text': transcript}
|
data = {'text': transcript}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Use dedicated transcriptions API
|
|
||||||
url = f'{api_base_url}/audio/transcriptions'
|
|
||||||
|
|
||||||
# Determine the MIME type
|
|
||||||
mime_type, _ = mimetypes.guess_type(file_path)
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
if not mime_type:
|
if not mime_type:
|
||||||
mime_type = 'audio/webm'
|
mime_type = 'audio/webm'
|
||||||
|
|
||||||
# Use context manager to ensure file is properly closed
|
form_data = aiohttp.FormData()
|
||||||
with open(file_path, 'rb') as audio_file:
|
form_data.add_field('model', model)
|
||||||
files = {'file': (filename, audio_file, mime_type)}
|
|
||||||
data_form = {'model': model}
|
|
||||||
|
|
||||||
# Add language if specified in metadata
|
language = metadata.get('language', None) if metadata else None
|
||||||
language = metadata.get('language', None) if metadata else None
|
if language:
|
||||||
if language:
|
form_data.add_field('language', language)
|
||||||
data_form['language'] = language
|
|
||||||
|
|
||||||
r = requests.post(
|
form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type)
|
||||||
url=url,
|
|
||||||
files=files,
|
|
||||||
data=data_form,
|
|
||||||
headers={
|
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
},
|
|
||||||
timeout=AIOHTTP_CLIENT_TIMEOUT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
r = await session.post(
|
||||||
|
url=f'{api_base_url}/audio/transcriptions', data=form_data,
|
||||||
|
headers={'Authorization': f'Bearer {api_key}'},
|
||||||
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||||
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
response = r.json()
|
response = await r.json()
|
||||||
|
|
||||||
# Extract transcript from response
|
|
||||||
transcript = response.get('text', '').strip()
|
transcript = response.get('text', '').strip()
|
||||||
if not transcript:
|
if not transcript:
|
||||||
raise ValueError('Empty transcript in response')
|
raise ValueError('Empty transcript in response')
|
||||||
|
|
||||||
data = {'text': transcript}
|
data = {'text': transcript}
|
||||||
|
|
||||||
# Save transcript to json file (consistent with other providers)
|
async with aiofiles.open(os.path.join(file_dir, f'{id}.json'), 'w') as f:
|
||||||
transcript_file = os.path.join(file_dir, f'{id}.json')
|
await f.write(json.dumps(data))
|
||||||
with open(transcript_file, 'w') as f:
|
|
||||||
json.dump(data, f)
|
|
||||||
|
|
||||||
log.debug(data)
|
log.debug(data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
log.exception('Error parsing Mistral response')
|
log.exception('Error parsing Mistral response')
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail=f'Failed to parse Mistral response: {str(e)}')
|
||||||
status_code=500,
|
except aiohttp.ClientResponseError as e:
|
||||||
detail=f'Failed to parse Mistral response: {str(e)}',
|
log.exception(e)
|
||||||
)
|
detail = None
|
||||||
except requests.exceptions.RequestException as e:
|
try:
|
||||||
log.exception(e)
|
if r is not None and r.status != 200:
|
||||||
detail = None
|
res = await r.json()
|
||||||
|
if 'error' in res:
|
||||||
try:
|
detail = f'External: {res["error"].get("message", "")}'
|
||||||
if r is not None and r.status_code != 200:
|
else:
|
||||||
res = r.json()
|
detail = f'External: {await r.text()}'
|
||||||
if 'error' in res:
|
except Exception:
|
||||||
detail = f'External: {res["error"].get("message", "")}'
|
detail = f'External: {e}'
|
||||||
else:
|
raise HTTPException(
|
||||||
detail = f'External: {r.text}'
|
status_code=e.status if e.status else 500,
|
||||||
except Exception:
|
detail=detail if detail else 'Open WebUI: Server Connection Error',
|
||||||
detail = f'External: {e}'
|
)
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=getattr(r, 'status_code', 500) if r else 500,
|
|
||||||
detail=detail if detail else 'Open WebUI: Server Connection Error',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None):
|
async def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None):
|
||||||
log.info(f'transcribe: {file_path} {metadata}')
|
log.info(f'transcribe: {file_path} {metadata}')
|
||||||
|
|
||||||
if BYPASS_PYDUB_PREPROCESSING:
|
if BYPASS_PYDUB_PREPROCESSING:
|
||||||
@@ -1160,7 +1072,7 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
|
|||||||
chunk_paths = [file_path]
|
chunk_paths = [file_path]
|
||||||
else:
|
else:
|
||||||
if is_audio_conversion_required(file_path):
|
if is_audio_conversion_required(file_path):
|
||||||
file_path = convert_audio_to_mp3(file_path)
|
file_path = await asyncio.to_thread(convert_audio_to_mp3, file_path)
|
||||||
if not file_path:
|
if not file_path:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -1168,13 +1080,13 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_path = compress_audio(file_path)
|
file_path = await asyncio.to_thread(compress_audio, file_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
|
|
||||||
# Always produce a list of chunk paths (could be one entry if small)
|
# Always produce a list of chunk paths (could be one entry if small)
|
||||||
try:
|
try:
|
||||||
chunk_paths = split_audio(file_path, MAX_FILE_SIZE)
|
chunk_paths = await asyncio.to_thread(split_audio, file_path, MAX_FILE_SIZE)
|
||||||
print(f'Chunk paths: {chunk_paths}')
|
print(f'Chunk paths: {chunk_paths}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
@@ -1185,28 +1097,20 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
if getattr(request.app.state.config, 'STT_ENGINE', '') == '':
|
tasks = [
|
||||||
max_workers = 1
|
transcription_handler(request, chunk_path, metadata, user)
|
||||||
else:
|
for chunk_path in chunk_paths
|
||||||
max_workers = None
|
]
|
||||||
|
for coro in asyncio.as_completed(tasks):
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
try:
|
||||||
# Submit tasks for each chunk_path
|
results.append(await coro)
|
||||||
futures = [
|
except HTTPException:
|
||||||
executor.submit(transcription_handler, request, chunk_path, metadata, user)
|
raise
|
||||||
for chunk_path in chunk_paths
|
except Exception as transcribe_exc:
|
||||||
]
|
raise HTTPException(
|
||||||
# Gather results as they complete
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
for future in futures:
|
detail=f'Error transcribing chunk: {transcribe_exc}',
|
||||||
try:
|
)
|
||||||
results.append(future.result())
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as transcribe_exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f'Error transcribing chunk: {transcribe_exc}',
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up only the temporary chunks, never the original file
|
# Clean up only the temporary chunks, never the original file
|
||||||
for chunk_path in chunk_paths:
|
for chunk_path in chunk_paths:
|
||||||
@@ -1337,7 +1241,7 @@ async def transcription(
|
|||||||
if language:
|
if language:
|
||||||
metadata = {'language': language}
|
metadata = {'language': language}
|
||||||
|
|
||||||
result = await asyncio.to_thread(transcribe, request, file_path, metadata, user)
|
result = await transcribe(request, file_path, metadata, user)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
**result,
|
**result,
|
||||||
|
|||||||
Reference in new issue
Block a user